Skip to content

feat: s2-stream-config header for auto-created streams - #718

Open
infiniteregrets wants to merge 27 commits into
mainfrom
m/create-stream-config-on-append
Open

feat: s2-stream-config header for auto-created streams#718
infiniteregrets wants to merge 27 commits into
mainfrom
m/create-stream-config-on-append

Conversation

@infiniteregrets

@infiniteregrets infiniteregrets commented Sep 3, 2026

Copy link
Copy Markdown
Member

Summary

When a basin has create_stream_on_append enabled, an append can carry an s2-stream-config header whose value is a compact JSON StreamConfig. If that request is the one that creates the stream, the config is layered over the basin's default_stream_config; unset fields inherit the defaults. It is ignored once the stream exists, so clients can attach it to every append without tracking whether the stream has been created.

This gives per-stream config (e.g. retention, delete-on-empty) on auto-created streams without a control-plane round trip.

Spec half: s2-streamstore/s2-specs#21 (this PR bumps the api/specs submodule to that branch's commit; re-bump to the merge commit once it lands).

API

One header, same for JSON, proto and S2S (an append session is a single request, so the header covers the whole session):

curl -X POST "https://$BASIN.b.s2.dev/v1/streams/tenant-42%2Fevents/records" \
  -H "Authorization: Bearer $S2_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H 's2-stream-config: {"retention_policy":{"age":3600},"delete_on_empty":{"min_age_secs":300}}' \
  -d '{"records": [{"body": "hello"}]}'

The value is validated exactly like a CreateStream config; an invalid value is rejected with 400 bad_header before any lookup and no stream is created:

{"code":"bad_header","message":"Invalid header `s2-stream-config`: age must be greater than 0 seconds"}

SDK: the option lives on the stream handle, like the encryption key, and applies to unary appends, append sessions and producers:

let stream = basin
    .stream(name)
    .with_stream_config(
        StreamConfig::new()
            .with_retention_policy(RetentionPolicy::Age(3600))
            .with_delete_on_empty(DeleteOnEmptyConfig::new().with_min_age(Duration::from_secs(300))),
    );
stream.append(input).await?;                       // or
stream.producer(ProducerConfig::default());        // or stream.append_session(..)

CLI:

echo hello | s2 append s2://my-basin/tenant-42/events --format text \
  --retention-policy 1h --delete-on-empty-min-age 5m

Why a header

  • No proto change: the same header carries the config for unary appends and S2S sessions, so AppendInput (which flows into storage) stays untouched.
  • Known before the server responds. With a body/frame field, S2S sessions needed the server to wait for the first frame before creating the stream, while clients wait for response headers before sending it; the header removes that ordering problem entirely.
  • Reusable for read paths (create_stream_on_read) later, since GET has no body.

Changes

  • api
    • v1::config::STREAM_CONFIG_HEADER (s2-stream-config) and StreamConfigHeader, a ParseableHeader that deserializes the JSON StreamConfig and reuses TryFrom<StreamConfig> for OptionalStreamConfig so validation lives in one place. to_header_value for clients.
    • data::S2StreamConfigHeader documents the header in OpenAPI (string schema, with an example value; utoipa cannot express content on a parameter).
    • AppendRequest::Unary / S2s gain stream_config: OptionalStreamConfig, parsed once in the extractor.
  • lite
    • stream_handle_with_auto_create takes an AutoCreateOn (Append / Read) and the OptionalStreamConfig to layer over the basin defaults when creating.
    • Backend::open_for_append(.., stream_config) serves both unary appends and sessions; the stream is created (or the request fails) before the response, as before this feature.
  • sdk: S2Stream::with_stream_config, mirroring with_encryption_key. Internally, AppendHeaders { encryption, stream_config } is threaded through sessions/producers and set on every (re)connect.
  • cli: s2 append accepts the same stream config flags as create-stream (--retention-policy, --storage-class, --timestamping-*, --delete-on-empty-min-age), listed under their own help heading; set on the stream handle.

Compatibility

  • Old clients never send the header; old servers ignore unknown headers.
  • s2-api public API change: AppendRequest variants gain a field.

Testing

  • s2-api unit: header parse/validate (valid, {}, invalid JSON, age: 0) and to_header_value roundtrip.
  • Backend-level: applies + merges with basin defaults; existing stream ignores config.
  • HTTP-level: JSON unary with header; invalid header -> 400 bad_header with no stream created (both invalid config and non-JSON); S2S session with header.
  • SDK integration against s2 lite: unary + producer create with config, existing stream unchanged.
  • CLI integration against s2 lite: 47/47 pass.
  • clippy -D warnings clean; workspace unit suites pass.

Made with Cursor

When a basin has `create_stream_on_append` enabled, an append can now carry a
`create_stream_config` that is layered over the basin's default stream config
if that append creates the stream. It is ignored once the stream exists, so
clients can send it on every append without tracking creation state.

- specs: bump submodule (s2-specs#21) for the proto/OpenAPI additions.
- api: JSON `AppendInput.create_stream_config`, proto <-> config
  conversions, `AppendRequest` carries `AppendMessage`.
- common: `AppendMessage { input, create_stream_config }`.
- lite: thread config through auto-create; sessions peek the first frame
  only if the stream is missing and auto-create is enabled.
- sdk: `with_create_stream_config` on `AppendInput`, `AppendInputs`
  and `ProducerConfig`.

Co-authored-by: Cursor <cursoragent@cursor.com>
@infiniteregrets
infiniteregrets marked this pull request as draft September 3, 2026 22:46
@greptile-apps

greptile-apps Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds an s2-stream-config request header for configuring streams created automatically by append or read operations.

  • Parses and validates partial stream configuration at the API boundary.
  • Layers supplied fields over basin defaults during automatic stream creation and checks supplied fields against existing stream configuration.
  • Propagates configuration through Lite, SDK sessions and producers, and CLI append/read commands.
  • Adds backend, HTTP, SDK, and CLI coverage for creation and mismatch behavior.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
api/src/v1/config.rs Defines parsing, validation, and client-side serialization for the JSON stream-configuration header.
api/src/v1/stream/extract.rs Extracts the optional stream configuration consistently across append and read request variants.
common/src/config.rs Adds partial-configuration comparison and detailed mismatch reporting.
lite/src/backend/core.rs Applies requested configuration during automatic creation and validates configured fields when resolving existing streams.
lite/src/handlers/v1/records.rs Threads extracted stream configuration into Lite append and read backend operations.
sdk/src/api.rs Serializes configured SDK operations into the new request header.
sdk/src/session/append.rs Retains stream configuration across append-session connections and reconnects.
sdk/src/session/read.rs Retains stream configuration across read-session connections and reconnects.
sdk/src/producer.rs Propagates producer-level stream configuration into its append session.
cli/src/ops.rs Converts append and read configuration flags into SDK operation options.

Sequence Diagram

sequenceDiagram
    participant Client
    participant API
    participant Lite as S2 Lite
    participant Metadata

    Client->>API: Append/read + s2-stream-config
    API->>API: Parse and validate JSON header
    API->>Lite: Request + OptionalStreamConfig
    Lite->>Metadata: Look up stream
    alt Stream is missing and auto-create is enabled
        Lite->>Metadata: Create using basin defaults layered with supplied fields
    else Stream exists
        Lite->>Metadata: Compare supplied fields with existing config
    end
    Lite-->>Client: Append/read response
Loading

Reviews (3): Last reviewed commit: "cli tests: bound read, assert mismatch r..." | Re-trigger Greptile

infiniteregrets and others added 9 commits September 3, 2026 20:49
Co-authored-by: Cursor <cursoragent@cursor.com>
…cking the response

Clients wait for the response headers before sending the first S2S frame, so
peeking the first frame before responding deadlocked until the request timeout,
after which the stream was created with basin defaults. Check existence (and
whether auto-create is allowed) before responding, and only create the stream
inside the response stream once the first message and its create_stream_config
are available. An empty or undecodable first frame no longer creates a stream.

Co-authored-by: Cursor <cursoragent@cursor.com>
Takes JSON in the same shape as a stream config in an s2 apply spec and passes
it through the producer so an auto-created stream picks it up.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…onfig

Replace the should_auto_create closure with AutoCreateOn::{Append, Read}, and
carry create_stream_config as a plain OptionalStreamConfig (an empty one means
inherit all basin defaults), so call sites read AutoCreateOn::Append /
OptionalStreamConfig::default() instead of closures and bare Nones.

Co-authored-by: Cursor <cursoragent@cursor.com>
Switch from an `AppendInput.create_stream_config` body/proto field to a
request header holding a compact JSON `StreamConfig`.

- api: `CreateStreamConfigHeader` (ParseableHeader, validated like
  CreateStream), `S2CreateStreamConfigHeader` OpenAPI param; the header
  is parsed once per request and carried on both `AppendRequest`
  variants. The proto/body field and `AppendMessage` wrapper are gone.
- lite: the config is known before the response, so the deferred
  session creation machinery is removed; `open_for_append` serves
  unary appends and sessions alike.
- sdk: `S2Stream::with_create_stream_config` sets the header on appends,
  append sessions and producers (per-stream, like the encryption key),
  replacing the per-input/producer builders.
- cli: `--create-stream-config` now goes through the stream handle.
- specs: proto reverted; openapi gains the header param.

Co-authored-by: Cursor <cursoragent@cursor.com>
@infiniteregrets infiniteregrets changed the title feat: create_stream_config on append for auto-created streams feat: s2-create-stream-config header for auto-created streams Sep 6, 2026
Matches the noun style of the other s2-* headers (s2-format, s2-basin,
s2-encryption-key) and stays accurate if reads honor it later. The
create-only semantics are spelled out in the header description.

Co-authored-by: Cursor <cursoragent@cursor.com>
@infiniteregrets infiniteregrets changed the title feat: s2-create-stream-config header for auto-created streams feat: s2-stream-config header for auto-created streams Sep 6, 2026
infiniteregrets and others added 5 commits September 5, 2026 23:23
…wording

- SDK `S2Stream::with_stream_config`, CLI `--stream-config`, and all
  internal fields/params now match the `s2-stream-config` header.
- Drop tests that duplicated lower layers or were no longer about this
  feature (backend session test, proto-existing-stream handler test,
  S2S responds-before-first-frame test, CLI inherit block and second
  invalid-JSON case).
- One phrasing everywhere: "Stream configuration to apply if the stream
  is created on append. Unset fields inherit the basin's default stream
  configuration. Ignored if the stream already exists."

Co-authored-by: Cursor <cursoragent@cursor.com>
…g-on-append

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	sdk/src/api.rs
#	sdk/src/ops.rs
#	sdk/src/producer.rs
#	sdk/src/session/append.rs
#	sdk/tests/stream_ops.rs
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@infiniteregrets
infiniteregrets marked this pull request as ready for review September 7, 2026 19:56
infiniteregrets and others added 8 commits September 9, 2026 12:38
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…smatch()

Co-authored-by: Cursor <cursoragent@cursor.com>
…ionPolicy

Co-authored-by: Cursor <cursoragent@cursor.com>
…reate race, fix stale docs

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@infiniteregrets

Copy link
Copy Markdown
Member Author

@greptile-apps review

infiniteregrets and others added 2 commits September 9, 2026 21:45
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant