Repository navigation
Conversation
moetemp
force-pushed
the
moe/streams-workflow-read
branch
2 times, most recently
from
November 14, 2026 09:06
fe5c2c6 to
d33cbd8
Compare
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
force-pushed
the
moe/streams-workflow-read
branch
from
November 14, 2026 10:23
d33cbd8 to
116f765
Compare
take, filter and reduce to a Stream, and a pipeline on top
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.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR adds three methods that scan a Stream,
take,filterandreduce, to the clientStreamand the WorkflowStream. It also addstemporalio.contrib.streams.pipeline, which chains steps over a Stream and runs the chain as one of these calls. It is a prototype, created for the purpose of getting your feedback on the API. It sits on #2129.Where to look
temporalio/nexus/system/stream_service/take,filterandreduceon bothStreamclasses, and the modelsStreamRead,StreamReduceandStreamReadStop.temporalio/nexus/system/_support.pytake_stream,filter_streamandreduce_stream. Each generated method calls one. A helper checks the call and sends it to the path of Workflow code or to the path of a client.temporalio/client/_stream_read.pytemporalio/worker/_stream_read.py,_workflow_instance.py,_stream_store.pytemporalio/client/_stream_store.py,temporalio/exceptions.pyStreamConfig.workflow_read_replay_from,StreamReplaySourceandStreamReplayError.temporalio/contrib/streams/_pipeline.py,README.mdpipeline, with the stepsfilter,map,take_untilandlimit, and the endsrecords,reduce,collectandcount.temporalio/contrib/streams/_appender.pyBufferedAppenderinside a function of a call.scripts/gen_nexus_system_api.py,.github/workflows/ci.yml,temporalio/bridge/sdk-coretests/nexus/test_stream_read_workflow.py,test_stream_read_client.py,test_stream_read_replay_from.py,tests/contrib/streams/test_pipeline.pyHow it behaves
takegives every Record up to a stop.filtergives the Records that its predicate keeps, and withmax_records=1it gives the first match.reducefolds the Records into one value. All three take the same optional parameters in the same order. Every result haslast_offset, so a loop loses and repeats no Record.StreamReadStopsays why a call ended. Its cases areMatch,Done,MaxRecords,MaxBytes,IdleandTimeout, in that order when several hold.idle_timeoutis 1 second by default,max_recordsis 1,000 andmax_bytesis 256 KiB.timeoutis required.takeandfilterthe result has the kept Records with their bodies, and forreduceit has the value. A replay takes the result from History, so it calls no Store. A result over 1 MiB fails the call.StreamConfig(stores=[...], workflow_read_replay_from=StreamReplaySource.STORE)keeps the Records of everytakeandfilterof Workflow code out of History. The marker then has the offsets of the kept Records, a count and a digest. Before a replay gives the call its result, the Worker reads those Records from the Store again and checks the count and the digest. So the Records must stay in the Store for as long as the Workflow can replay. A replay that can't read them fails the Workflow Task withStreamReplayError. A replay follows what the marker has, not the setting, so a change of the setting doesn't break a running Workflow.reducekeeps its value in History under both settings.readof the clientStream, so the client's interceptors see it. The result has no size limit, since it doesn't go into History.max_bytescounts the encoded size of each kept Record, in every context. A typed page ofreadhas no sizes, so the client's Store call hands them to the scan through a context variable.BufferedAppenderin a function. A function can append to another Stream through aBufferedAppender. The call writes the appender's Records before it returns, so a fold can transform one Stream into another with one marker.take,filterorreduce, so in Workflow code a chain is one Local Activity and one marker. The steps keep no state of their own, so a Local Activity that runs again gives the same result.It needs no code change in Core or in the
temporalio-streamscrate.What it costs, and its limits
takeof 40 small Records is one Workflow Task and one marker of about 5.5 kB. Afilterthat keeps 5 of the 40 is about 1 kB. WithSTORE, the two markers are 383 and 518 bytes.idle_timeoutbounds that on a quiet Stream, and a shorttimeout, such as 2 seconds, bounds it while Records flow. A call that runs longer than 80% of the Workflow Task timeout makes them wait for a Workflow Task heartbeat.filterandreducekeep the bulk of a token Stream out.STORE, a call has no defaultmax_bytesand no 1 MiB limit, since no body goes into History. A replay of afiltermakes one Store read for each kept Record.What it depends on
@nexus.helper, which only the fork moetemp/nexgen has yet. Sopoe gen-protosand thecheck-protosjob build the Stream package with a commit of that fork. The pin moves back to nexgen'smainonce the helper methods are there.moe/streams-crate-for-sdk-pythonwhose only change is the Stream WIT with the three methods, as in feat(brook): add the Stream service messages (as experimental) api#895.Open points
temporalio.contrib.streamssits next totemporalio.contrib.workflow_streams. Is the name clear enough?STOREyet.timeoutthe right name, or should it bemax_duration?