Skip to content

feat: add take, filter and reduce to a Stream, and a pipeline on top - #2131

Draft
moetemp wants to merge 21 commits into
moe/streams-apifrom
moe/streams-workflow-read
Draft

moetemp wants to merge 21 commits into
moe/streams-apifrom
moe/streams-workflow-read

Conversation

@moetemp

@moetemp moetemp commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

What changed?

This PR adds three methods that scan a Stream, take, filter and reduce, to the client Stream and the Workflow Stream. It also adds temporalio.contrib.streams.pipeline, which chains steps over a Stream and runs the chain as one of these calls.

It's a prototype, created for the purpose of getting your feedback on the API. Its base is #2129.

tokens = workflow.get_stream(ref, body_type=Token)

# Wait for the next tool call, for at most a minute.
calls = await tokens.filter(
    lambda record: record.body.kind == "tool_call",
    timeout=timedelta(minutes=1),
    max_records=1,
)

# Join the text of the tokens after it into the answer.
answer = await tokens.reduce(
    lambda text, record: text + record.body.text,
    "",
    timeout=timedelta(minutes=1),
    from_offset=calls.last_offset,
)
  • The three calls. take gives every Record up to a stop. filter gives the Records that its predicate keeps. reduce folds the Records into one value. Each result has last_offset for the next call, and a stop that says why the call ended.
  • Workflow code. A call runs its scan in one Local Activity, and History gets one marker with the result. A replay takes the result from History and calls no Store. A result over 1 MiB fails the call.
  • Records out of History, by a setting. With workflow_read_replay_from=StreamReplaySource.STORE on the StreamConfig, the marker of a take or a filter has only offsets, a count and a digest. A replay then reads the Records from the Store again, and it fails the Workflow Task with StreamReplayError if it can't. So the Records must stay in the Store for as long as the Workflow can replay.
  • Client and Activity code. The same scan runs in the caller's process, and the result has no size limit.
  • The functions of a call can't be serialized. In Workflow code they run in the Local Activity, on the Worker's event loop, and must change no state.
  • The pipeline. A step, such as filter or map, runs nothing. An end, such as records or count, runs the whole chain as one call. So in Workflow code a chain is one Local Activity and one marker.

It depends on two things that have no release yet.

  • nexgen. The Stream WIT marks the three methods with @nexus.helper, which only the fork moetemp/nexgen has yet. So poe gen-protos and the check-protos job build the Stream package with a commit of that fork.
  • Core. The submodule points to a commit whose only change is the Stream WIT with the three methods, as in feat(brook): add the Stream service messages (as experimental) api#895. Core's code doesn't change.

Why?

The Workflow Stream of #2129 has no read. Workflow code usually doesn't need each Record. It needs a decision from the Stream, such as the next tool call or the full text of an answer. These calls give that decision with one Local Activity and one marker, and Workflow code doesn't run for each Record.

It has two costs.

  • A call keeps its Workflow Task open until it returns, so a Signal, an Update or a cancel reaches the Workflow only then. The default idle_timeout of 1 second bounds that on a quiet Stream, and a short timeout bounds it while Records flow.
  • What a call takes goes into History. A take of 40 small Records is one marker of about 5.5 kB, or 383 bytes with STORE.

These points are open.

  • The generated methods get their docstrings from the WIT, so the Python details (History, the Local Activity, the limits) are only on the helpers. Where should a user read them?
  • The Local Activity of a Workflow call reads its Store directly, so a client interceptor doesn't see those pages.
  • Is the package temporalio.contrib.streams clear enough next to temporalio.contrib.workflow_streams?
  • Is timeout the right word, or should it be max_duration?

How did you test it?

The tests are in tests/nexus/test_stream_read_*.py and tests/contrib/streams/test_pipeline.py. They run on the local dev server with the memory Store. The Redis tests don't call the three methods yet.

Link to a test plan if any -

  • Unit Tests
  • Staging
  • End to End Tests

@moetemp
moetemp force-pushed the moe/streams-workflow-read branch 2 times, most recently from fe5c2c6 to d33cbd8 Compare November 14, 2026 09:06
One Local Activity reads pages until the Workflow's predicate is true, so History gets one marker
for the whole read and no Record. The predicate and the Records pass through a registry in the
Worker process, since a closure can't be serialized and Records must stay out of History.
History keeps only the marker of a read, so a replay reads its range from the Store again. The
Worker checks the count, the last offset and a digest before the Workflow sees the Records.
The tests cover a first run, replays on a Worker and with the Replayer, and the limits of a read.
The measurements show the History events and the Workflow Tasks that one read costs.
The API review asked for an idle stop, a byte cap and one reason for the end of each read. A fold
keeps its value in the marker, so its replay needs no Store and calls no function of the Workflow.
Workflow code now appends and closes, so a replay skips the Store for those calls too, and a
close's Store error comes from a regular Activity. Also sorted an import of the merge and added
the changelog entry.
The refusal told Workflow code to read in an Activity. This branch has a read for Workflow code, so the message names it.
A read or a fold on a Worker with no Stream config failed the Workflow Task, which retried for good.
They raise the same non-retryable `ApplicationError` as the other Workflow Stream calls.
The three calls share one scan, which Workflow code runs in a Local Activity and other code runs
against the Store. The result goes into History, so a replay calls no Store and needs no config.
A counter numbers the calls, and a result over 1 MiB fails at the await.
A fold can append each transformed Record to another Stream. The appender writes through the
Activity's client there, and the call writes its Records before it returns, so History gets the
result only after they are in the Store.
The tests cover each stop of each call with its last offset, replays with no Stream config, a
Query after the Stream is gone, and a fold that appends to another Stream. The measurement tests
print the History events of each case.
The type checkers and pydocstyle flagged an unused import, private helpers that other modules
use, and module docstrings. The tests that misuse a call on purpose now say so to the checker.
The client must not import a contrib package. So the scan keeps the list of writers to
flush, and the appender adds itself to it.
The WIT now declares the three calls as methods of the Stream resource. Only that file
differs from the old pin, so the bridge needs no rebuild.
The Stream WIT marks take, filter and reduce with `@nexus.helper`. Only a fork of nexgen
knows that tag until its `main` has it.
The client Stream and the Workflow Stream get the three calls as methods. Each body is one
call of a support helper, and the result types are generated models.
The methods of the Stream are the public API, so the module functions and the hand-written
result types go. A client reads its pages with `Stream.read`, so its interceptors see each one,
and only Workflow code limits the size of a result, since only there it goes into History.
The take, filter and reduce calls don't chain. The pipeline compiles a chain into one of them,
so in Workflow code it stays one Local Activity and one marker.
@moetemp
moetemp force-pushed the moe/streams-workflow-read branch from d33cbd8 to 116f765 Compare November 14, 2026 10:23
@moetemp moetemp changed the title feat: add a Workflow read of a Stream that waits for a predicate feat: add take, filter and reduce to a Stream, and a pipeline on top Nov 14, 2026
A large take put every body into History. With `StreamReplaySource.STORE` the marker holds only
offsets and a digest, and the Worker reads the Records from the Store before a replay.
A replay follows the marker, and a failed read fails the Workflow Task with `StreamReplayError`.
The tests cover History without bodies, the reads of a replay, its failures, and a change of
the setting while a Workflow runs.
The docs tool read the bare enum default of the field as an alias, and failed on every link to the field. A field default is not an alias.
@moetemp
moetemp added this pull request to stack #2130 November 14, 2026 17:59

This branch has not been deployed

No deployments
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