Skip to content

Added server-side streams for workflows and external clients. - #2

Open
moedash wants to merge 47 commits into
mainfrom
moe/AI-198-server-side-streams
Open

moedash wants to merge 47 commits into
mainfrom
moe/AI-198-server-side-streams

Conversation

@moedash

@moedash moedash commented Aug 28, 2026

Copy link
Copy Markdown
Owner

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_ATTRIBUTES or WORKFLOW_TASK_FAILED_CAUSE_BAD_SUBSCRIBE_STREAM_ATTRIBUTES instead 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 with WORKFLOW_TASK_FAILED_CAUSE_STREAM_RANGE_UNAVAILABLE and 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 explicit expected_offset is checked. A stream a workflow owns carries a byte and item budget, and the resource-bounding limits are namespace-scoped dynamic config under stream.*. 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, and DeleteStream refuses while one is unless force is 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_ranges and StreamRange. Each StreamRecord carries its kind, producer_id, attempt and sequence; the server stores them as sent and delivers them on the Workflow Task, and the workflow's own appends are stored with an empty producer_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, DescribeWorkflowStream and ListStreams are read-only. CreateStream, AddMessages, FinishWriting, SubscribeWorkflow, AddWorkflowMessages, CloseStream, TruncateStream and DeleteStream are writes. RegisterStreamConsumer and AdvanceConsumerHead, the two calls History makes on itself, are admin, and the frontend answers them with Unimplemented anyway. 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, ListStreams with 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 with FailedPrecondition, since there is no task to fail.

A workflow reset carries its subscriptions. A run rebuilt from its events recreates its cursors: the WorkflowStreamSubscribed event places a cursor, each completed task's consumed_stream_ranges move 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 the WorkflowTaskFailed event with cause RESET_WORKFLOW and its base_run_id, and names that run in StreamSlice.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 in DescribeWorkflowExecution under workflow_extended_info.reset_run_id.

Failover is characterized under both replication modes in tests/xdc/. With state-based replication (history.enableTransitionHistory) the CHASM nodes Streams#<name>, Streams$<name>$Batches#<offset> and StreamCursors#<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 floor DescribeWorkflowStream reports; 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; the replace of go.temporal.io/api goes away once the api change is tagged.

How did you test it?

Link to a test plan if any -

  • Unit Tests
  • Staging
  • End to End Tests

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.

@moedash
moedash changed the base branch from moe/AI-198-base to main August 28, 2026 20:20
@moedash
moedash force-pushed the moe/AI-198-server-side-streams branch 2 times, most recently from f7e6525 to 0e74266 Compare September 2, 2026 05:29
@moedash
moedash force-pushed the moe/AI-198-server-side-streams branch from 8cb5952 to d7187cc Compare September 14, 2026 22:49
@moedash
moedash force-pushed the moe/AI-198-server-side-streams branch from d7187cc to 27ae8a3 Compare September 14, 2026 23:00
`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
moedash force-pushed the moe/AI-198-server-side-streams branch from df178fa to 989b303 Compare September 16, 2026 22:42
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.
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.
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