Conversation
moedash
force-pushed
the
moe/AI-198-server-side-streams
branch
2 times, most recently
from
September 2, 2026 05:29
f7e6525 to
0e74266
Compare
moedash
force-pushed
the
moe/AI-198-server-side-streams
branch
from
September 14, 2026 22:49
8cb5952 to
d7187cc
Compare
moedash
force-pushed
the
moe/AI-198-server-side-streams
branch
from
September 14, 2026 23:00
d7187cc to
27ae8a3
Compare
`make fmt` left these two files dirty, which fails the `fmt` job. The proto also carried a truncated comment for a field that is no longer there.
The stream log methods on `ExecutionStore` had no fault-injection or telemetry wrapper, so `make go-generate` left the tree dirty.
Both were rewritten to point at `add_stream_log`, which dropped `nexus_incoming_services` from the Cassandra upgrade path and `tasks_v2` from the SQLite one. A released version has to stay what it was.
Cassandra goes to 1.14, SQLite to 0.12, MySQL and PostgreSQL to 1.20. The MySQL and PostgreSQL stores already query `stream_log`, so their base schema needed the table too.
It holds one cluster per arm for the whole run, which outlasts the dedicated pool and parks the functional suite until the 35 minute timeout.
Three placeholders carried a trailing digit. MySQL connections interpolate parameters, so the digit was appended to each value and a read returned batches past the offset it asked for.
The orphan comment dropped from the `.proto` still rode the generated field, which leaves `make proto` dirty.
The `api-go` branch was rebased onto its upstream `main`, which pulls in newer gRPC and OpenTelemetry requirements through the module graph.
moedash
force-pushed
the
moe/AI-198-server-side-streams
branch
from
September 16, 2026 22:42
df178fa to
989b303
Compare
The new api pin ships the nexusoperation proto, so the generated import map has to list it.
The module pins its own grpc version and has to track the root module after the api pin bumped it.
The mixed brain tests live in their own module, so the root go test invocation cannot reach them.
The metric SDK returns a nil slice when no exemplar was recorded and an empty one when the reservoir was already allocated, which made the comparison flaky.
The log is appended to, so a failure from an earlier run kept failing every later verification.
Taken from upstream temporalio/temporal temporalio#11906. The api version this branch pins rejects a system payload that is not binary/protobuf, so the endpoint has to refuse one instead of labelling it.
This was referenced Sep 18, 2026
Payloads live in the CHASM component, so the table, the store methods, the four schema bumps and the scavenger marker had no caller on the serving path. The internal protos reserve the field numbers the log addressing used.
The completion path reached the stream with the request context's engine, which only serves shards this host owns. Registration now resolves its start offset in one transition, and routed calls made under the workflow lock carry a bounded deadline.
Cold replay walks every page of history for the recorded ranges instead of stopping at the first. A range that is truncated, deleted or over the replay budget fails the task with its own cause rather than handing matching a retry. Slices now name the run that holds the stream.
A refused publish or subscribe came back as a plain error, which failed the completion call and left the worker retrying the same command with nothing in History saying why.
The publish commits with the task, so a task that fails after publishing has to leave no event, no offset and no message behind.
Delivery to a workflow drops anything that is not data, so a producer leaving the field unset got an offset for a message no subscriber ever saw.
The batch and the frontier commit in one transition and the execution serializes transitions, so the pin only made an outside producer fail when the workflow published in between. The stripe lock and the separate create step went with it.
The caps that bound resource use are namespace-scoped settings with the old constants as defaults. A stream a workflow owns now carries a byte and item budget well under the mutable state limit, so the append is refused instead of the workflow being terminated.
A pin keyed by workflow id alone outlived its run, refused a later run of the same id and kept the notify task pushing at closed workflows. The push now reports a closed or continued run, so the stream drops or re-keys the pin, and appends coalesce into one outstanding notify task.
Retention now probes the consumers of a closed stream and re-arms while one is running, and DeleteStream refuses unless force is set. The truncation test also asserts the floor from the moment of subscribing, which is when it is taken.
The page-level fallback moved the reader to the end of a window whose batches did not reach it. The message loop already advances past every filtered message it examined, which is all the advance a filtered page is owed.
A stream id becomes an execution's business id and a stream name a key in mutable state, so both are bounded like workflow ids before the request is routed.
Hand-written lines now fit in 100 characters, the stream handlers all tag their context with the caller, the comments describe the current behaviour without history, and the SDK validation host is opt-in like the other tooling.
The api names an entry a record and carries its kind, producer, attempt and sequence on the wire, so the store keeps those fields and hands them back on the Workflow Task instead of dropping everything but the body. The workflow's own publish clears the producer id, which is how a reader tells its records from an outside producer's.
A workflow reads a topic before anything has been written to it, and the name it subscribes with is the stream an outside producer later appends to. A subscribe that names neither an owned stream nor a standalone one now creates the owned stream instead of failing the task.
…ages meet. The importas rule reserves the pb suffix for go.temporal.io/api packages, so the internal chasm package takes the streamlib alias the rest of the tree already uses for it.
The race test asks the env for a context from eight goroutines at once, and the unguarded append was a data race. Under the race detector that fails the test, and every parallel test running at that moment fails with it, which is how the standalone Nexus and activity parity tests went red on the same shard.
…ervice. Without an entry in the method table the default authorizer denied every stream RPC and the no-op one admitted any namespace caller. Each method now declares namespace scope with read, write or admin access, has a rate-limit priority, and is forwarded to the namespace's active cell like a workflow RPC.
A query dispatched straight through matching is built without RecordWorkflowTaskStarted, so nothing re-supplied the ranges its history recorded and a cold worker could not replay a consuming workflow to answer. Matching now asks History for them through a new internal RPC after it has fetched the history.
A reset run is rebuilt from events, and the cursors were CHASM state the events never touched, so the new run had no subscription and could not replay the ranges its copied history records. The rebuild now recreates cursors from the events, the reset copies what only the base run knows and gives the new run streams of its own from the inherited offset, and replay reads each range from the run that holds it.
…odes. State-based replication carries the stream, its batches and the cursor to the standby, and the workflow goes on consuming and publishing there. Event-based replication rebuilds only the cursor from the recorded ranges, so the second suite pins that the stream does not follow the namespace yet.
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.
What changed?
This PR adds server-side streams, the Option 5 prototype. A stream is a CHASM component whose payload lives in the component's data nodes. The stream service RPCs land with frontend wiring. A workflow appends records with a command that commits inside its Workflow Task, and a consuming workflow gets consumed ranges delivered on the task, routed across History hosts. Retention refuses to trim below an active consumer, and a capped stream refuses the append instead of deleting the range. The append dedup fingerprint is independent of protobuf map order. The benchmark suite sits behind
TEMPORAL_STREAM_BENCH=1.A subscribe command naming a stream on another History host is registered through the routed stream service, so it works on a cluster with more than one History host. A refused append or subscribe fails the Workflow Task with
WORKFLOW_TASK_FAILED_CAUSE_BAD_APPEND_STREAM_RECORDS_ATTRIBUTESorWORKFLOW_TASK_FAILED_CAUSE_BAD_SUBSCRIBE_STREAM_ATTRIBUTESinstead of failing the completion call. A task whose recorded stream range can no longer be served, or whose cold replay would exceed the re-supply budget, fails withWORKFLOW_TASK_FAILED_CAUSE_STREAM_RANGE_UNAVAILABLEand a message naming the stream. Cold replay follows every page of history to find the recorded ranges rather than the first page only. A record appended with no kind is stored as data. An RPC append no longer pins to the head it read; only an explicitexpected_offsetis checked. A stream a workflow owns carries a byte and item budget, and the resource-bounding limits are namespace-scoped dynamic config understream.*. A consumer's pin on a stream is keyed by run, released when the run closes, and moved to the successor after continue-as-new. Retention waits while a consumer is active, andDeleteStreamrefuses while one is unlessforceis set. The frontend checks stream ids, names, offsets and page sizes before routing. Every slice names the run that holds the stream.The wire follows the record vocabulary of the api:
AppendStreamRecords,WorkflowStreamRecordsAppended,consumed_stream_rangesandStreamRange. EachStreamRecordcarries itskind,producer_id,attemptandsequence; the server stores them as sent and delivers them on the Workflow Task, and the workflow's own appends are stored with an emptyproducer_id. A subscribe naming a stream this workflow does not own yet, and that exists nowhere else, creates the owned stream so a reader can subscribe before the first record arrives.The stream service declares its authorization metadata. Every method is namespace-scoped.
PollMessages,PollWorkflowMessages,DescribeStream,DescribeWorkflowStreamandListStreamsare read-only.CreateStream,AddMessages,FinishWriting,SubscribeWorkflow,AddWorkflowMessages,CloseStream,TruncateStreamandDeleteStreamare writes.RegisterStreamConsumerandAdvanceConsumerHead, the two calls History makes on itself, are admin, and the frontend answers them withUnimplementedanyway. Whether closing, truncating and deleting a stream should need an operator rather than a namespace writer is a policy call this PR leaves open. Every method has a rate-limit priority next to the workflow service's: appends and subscribes with the signal-like APIs, close, truncate and delete with the state changes, the describes with the status reads, the two polls with the other polls and counted as long-running,ListStreamswith the visibility reads. The redirection interceptor used to serve a stream call wherever it landed; it now forwards a call for a namespace active in another cell to that cell, the way it forwards a workflow service call, through the interceptor's registered-method hook.A query dispatched straight through matching now carries the recorded stream ranges. Matching asks History for them through a new internal RPC,
GetStreamReplaySlices, after it has fetched the history for a non-sticky query task, and History re-supplies them from the streams with the same page walk a cold Workflow Task uses. Every slice on a query task is tagged with the completion that recorded it. There is no untagged slice, because a query starts no task. A range the stream can no longer serve fails the query withFailedPrecondition, since there is no task to fail.A workflow reset carries its subscriptions. A run rebuilt from its events recreates its cursors: the
WorkflowStreamSubscribedevent places a cursor, each completed task'sconsumed_stream_rangesmove it, and a range for a subscription made out of band, which leaves no event, creates the cursor from the range. The reset then copies what only the base run knows: whether a stream lives in another execution, and the frontier that stream last pushed. A cursor on a stream the base run owned gets a stream of the reset run's own that begins at the offset the cursor stands at, so the ranges recorded before the reset point stay in the base run's stream and everything from the reset point on is the reset run's. Replay reads each recorded range from the run whose era recorded it, found by theWorkflowTaskFailedevent with causeRESET_WORKFLOWand itsbase_run_id, and names that run inStreamSlice.run_id. The base run's pin on a standalone stream is released the way a closed run's is: the next notify finds the base run terminated and re-keys the pin to the reset run, at the floor the inherited subscription began at. The base run names its successor inDescribeWorkflowExecutionunderworkflow_extended_info.reset_run_id.Failover is characterized under both replication modes in
tests/xdc/. With state-based replication (history.enableTransitionHistory) the CHASM nodesStreams#<name>,Streams$<name>$Batches#<offset>andStreamCursors#<name>reach the standby, the records and the frontier are readable there after the failover, and the workflow keeps consuming and publishing with the consumed range re-supplied on its first task. With event-based replication only what the events carry reaches the standby: the cursor is rebuilt from the recorded ranges, the stream and its batches are not, so the frontier reads as an unwritten stream and no record can be polled. Carrying the stream under that mode would take a replication task for the component, the way the HSM state replicator ships state; the appended event cannot carry it without putting payloads in History.Fixes AI-198
Why?
Today's Workflow Streams spends History and Update budgets per record. With the payload kept out of History (an append is one fixed ~41-byte event), outside readers fan out without workflow tasks, and replay re-reads the recorded ranges.
Known limits
A history-only replayer, one that reads events without asking the server, has nothing to re-supply the recorded ranges from; the query path is covered now, the replayer is not. A consumer whose recorded range is gone for good keeps failing its task attempts until the stream is repaired or the workflow is terminated. After a reset, the base run's stream holds the records the reset run's history refers to, and the base run's retention eventually deletes them; a cold replay of the reset run after that fails its task with
STREAM_RANGE_UNAVAILABLE. A reset run's own stream begins at the inherited offset, so a reader following the chain into it has to start at the floorDescribeWorkflowStreamreports; polling below it is refused. A subscription made through the service rather than by a command leaves no event, so a reset run carries every such subscription the base run held, positioned where the reset run's history left it, even one made after the reset point. Under event-based replication the workflow's first task on the new cluster cannot find the stream its cursor names; the xdc test asserts what the standby holds and reads, not the stuck task. Taking the consumer pin before the workflow lock is held, rather than inside the completion transaction, is left for a later change. The Nexus payload commit duplicates upstream's fix and is dropped on rebase; the grpc and OpenTelemetry bumps come from the api-go module; thereplaceofgo.temporal.io/apigoes away once the api change is tagged.How did you test it?
Link to a test plan if any -
Unit suites plus the functional tests (
tests/stream_*.go) on SQLite. Cross-host routing over the RPC and over the command, cold replay across history pages, refused commands, append atomicity on a failed task, consumer lifecycle across completion and continue-as-new, retention and deletion guards, subscribe-before-write and record identity on delivery have their own functional tests.The authorization table is checked against every method of the generated service, the quota tables the same way, and the redirection of a stream call in the interceptor's unit suite; the redirection is not driven across two clusters. The query path has a functional test in which a poller that took part in no earlier task answers a query from the slices on the query task alone, and a matching unit test for the sticky and non-sticky cases; it is not exercised with an SDK worker. The reset has a functional test for an owned stream, including a cold replay of the reset run and the reads from outside, and one for the pin on a standalone stream; the rebuild and inheritance have unit tests, and a reset of a reset run is covered by a unit test of the page walk only. Two xdc suites fail a namespace over with a stream under state-based and under event-based replication.