Skip to content

Streams API: combined branch for review - #381

Closed
moetemp wants to merge 49 commits into
moe/streams/generated-codefrom
moe/brook/streams-combined
Closed

moetemp wants to merge 49 commits into
moe/streams/generated-codefrom
moe/brook/streams-combined

Conversation

@moetemp

@moetemp moetemp commented Nov 9, 2026 •

Copy link
Copy Markdown
Owner

This PR restarts the Streams API in sdk-python on the code that nexgen generates.

The public API is now the generated code. client.create_stream and client.get_stream are generated methods, and temporalio.workflow exports the generated Workflow functions. The generated files are in #382, the base of this PR, and #383 below it adds the Workflow Task info of a Local Activity. So this diff is only the hand-written Stream code. The earlier branch is kept at backup/pr-381-streams-combined-1112.

  • Config. A client takes stream_config=StreamConfig(stores=[...], store_selector=...), in the shape of ExternalStorage. A Worker takes the config from its client and serves every Store in it.
  • Routing. With a config, a Stream operation goes to the Store that its ref names, else to the only Store, else to the one that the selector returns. With no config, a Stream call raises StreamConfigError, since this release has no Streams on the server.
  • Workflow code can create, get and describe a Stream. The Store answers them through a Local Activity of its own, so a replay sends nothing to the Store. The Workflow Stream has no append, close or read in this release. To write, a Workflow calls its own Local Activity, which appends through activity.client().
  • Fencing. A write from a Local Activity uses the Workflow Task as its epoch, so a Store fences the writes of a failed Workflow Task. append also takes an optional sequence, and a batch that has one is written once.
  • Batching. BufferedAppender collects the appends of up to max_delay into one batch, with one write in flight.
  • Stores. StreamStore is a public interface with the five operations as methods, so a person can write a Store. It also owns its Worker side, with worker_activities() and call_from_workflow(). RedisStreamStore keeps Streams in Redis, and MemoryStreamStore keeps them in memory for tests. Both run Core's temporalio-streams crate through the bridge.
  • Errors. A failed Stream call raises StreamError with a reason. A wrong setup of the Stores raises StreamConfigError.

The Stream WIT and protos come from temporalio/api#895, through the sdk-core submodule at moetemp/sdk-rust#228 1addba05. The Stream package needs the main of nexgen (9653a0f5), which no release has yet. So poe gen-protos takes a second generator from NEX_GEN_STREAM_BIN, and the other packages stay on the released pin.

sdk-python takes its API files from Core's vendored copy. That commit adds the Stream protos and WIT from the api repo until the api Stream change merges.
The Stream package needs a newer nexgen than the existing packages, so it has its own pin until the api WITs and a nexgen release agree. The payload visitor covers every generated system Nexus package, and mypy skips the Stream package like the other generated one.
Written by `poe gen-protos` with NEX_GEN_BIN at nexgen 0.2.8 and NEX_GEN_STREAM_BIN at a build of nexgen commit bf3c49c9. Nothing in this commit is edited by hand.
The generated code calls these by name for request ids, Producers and read paging. The system Nexus serialization lookup also knows the Stream operations.
Client inherits the generated extension, so its Stream calls are the generated ones. A Store answers them in place of the server, with the conversion and the codec of the system Nexus path.
Workflow code can't do I/O, so a Worker whose client has a Stream Store calls it from an internal Local Activity. The Replayer takes the Stores too, so a replay makes the same commands.
@moetemp
moetemp force-pushed the moe/brook/streams-combined branch from a5d7b87 to a92a582 Compare November 13, 2026 04:20
Core sends the Workflow Task that dispatched a Local Activity. A write from a Local Activity needs
it for its Producer epoch.
On a Store, a Workflow appends as a Local Activity Producer, and the Store refuses the Workflow
Producer. Its epoch is the Workflow Task that ran the Local Activity, so the Store fences the write
of a failed task.
The bridge links the Stream runtime from Core's new `temporalio-streams` crate. The Core commits add
no proto that the SDK generates from.
A Store on the bridge needs the crate's runtime and its memory Store. The feature is off by default,
and the build tasks and wheels turn it on.
Client, Activity and Workflow code can catch one class and match on its reason. The details carry
the reason, so Workflow code rebuilds the same error from a Local Activity's failure.
Tests now run on the same Stream runtime and rules as every Store on the bridge. They check Records
and Producers with the generated read, so the fake Store is gone.
Each run of a chain sends the same strict create. With one request id for the chain, the Store
answers a later run's create as created and not as ALREADY_EXISTS.
Comment thread temporalio/worker/_activity.py Outdated
impl: ActivityInboundInterceptor = _ActivityInboundImpl(self, running_activity)
for interceptor in reversed(list(self._interceptors)):
impl = interceptor.intercept_activity(impl)
# Users see a Stream call through the Workflow interceptors, so this internal

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't understand that

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A Workflow's Stream call already goes through the user's Workflow interceptors, as one system Nexus operation. This Activity is how the Worker runs that call. Without the skip, the user's Activity interceptors would also see an Activity that's not scheduled by user code. A tracing interceptor would show it as an orphan span, for example. I rewrote the comment to say that. Wdyt?

Comment thread temporalio/worker/_worker.py Outdated
activities = list(config.get("activities") or [])
stream_stores = client_config["stream_stores"]
if stream_stores and config.get("workflows"):
activities.append(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit sketchy. I follow the point though

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably we'd be better off with a redis plugin that does the activity + stream store.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the appeal. My concern with a plugin is that a user would mention the Stores twice, once in stream_config and once in the plugin. Or the Stores move out of the client option, which the API review chose to match external storage. So the Worker still takes its Stores from its client, and the user sets them up in one place. To make it less "sketchy", the Activity and the Workflow routing sit in one module next to the Store as of 1c836c8.

Comment thread temporalio/worker/_workflow_instance.py Outdated
# Workflow code builds the request, so the Local Activity does only the I/O.
payload_converter, _ = self._system_nexus_converters(input)
[request] = payload_converter.to_payloads([input.input])
activity = self._outbound_schedule_activity(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect this should probably live inside the stream store itself.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the code in this function, likely

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved it in 1c836c8.

Comment thread temporalio/worker/_workflow_instance.py Outdated
) -> _NexusOperationHandle[OutputT]:
) -> temporalio.workflow.NexusOperationHandle[OutputT]:
options = self._stream_store_call_options
if options is not None and input.service == _STREAM_SERVICE.name:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't know why we would check options is not none.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It meant "this Worker's client has a Store", and it wasn't obvious. The check is on the Worker's Stream config now. With none, a Stream call goes to the server like every system Nexus operation.

Comment thread pyproject.toml
select = "*musllinux*"
environment = { PATH = "$PATH:$HOME/.cargo/bin", CARGO_NET_GIT_FETCH_WITH_CLI = "true", RUSTFLAGS = "-C target-feature=-crt-static" }
environment = { PATH = "$PATH:$HOME/.cargo/bin", CARGO_NET_GIT_FETCH_WITH_CLI = "true", RUSTFLAGS = "-C target-feature=-crt-static", MATURIN_PEP517_ARGS = "--features streams-redis" }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like quite a bit of shenanigans involved here, I'll ignore the pyproject until we're further along in core.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Most of it is there so the dev build and the wheels link the Stream runtime while it's behind a cargo feature. The rest keeps the linters out of the Core submodule.

Comment thread .github/workflows/ci.yml Outdated
if: ${{ matrix.clippyLinter }}
- run: poe build-develop
- run: poe lint
- uses: ./.github/actions/install-redis

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not 100% on installing (and starting, which is unclear) a redis server for all tests, but maybe

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The step installs Redis and starts one server on a port. Only the Redis Store tests use it, and they skip when its URL isn't set, as on Windows. I mentioned the step in ed82555 so that's clear at the call site. I can move those tests to their own job if you'd rather keep Redis out of the main one.

The Activity, the Local Activity call and its handle were spread over generic Worker files.
One module now holds all that a Worker does for the Stream Store of its client.
A Store is the Rust runtime behind the bridge, so no user writes one in Python. Its call takes the
bridge's operation name and bytes, which is not an API for users.
A reader asked why a list takes one Store. A named CI step shows why Redis is installed.
The agreed API holds the Stores and the rule that picks one in a single config, as
`ExternalStorage` holds drivers and a selector. A ref names its Store, so a call on a known Stream
reaches the right Store with several of them.
A test that starts its own server needs a client with Stores, as a test with `Client.connect`
has. `start_local` and `start_time_skipping` give the config to the client that they build.
A Workflow's Stream call runs in the SDK's Local Activity, so the Store can be picked there by
the client's rules. History keeps the ref with the Store's name, so a replay calls no selector.
@moetemp
moetemp requested a review from tconley1428 November 13, 2026 20:50
A Workflow's create picks its Store only in the Local Activity, so with several Stores it could
not use the options of any one Store. The config now holds one set of options for every Store.
Decoding the encoded request to find its ref decoded every Record of an append once more.
The caller has the request object before it encodes it, and the Local Activity gets the ref
as two strings.
An Activity that streams model tokens appends a few characters many times a second, and one
Store call for each is too many. The appender writes them in batches of at most 1,000 Records.
The new Stream WIT leaves `append`, `close` and `read` out of the Workflow target and gives the
Producer parameters their default. Its Rust code is the same, so the bridge needs no rebuild.
It brings the Workflow Stream without append, close and read from the generated-code branch.
The regenerated Workflow `Stream` lost the three methods, but Workflow code can still start
their operations through a Nexus client, so the Worker keeps its refusal and the tests start them
that way. The generator script names the nexgen commit that made the code.
The generated Workflow modules import `temporalio.workflow` at their top, and it imports them
back, so a new interpreter that started with one of them failed. The parent package now loads
`temporalio.workflow` last, which completes it before a generated Workflow module starts.
An Activity interceptor sees every Activity that runs on its Worker, also one that the SDK
schedules. A tracing interceptor then shows the Store call under its Workflow, which is correct.
This release has no Streams on the server, so such a call could only fail late, with a message
that didn't say why. It now fails at once with a `StreamConfigError` that names the fix.
A person who writes a Store for another system needs typed operations, not an operation
name and bytes. Bytes now belong to the Stores on the bridge, and the hooks hand messages
to a Store with no extra parse.
Worker code should only find a Store and hand it the call, not know how the Store serves
Workflow code. Each Store now brings its own Activities and its own call from Workflow code,
with a default Local Activity that carries the Store's name.
The crate writes its test vectors with a Rust program, so the submodule has no Python file. Its Rust code is the same, and the bridge needs no rebuild.
The submodule has no Python file any more, so pyright, pydocstyle and ruff have nothing to skip there.
A Store is an interface that a person can implement, so the docstring no longer calls it configuration.
The Workflow Task info of a Local Activity has a branch of its own below the generated code, so this change no longer shows it.
@moetemp

moetemp commented Nov 14, 2026

Copy link
Copy Markdown
Owner Author

closed in favour of temporalio#2129

@moetemp moetemp closed this Nov 14, 2026
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.

2 participants