Skip to content

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

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

Added server-side streams for workflows and external clients.#2
moedash wants to merge 72 commits into
mainfrom
moe/AI-198-server-side-streams

Conversation

@moedash

@moedash moedash commented Aug 28, 2026

Copy link
Copy Markdown
Owner

This PR adds server-side streams: a durable, offset-addressed log that lives beside Workflow
History instead of inside it.

An append schedules no Workflow Task. Each reader holds its own cursor, so a reader costs the
writer nothing. Payloads never enter History. A workflow can publish to a stream, consume one,
or both, and a process outside Temporal can do the same over gRPC.

Today the same job needs Signals in and a polling Update out. That batches at seconds, grows
against the 50MB history cap, tops out at 10 subscribers, and becomes unreadable once the
workflow closes.

How it works

A stream gets its own branch in the existing history_node table, so no new storage engine is
needed. Offsets map to node ids by bucket arithmetic. The transaction-id chain the store already
maintains is what drops a stale node after a retry, and reads clip to HeadOffset, so bytes are
durable before the frontier makes them visible.

Consuming inside a workflow records only the offset range on WorkflowTaskCompleted. Replay
re-reads the same range from the stream, so the data stays out of History but the run stays
deterministic.

Cost

TestStreamPublishHistoryCost measures this. It runs a workflow whose single Workflow Task
carries N publish commands, then differences against an N=0 run so the fixed events cancel.

what was published per batch per message
100 batches x 1 message x 20 bytes 41 bytes 41 bytes
100 batches x 1 message x 2000 bytes 40 bytes 40 bytes
100 batches x 10 messages x 20 bytes 41 bytes 4.1 bytes
the same 100 messages as Signals, 20 bytes 112 bytes 112 bytes
the same 100 messages as Signals, 2000 bytes 2097 bytes 2097 bytes

The event is a fixed 41 bytes. A hundredfold increase in payload size does not move it, which is
the claim that bodies stay out of History, measured rather than read off the code.

Batching is free, because the event is per call. Against Signals that is 2.7x cheaper at 20-byte
messages and 51x at 2KB, and the gap grows with payload size.

The binding limit is the event count, not the size: 51,200 events is the limit.historyCount.error
ceiling, reached while using 2MB of the 50MB size budget. So a workflow is bounded at 51,200
publish calls with no bound on the messages inside them.

Layout

  • chasm/lib/stream/ holds the component, the log helpers and the stream service.
  • chasm/lib/workflow/stream_commands.go handles AddStreamMessages and SubscribeStream.
  • service/history/api/recordworkflowtaskstarted/stream_slices.go delivers ranges and
    re-supplies them on replay.
  • service/history/api/respondworkflowtaskcompleted/stream_appends.go flushes staged log writes
    and resolves subscriptions before the commit.

Three companion branches carry the protos and the SDK support: moedash/api, moedash/api-go,
moedash/sdk-rust and moedash/sdk-python.

What is not done

  • Replay reassembly reads only the first history page, so a consumer past limit.historyMaxPageSize
    events replays without its data. This needs fetch-on-demand rather than riding the response.
  • A replayed range may reach the workflow one activation later than the run that recorded it.
    Fixing it means tagging on WorkflowTaskStarted rather than WorkflowTaskCompleted, across the
    server and sdk-core.
  • An external consumer's truncation pin never advances and never releases, so max_items does not
    enforce while such a consumer exists.
  • Stream payloads do not pass through the data converter or a payload codec.
  • Cassandra is out of scope and its numbers are unclaimed.
  • Path C is not benchmarked, and the new tests have not run against Postgres.

@moedash
moedash changed the base branch from moe/AI-198-base to main August 28, 2026 20:20
moedash added 29 commits August 28, 2026 16:21
Derives a server-side stream primitive from Temporal's storage
invariants: the stream gets its own history-node branch, appends do not
schedule workflow tasks, and readers own their cursor. Keeps the CHASM
component O(1) so it does not depend on partial reads.
Max's external-store prototype and Johann's dedicated-facet prototype
both reach further than our design did in places, so the comparison is
recorded and the twelve resulting changes are folded into both docs.

The load-bearing one is a correctness fix: an in-workflow consumer must
record its delivered range on every task, including empty ones, or
replay can hand it items it did not have. Riding WorkflowTaskCompleted
instead of a new event makes the idle case free.
The Native Streams design reuses the history-node store for stream
payloads instead of standing up a parallel one, which rests on two
properties that were only ever read off the code. Both now have tests
on SQLite and Postgres: a non-proto blob round-trips byte-identical on
a branch whose tree ID is not a run ID, and the transaction-ID chain
rejects a stale node left by a shrinking retry once the frontier has
moved past it.

The stale-node case is asserted on the raw read path as well, since
that is the path a stream uses and it carries no contiguity check of
its own. A negative control confirms the assertion is load-bearing.
The neighbouring suite methods use underscores but predate the lint base
rev, so only new code is held to ST1003 and ST1020.
The canonical option list now lives in Notion, so design-comparison.md
says so and narrows its own scope to our design against the prototypes
that exist as running code.

Option 7 and Max's task/python-sdk-streaming branch are the same
approach, so they are compared as one. Option 7 targets bucket 2, which
makes the claim that nothing else did stale; the high-level design said
that and no longer does.

Took two things from it: an explicit flush marker so a consumer does not
wait out an idle timeout, and a per-topic sequence alongside the global
offset.
…ign.

Codex independently reached nearly the same architecture, and found a
real flaw in ours. history_node partitions on tree_id alone, which is
safe for workflow history because history is capped and unsafe for a
stream because it is not. Streams now roll to a new tree every
bucket_size offsets; the bucket is arithmetic so there is still no index.

Also filled three gaps it exposed: continue-as-new for an attached
stream needs the successor to inherit the state and the old child to
redirect, reset must carry the current head forward rather than rewind,
and a missing committed node has to read as DataLoss rather than as an
empty stream.

Group commit moves to deferred, matching their reasoning that the case
for it rests on Cassandra numbers this prototype does not measure.
Stage 0 of AI-198. Reproduces the shipped pattern, batched Signals in and
a long-polling Update out, and measures history events, history bytes,
persistence operations, and latency across flush interval and subscriber
count. Nothing to compare a native path against without it.

Latency is stamped at generation rather than at flush. Stamping at flush
measures only the server round trip and hides the batching delay, which
is the dominant term and the whole reason a shorter interval is wanted.

Two ceilings fell out that matter more than the cost curve.
history.maxInFlightUpdates caps concurrent subscribers at 10 and starves
rather than degrades past it. history.maxTotalUpdates gives a workflow
2000 reads for its entire life, so at 100ms batching five subscribers
exhaust it in under a minute and force continue-as-new for reasons that
have nothing to do with the application.
Stage 1 of AI-198. The component holds only the frontier: head and base
offsets, the transaction chain, producer dedup, and consumer pins. It is
O(producers + consumers) rather than O(messages), which is what keeps it
off the CHASM partial-read path.

Payload bytes go to the history-node store on trees of the stream's own,
rolling to a new tree every bucket_size offsets. That is not cosmetic:
the Cassandra table partitions on tree_id alone, which is safe for
workflow history because history is capped and unsafe for a stream
because it is not. Buckets are arithmetic and tree IDs derive from them,
so nothing indexes them.

The component stages appends rather than writing them. Keeping the
staging separate from the transaction is what will let an append ride a
workflow's own commit later without the component knowing.

Transaction IDs come from the caller rather than from stream state: a
retry has to carry a higher ID than the attempt it replaces, and a
counter derived from committed state would hand the retry the same one.

The bucket-boundary test earned its place twice. It found a real bug
where the result cap was passed through as a storage page size, which
made the store read past the end of an empty result. Its negative
control confirms the transaction chain, not luck, is what rejects a
stale node in a freshly started tree.
Eight shard-routed RPCs, with the request shapes declared in the library
rather than in the public API, since streams have no public API yet.
Nesting them under frontend_request matches the other CHASM libraries,
so promoting them later is a package move rather than a redesign.

PollMessages is categorised as a long poll so it lands in the right
quota bucket, though it does not block yet.

ListStreams is left out: it needs a visibility field and search
attributes on the component, which belongs with the lifecycle work
rather than here, and nothing on the benchmark path reads it.
History registers the library and serves the service; frontend resolves
the namespace name and forwards over the layered client, which routes to
the shard owning the stream. The frontend registers the service on its
gRPC server directly rather than embedding a handler in WorkflowHandler,
because StreamService is not part of the public API.

Appends are serialized per stream in the handler. The node has to be
durable before the frontier advances, so it is written outside the
transition that advances it, and two concurrent writers could otherwise
commit in a different order than they wrote: whichever node carried the
higher transaction ID would win the read regardless of which writer
actually committed. Serializing removes the interleaving. It is a
stopgap for the CHASM transaction hook, and it is noted as such in the
handler rather than left for someone to discover.
Reads starting inside a batch silently dropped everything before the
batch boundary. A node is addressed by the first offset of its batch, so
a read has to begin at the node containing the offset, not at the node
whose ID equals it. Batch size is now bounded on write, which bounds how
far back a read steps; the bound is a correctness mechanism, not only
admission control. A negative control confirms the test catches it.

Producer and consumer maps come back nil after deserialization, so
appends against a reloaded stream failed on a nil map write. Guarded at
each write rather than relying on the constructor.

Truncation, close, dedup, topic filtering, and per-producer fencing are
covered end to end through the frontend.
A caught-up reader now parks on chasm.PollComponent until the head moves
past it or the stream closes. The predicate is monotonic as that API
requires: the head only advances and closed never clears. Expiry of the
server's own budget returns an empty response rather than an error, so a
reader cannot confuse a quiet stream with a failure. A reader that is
already behind is never parked.

The tail cache keeps recently appended batches in memory so a reader at
the tail is served without a database read. That is what makes fan-out
cheap: readers at the tail cost a copy each rather than a range scan
each, which is the difference between a subscriber ceiling and none.

Two properties it has to hold. It caches only after the commit, because
a write whose commit failed can be superseded by a retry carrying
different bytes at the same offsets. And a partial hit is a miss:
returning only the tail of a requested range would look like a short
read, which is the shape of a silent data loss.

Group commit stays deferred. The case for it rests on Cassandra numbers
this prototype does not measure.
A capped stream now advances its own readable floor at the end of a
successful append rather than through a sweeper. The append transition is
already writing, so folding the check into it costs nothing and keeps the
cap tight instead of eventually true.

Truncation reclaims whole buckets. That is the point of bucketing: a
storage partition is dropped outright rather than leaving a tombstone per
message. Deletion always follows the commit that moved the floor, so a
failure leaks storage to reclaim later rather than losing data a reader
can still ask for.

The cap yields to a registered workflow consumer. That consumer's history
records an offset range it must re-read on replay, so storage grows
rather than the consumer losing data underneath it.

Close now records when and arms a retention task; deletion removes the
log before the execution, since the other order would drop the only
record of which buckets exist and leak them permanently.

Close became a pure function returning when to schedule, with the caller
arming the task. That keeps the component testable without a live
context, matching how appends already stage rather than persist.

max_bytes is removed from the lifecycle proto rather than left accepted
and silently ignored. Enforcing it needs per-bucket byte accounting that
does not exist yet.
Streams now carry a visibility field and ListStreams answers from
visibility on the frontend. Without it the only way to reach a stream is
to already know its ID, which is not something an operator can work
with.

It is deliberately not on the history handler: the query is against
visibility rather than any one stream, so there is no business ID to
route on and nothing a shard could answer.

Unit tests build the component directly instead of through NewStream,
which now needs a live context to wire the visibility field. Construction
through the real path stays covered end to end.
Persistence metrics and rate limits are keyed on caller info carried by
the context, not on the request. The RPC path sets it through
interceptors, but the stream writes its log directly, so those calls
carried no caller name at all. They were escaping namespace rate
limiting and priority, and going uncounted in per-namespace metrics.

Found while building the native half of the benchmark, which reported
zero persistence operations per message for a path that demonstrably
writes on every append. Worth stating plainly: the first version of that
number was a fiction, and only the raw per-operation breakdown exposed
it.

The retention task needs the same treatment, since it runs outside any
request.
Without one, every append and every poll resolved the stream's current
run through persistence. That lookup dominated: at 100ms batching the
native path spent 302 of 423 persistence operations on it, which made an
otherwise free read cost a database call and left the design no cheaper
than the pattern it replaces.

Supplying the run id, which CreateStream already returns, takes the same
workload from 423 operations to 121 against the baseline's 408. The
remaining native cost is exactly what the design predicts: one log
append and one frontier update per flush, and nothing at all for reads.

Optional, and defaulting to the current run, matching how run ids work
elsewhere in the API.
Same workload through both designs, sharing workload generation, latency
stamping, and metric capture, because a benchmark whose halves generate
load differently measures the harness rather than the design.

At 100ms batching with 25 subscribers the current pattern delivers 19% of
expected messages with an 11.2 second p99. The native path delivers 100%
with a 104ms p99 at a twentieth of the persistence cost per message.

The shape matters more than the ratio: native cost per message barely
moves between 1 and 25 subscribers because reads cost no persistence
operations at all, while the current pattern's rises sevenfold over the
same range.

Both measurement bugs are recorded in the results rather than quietly
fixed, since each produced a confident wrong number that no failing test
would have caught.
…cked.

The Command attributes oneof is closed and the module cache is read
only, so a workflow cannot publish to a stream without changing the
public API module. The patch against temporalio/api is exact and applies
cleanly; what does not work is generating from it. buf.gen.yaml runs a
plugin from a directory absent from the repository, and the published
module is a reshaped artifact rather than the checkout.

A local replace directive would make this branch unbuildable for anyone
else, which defeats a prototype meant to be reviewed. The unblock is a
branch on temporalio/api, which is a shared repository and not mine to
push unilaterally.

Recording it rather than leaving the next attempt to rediscover it.
A producer standing in for an LLM-calling activity appends tokens, and a
browser watches them arrive over SSE with a bridge in the middle doing
what an application backend would.

It uses no SDK and no workflow, which is the point of the path it
exercises: tokens come from an activity rather than from workflow code,
so the demo does not wait on the workflow-facing API that Paths A and C
still need.

The reconnect story falls out of the design rather than being built: the
reader owns its offset, so resuming means passing the offset back and
the server remembering nothing about the reader.
Paths A and C need a command type and two fields that cannot exist
without changing the public API module, so the module is forked to
moedash/api and pinned here by pseudo-version.

Building a usable fork was most of the work. A clean clone cannot
generate at all, plain buf generate leaves an enum prefix the published
module strips, and the pipeline does not emit several packages the
module ships. All of that is written down rather than left for the next
person.

The round-trip test marshals the new shapes rather than only compiling
against them, since a field added without its descriptor compiles and
then silently drops on the wire.
I had this wrong. temporalio/api is protos-only by design and
temporalio/api-go is the Go module, carrying api as a submodule and
regenerating with make update-proto. My earlier note claimed a clean
clone of api "cannot generate", which is backwards: it is not meant to.

So the previous fork was a hand-assembled module built by copying pieces
out of the published artifact. It compiled, but it could not be
regenerated and would have misled anyone who opened it.

Replaced with the supported shape: a proto-only branch on moedash/api,
and moedash/api-go regenerated from it through its own make targets with
the submodule pointed at that branch. The proto diff is now 65 lines
with no vendored Go alongside it.
The stream is a co-located subcomponent of the workflow, so its frontier
advances in the workflow task's own commit. The test asserts what that
buys: publishing writes no history event at all.

A command handler runs under the state lock with no context for I/O, so
it cannot write the log itself. It stages the write, the task handler
collects it, and RespondWorkflowTaskCompleted flushes before the commit
that makes the offsets visible. A crash between the two leaves nodes at
or past the frontier, which no reader can observe, and the retried task
stages them again.

Reusing a transaction id across workflow task attempts is safe here in a
way it is not for an external producer: replay is deterministic, so a
reused id lands identical bytes at the same node, which the store treats
as an idempotent overwrite. The hazard the external path guards against
is differing content under an equal id, which replay cannot produce.

Three things this turned up. The stream package had to split, because the
service half imports the history service and the workflow library cannot
import that; the component now lives in chasm/lib/stream and the service
in chasm/lib/stream/service. The command attribute validator rejects
unknown command types by design, to make new commands declare whether
they close the workflow, so this one is listed as non-closing. And a
visibility component must be an immediate child of a CHASM root, so an
attached stream carries none.

An attached stream is not yet readable through the stream API: it has no
standalone id to route on, and reaching it needs a path-addressed
component reference that CHASM does not expose. The test asserts that gap
rather than tolerating it, so it will fail and say so when it closes.
The poll response is built once per delivery, so it cannot carry slices for
the prior tasks a cache miss replays. The Go SDK builds its per-task message
index from History on the replay path and never does I/O inside the replay
loop, so the bytes have to be reassembled server-side on the History read
rather than fetched by the worker.
Both pairs keep a second copy of the message so matching can forward a task
without deserializing it, and the invariant that every field number means the
same thing on both sides lives only in a proto comment. A mismatch would stay
invisible until sendRawHistoryBetweenInternalServices is enabled.
The field has to exist on four messages, because both the history and the
matching response keep a wire-compatible twin for the raw-history path, and
then be copied by hand at each of the three hops. The guard test covers the
proto halves; the copies are still hand-written.
The cursor sits under the consuming workflow rather than on the stream so
that folding in a delivered range commits with the event recording it. On the
stream it would be a cross-execution write, and a crash between the two would
redeliver or skip. An empty range still counts as pending, because a task that
observed nothing is a fact replay has to reproduce.
A failed attempt never commits, so the stream's last committed id does not
move, and the next attempt anchored on the same completed event id wrote the
same node under the same transaction id. Storage keys a node by that pair, so
the rows collapse and the survivor is whichever reached the database last
rather than whichever attempt committed.
RespondWorkflowTaskCompleted can return the next workflow task inline, and
every current SDK asks it to. That path builds its response from its own
handler, which neither asked for a range nor copied the field, so a subscribed
workflow got an empty task whenever one was returned that way.

Subscription resolution now waits until the task is known to have succeeded. A
failed command returns no error, so the durable RegisterConsumer write went
ahead and then the workflow's own side rolled back, leaving a pin on another
execution's stream with no cursor behind it.

Publishing checks the batch against the payload size limit. Unchecked it
failed in the flush instead, which the worker replays and re-issues forever.
A sticky task's history starts at the previous task's completion, and that
event carries the range the task already consumed. Reassembly ran over it and
attached the payloads again, so the workflow read the same messages twice on
every task boundary.
The server no-opped a second subscribe to the same stream and wrote nothing.
Commands are matched against command-generated events by position, so the
machine that issued it stayed unmatched and every later command went to the
wrong event. It registers nothing now but still records, reporting where the
cursor actually is.

A redelivered range is handed over exactly as staged. The re-read recomputed
the byte cap, so a short read gave the worker less than the completion was
about to record.

HasPendingStreamData runs on every transaction close for every workflow, so it
resolves the root component once instead of asking whether it exists and then
asking for it.
Event sizes move by a byte or two on varint widths, so demanding the two
payload-size arms match exactly passed on luck. A leak would cost six figures
across a hundred batches, so the bound is set well below that and well above
the noise.

The demo binary was committed by accident and is ignored now.
@moedash
moedash force-pushed the moe/AI-198-server-side-streams branch from 7e9c756 to 628d509 Compare August 28, 2026 20:37
A stream a workflow owns is a subcomponent of that execution, so it has no
id of its own and the id-addressed read could not reach it. Publishing from
a workflow was therefore write-only from anywhere else, which rules out the
case the feature exists for: an agent emits tokens and a UI reads them.

It is addressed by its owner and its name instead, and routed on the owner
so the call lands on the shard that holds both the frontier and the log.
A stream the workflow has not published to yet reads as an empty one rather
than as an error, because a reader arriving before the first event is
ordinary and a parked reader has to wake when the stream appears.
A workflow brackets a turn, but the tokens come from the activity making the
model call, and an activity has no Workflow Task to ride. Both producers now
write one ordered log: the workflow inside its own commit, at no transition
cost, and everything else in a transition of its own per batch.

Creating the stream is what the first append from outside has to pay for,
because the collection id and bucket size it writes under are decided then.
Only the first one pays it.
Nothing can be added to a stream inside a closed execution, from the
workflow's own task or from any other producer, so a reader parked on one was
waiting for a message that could never arrive. It is now told the stream is
finished, and it can still read everything published before the end.

A message also carries the offset it sits at. A topic filter leaves gaps in
the sequence, so a reader that resumes between messages cannot count its way
back to a position.
A stream log takes writes from a workflow task and from producers outside it,
and the two drew transaction ids from unrelated sequences. The store resolves
a contested node by keeping the highest id it has seen and dropping everything
below, so two sequences on one log lose data rather than order it: a node left
by a write that never committed outranks every later write numbered below it,
and the reader is not told, it just stops seeing new messages.

Both producers now number from the shard. Ids only increase, so an uncommitted
node is superseded rather than dominant, and two attempts of one workflow task
can no longer land on the same key, which the design required from the start
and the event-id derivation could not give.

The staged flush is skipped once the task has failed, so a task that will not
advance the frontier writes no bytes at all. The renumbering against
already-read state is gone from the external paths: that state can be stale by
commit time, so it protected nothing, and two writers that both took it would
collide.

Found by an outside review. The suite could not see it because SQLite resolves
a repeated key with REPLACE, where MySQL and Postgres fail the write.
@moedash
moedash force-pushed the moe/AI-198-server-side-streams branch from f7e6525 to 0e74266 Compare September 2, 2026 05:29
Two defects an outside review found, unrelated except that both were invisible
to the tests.

A stream's log rows carried a cleanup tag the history scavenger read as a
workflow identity. It looked the namespace up, found none, took the branch for
garbage and deleted it. On by default, sixty days old by default, so a live
stream would lose everything past that. Branches that are not an execution's
history now say so, and the scavenger leaves them to whoever wrote them.

The subscribe event was written in the flush, which runs after every command,
so it landed behind the events of commands issued later. Every SDK matches
issued commands against their events by position, so a workflow that
subscribed and then did anything else in the same task would fail its first
replay. The event is now written where the command is, and the flush fills in
the start offset it could not know yet. Only the tests subscribing last kept
this hidden.
The frontier push resolves each consumer through the local shard controller,
so a consumer whose shard lives on another host fails every time. Its known
head never advances, delivery clips to it, and the workflow simply never
receives anything. That was logged at warning level per consumer and then
dropped, which is why a single-host test cluster showed nothing.

Retrying does not make it work across hosts. Routing these through the history
client the way signals are is the actual fix, and that is a larger change. This
makes the failure visible and keeps one unreachable consumer from stopping the
others in the same pass.
Registering a consumer pin and telling a consumer the frontier moved both
resolved refs through the local shard controller, which refuses a shard this
host does not own. Both are now RPCs on the stream service, routed on the
execution they act upon, so they land wherever it lives.

The pin still goes on before the cursor, because the routed call returns before
the local update runs, so the ordering that guarantee rests on is unchanged.

The third step cannot be done this way. A subscribe issued from workflow code
registers its pin inside the workflow task's own transaction, and a
cross-shard call cannot live there. Moving it out makes the pin asynchronous
and gives up the ordering. That is a design question rather than plumbing and
is written up beside the design documents.
Per-partition cost was the biggest named risk to a Temporal-owned stream and
had never been run. It has been now, on the schema a dedicated facet would use.

Cassandra is not the obstacle it was feared to be: a 100k-token session is
5.84 MB, well inside guidance. Row count binds before size does, so buckets
should be sized against the unbatched case, around 10,000 offsets rather than
the 100,000 in use.

It also found a gap that applies to the current substrate just as much: a
bucket has an offset budget and no byte budget, so a stream of large batches
can build a partition twice the guidance out of cells that are themselves too
big. A bucket has to roll on whichever budget is reached first.

And it measured a reason to move that is not about correctness. Keying a row by
the offset its batch starts at positions a read in one row. The node-id mapping
cannot, so it reads back up to a full batch of rows before the target on every
poll, for every reader.
The log lived in history_node, which was the fastest substrate to prototype on
and the wrong one to keep. Its contested-node rule is written for one writer
per branch, its rows look like workflow history to anything that inspects them,
and its replication path runs through version histories a stream has none of.
Three of the six blocking defects an outside review found came from that
choice.

A row is now keyed by the offset its batch starts at. That single change is
what the substrate is for: a write is idempotent, a retry addresses the row it
wrote before, and there is nothing for an uncommitted write to outrank. The
frontier the stream component commits is the only thing deciding what a reader
may see, which is what the design always said and could not previously rely on.

It also positions in one row. Finding the batch holding an arbitrary offset is
an indexed lookup rather than reading a full batch of rows backwards on every
poll, which is measured in streaming-cassandra-partitioning.md.

SQLite, MySQL, Postgres and Cassandra all implement it, and the Cassandra DDL
is validated against a running server. Only SQLite is exercised by tests here.
The whole stream functional suite passes unchanged, which is the point: this
moves where the bytes live and nothing else.

The transaction id is now vestigial and still threaded through the component.
Removing it is a follow-up, kept separate so this change stays reviewable.
An offset-keyed row is idempotent, so there is nothing for a second id to
order. What the id used to guard against, a writer working from a frontier
that has since moved, is what ExpectedOffset checks, and that check can see the
committed state where the id could not.

Gone from the component, its state, the command handler options and both
external append paths. The test that asserted the id must advance goes with it;
the case it protected is covered by the expected-offset test beside it.
The floor waited for the slowest reader so that a consumer could always
re-read a range its history recorded. Nothing released it when that consumer
finished, so a stream with a cap kept everything for as long as a consumer had
ever been registered. It was not protecting readers, it was disabling the cap.

Registering a consumer now says who to wake and nothing more, and truncation
applies the cap it was asked for. A consumer that falls behind is told: the
polling path already refused a read below the base, and delivery now does too.
Delivery used not to, which was the real hazard, because it would have handed
the workflow whatever survived and left a hole nothing could see.

This also removes the reason subscribing from workflow code had to reach
another shard while holding the execution lock. That was the last of the three
cross-execution steps and the one that could not be routed, so it stops being
a problem rather than being solved with machinery.

Six tests asserted the old floor. They assert the new behaviour instead, since
what changed is a guarantee and not an implementation.
The component's state already replicates: a stream's frontier and cursors are
CHASM nodes and ride the sync-versioned-transition message. Only the log rows
do not, and on the old substrate there was nowhere to put them, because
replication reads history through version histories a stream had none of.

Keying a row by the offset its batch starts at changes that. Applying a row is
idempotent, rows are independent, and a row says which offsets it covers, so
the payload can be shipped rather than fetched back the way history events are.

Carrying the rows in the message that already carries the frontier removes the
ordering question instead of answering it. Split them and the frontier can
arrive first, leaving a standby holding offsets whose bytes never landed, found
at failover.

Not built. What needs deciding first is bigger than the mechanism: a size cap
on the most important message in the replication path, and what happens to a
stream written from both sides of a failover, which idempotent-by-offset turns
into silent last-writer-wins.
The mechanism said to carry the rows with the frontier and left unsaid how
the sender knows which rows those are. The state delta is versioned and the
rows are not, so it does not follow from what is already there.

Two ways, and the choice turns on something other than the obvious. Having the
receiver report the offset it holds through needs a back-channel that does not
exist, but it makes the byte cap free: a message that cannot carry the range
carries a prefix, and the next watermark resumes there. Versioning each range
as a child node needs no protocol change and costs a node per batch, which for
a token stream is the state this design keeps small everywhere else, and it
still needs continuation built separately.
The note designed a private replication protocol for the stream log. The
reason it needed one is that CHASM has no way for a component to own data it
does not store inline, so the framework does not know the bytes exist and
cannot replicate, reclaim or account for them. History solved this bespoke
with branch tokens. Asking for the general version is the right order, since
building the private one first makes it harder to ask.

Reframed on the assumption that the store is external and shared rather than
the execution database. That assumption removes any chance of the framework
reaching the bytes through an existing persistence path, and it puts the
current interface in the wrong place: the three methods sit on ExecutionStore,
which presumes the log is shard-local.
The baseline signalled the workflow done before waiting for consumers to
drain, so consumers polled a completing execution for up to fifteen seconds
and counted their own doomed polls as rejections and their client backoff as
delivery latency. The comment above it already described the correct order.
Rejections fall to 1 per cell on both halves with the order fixed.

The native half assigned a constant zero to the history columns under a
comment claiming a measured zero was the point. That workload has no workflow
at all, so there is nothing to measure, which is a different claim and now
renders as such.
The sink lives in the worker process, so it does not rewind when a Workflow
task replays, and it was stamping an observation per replay rather than per
token. Only the Option 5 half was replaying, so only its counts drifted, but
the sink was equally wrong on both sides.

Counted by token identity rather than by an SDK replay flag. The token carries
its own send time, so it is unique and the check is exact. A replay flag would
not work here, since a sticky cache hit is reported as a replay and guarding
on it would drop live observations instead of duplicate ones.
CHASM event storage with replication and lifecycle is planned for H2 and owned
elsewhere, so the request this file made is withdrawn. Nothing here should be
built to substitute for it.

Also recorded the prior art, which matters more than the answer. A CHASM
streaming component already exists on seandan/streaming with the same component
path, and its chasm.Map of payload per message hits the mutable state ceiling
that a year-old note on it already called out. The same note enumerated the
three ways out, including the two this prototype arrived at on its own. Not
having read the existing discussion first is what made that duplicate work.
Both halves rebuilt in release. The old numbers ran on a debug sdk-core on
both sides, which was never stated and is most of why Option 7 showed a
6 to 8 second tail it does not have.

Three runs each, because the first two Option 5 runs disagreed by more than
2x on p50 and history events, so one run per side was not defensible. Option 5
delivery is paced by workflow task scheduling and varies with it; Option 7
streams into a task it holds open, which Core warns about at 8 seconds per
task, and its latency barely varies.

Exactly-once holds on both sides in all six runs, 800 observed for 800
published, which closes the 813 question with the sink counting correctly.

Also fixed the Redis scrape, which shelled into a container that need not
exist and returned an empty dict on failure, reporting the cost that moved out
of Temporal as zero.
The batches are now data nodes keyed by the offset each one starts at, so the
payload and the frontier commit in one transaction and replication comes from
UpdatedChasmNodes rather than needing a protocol of its own.

That collapses a lot. The append no longer previews itself against a detached
copy to work out what to write, because there is nothing separate to write.
The staging and flush between the command handler and the commit are gone, and
so are the buckets: they bounded a storage partition, and mutable state is not
one. Deleting a stream now takes its payload with it instead of sweeping
buckets first.

Path C delivery is not converted yet. It reads a stream in another execution
straight from the store, which is no longer written, so in-workflow consumption
returns nothing until that path is routed.
It existed so N readers at the tail cost N copies rather than N range scans
against the database. The payload is component state now, so a read never
reaches the database in the first place and the cache was written on every
append and consulted by nothing.
Delivery and replay reassembly both read a stream's payload where it now
lives. An owned stream is read from the consumer's own component, and during
delivery that is the one already loaded, which is the only safe order while its
task is being built. Replay runs after the execution lock is released, so it
reads both kinds back through the engine.

Both resolve through the local shard controller, so a stream on a shard this
host does not own is not reachable. That is the same limit the other
cross-execution steps had before they were routed, and it is the next thing
this path needs.
Persistence cost per message halved, because an append is one mutable state
write rather than a log write plus a frontier update, and the slope across
subscriber count is now flat rather than nearly flat.

Two things the three runs show that one run would not. Option 7 stalls for
about eight seconds in one run of three, so its tail is intermittent rather
than absent; a debug build made it show every time and that is why it looked
structural. And Option 5's latency and its history cost are the same dial:
across the runs the median latency and the event count move inversely and
their product is roughly constant, so the spread is the trade moving rather
than noise.
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