Skip to content

Carried stream subscriptions through reset and failover. - #8

Open
moedash wants to merge 59 commits into
moe/AI-198-srv-5-delivery-replayfrom
moe/AI-198-srv-6-reset-failover
Open

moedash wants to merge 59 commits into
moe/AI-198-srv-5-delivery-replayfrom
moe/AI-198-srv-6-reset-failover

Conversation

@moedash

@moedash moedash commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

This PR carries stream subscriptions through a workflow reset and characterizes a stream under namespace failover.

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.

What changed?

  • The rebuild recreates a cursor from the subscribe event and moves it with each completed task's recorded range. A range for a stream with no cursor is proof of a subscription made through the service rather than by a command, which leaves no event of its own, so the cursor is created from the range.
  • InheritStreamsOnReset copies what only the base run knows. A subscription the events never mentioned starts where it started in the base run. A cursor on a stream in another execution keeps reading that stream and is given the frontier the base run last knew. A cursor on a stream the base run owned gets a stream of this run's own, starting at the offset the cursor stands at, so the offset space continues and a range in this run's History is unambiguous about which run holds it.
  • Replay reads each range from the run that holds it. A reset point in the history names the base run for everything recorded before it; everything after belongs to the consumer itself.
  • tests/xdc/stream_failover_test.go pins what happens under namespace failover in both replication modes. 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.
  • The tip is an empty merge of 19b4452be899, the head of Added server-side streams for workflows and external clients. #2. It adds no tree change. It gives this chain the original head as an ancestor, so the downstream PRs that pin or branch off that sha keep correct diffs and merge cleanly once this chain lands.

Why?

Reset is where the two halves of a subscription, the events and the CHASM state, have to be reconciled, and failover is where the honest answer is that state-based replication carries a stream and event-based does not. Both are characterizations, so they read better after the behaviour they characterize.

How did you test it?

go build ./...
make fmt              # tree stays clean
go test ./chasm/lib/workflow/ ./service/history/... -count=1
go test -tags test_dep ./tests/ -run 'TestResetReplaysTheBaseRunsRangesAndGoesOnWithItsOwnStream|TestResetHandsTheExternalPinToTheResetRun' -count=1
git diff HEAD^1 HEAD --stat        # empty
git diff 19b4452be899 HEAD --stat  # empty

Series

Server 6 of 6. Replaces #2.

Previous: #7 moe/AI-198-srv-5-delivery-replay. Next: none, this is the tip of the server chain.

Review round

A reset run's stream continues the base run's offset space, so it is born with a head offset well above zero while holding nothing. The item budget read that head as a stream already full, which refused every publish on the reset run once the inherited cursor stood past stream.ownedStreamMaxItems. The budget measures held records now, fixed in #4, and TestAnInheritedStreamIsNotBornOverItsItemBudget covers the reset shape directly.

TestStreamDoesNotReplicateUnderEventBasedReplicationYet used to assert that the records did not replicate and stop there, while its comment claimed something about the workflow's first task on the new cluster. It now drives that task and asserts what it sees: a WorkflowTaskFailed with STREAM_RANGE_UNAVAILABLE naming the cursor whose stream the cluster does not hold. The cause comes from the #7 change; before it the task failed with a bare error and matching retried forever with nothing in History.

TestResetOfAPublisherRestartsItsStreamAtZero characterizes the publish side of a reset, which had no coverage: a workflow that only publishes holds no cursor, so its reset run gets a stream starting at offset zero while the copied history still names the base run's offsets. The two appended events then describe different records at the same offset.

Known limits, argued rather than fixed:

  • Nothing pins the base run. The ranges a reset run's history recorded before the reset point live in the base run's mutable state, so once namespace retention deletes that run the reset run's cold replay has nowhere to read them from and the run becomes unstartable. Copying those batches into the reset run, bounded by the replay budget, is the shape of the fix.
  • Records the base run's stream held but had not delivered, everything between the cursor and the base head, are not carried to the reset run and nothing records that they were dropped. InheritStreamsOnReset says so.
  • The publish side of a reset restarts its offset space while the consume side continues it. Whether it should be given the same treatment is open.

Review round: api surface

The reset test reads the appended event's range through GetFromOffset(). Everything else on this branch comes up through the merge.

Brought onto current main (d8f9c6d86b2c) by merge, which came through without conflicts and needed no code change on this layer.

`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.
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.
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.
A reset run is rebuilt from events, and the cursors were CHASM state the events never touched, so the new run had no subscription. The rebuild now recreates cursors from the events, the reset copies what only the base run knows, and replay reads each range from the run that holds it.
State-based replication carries the stream, its batches and the cursor to the standby, and the workflow goes on 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.
The merge adds no changes; it gives the chain the original head as an ancestor so downstream pins and merges reconcile.
Its stream continues the base run's offset space, which the item budget read as
a stream already full. The event-based failover test now runs a task on the new
active cluster and asserts what that task sees, and the publish side of a reset
is characterized rather than left unstated.
The event bounds its batch with a from and a to offset like every other range in this API.
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