Stream Processor Runner — Redesign Spec & Implementation Plan
Status: implemented; the phase list below is the historical cutover sequence.
Design record: stream-processor-runner-design-conversation.md (raw transcript — this file is the distilled spec; build from this one).
Why#
Over the last weeks, three unrelated concerns accreted onto the processor model:
reconcilebecame a third mandatory hook — but it only exists to centralize an "only act at the maximum durable offset" guard that was needed after refold semantics changed. A processor is not inherently a reconciler.processEventBatchbecame five things — a transport batch doubles as a reduce batch, a concurrency group, an effect-failure group, a checkpoint transaction, and an observation that the scan reached the highest offset known to the runner. It started as a browser-SQLite optimisation; 6 of 8 overrides compensated for missing runner guarantees.- The host is the star, processors are anonymous —
createStreamProcessorHost(ctx)hosts N processors viahost.add(factory), hand-fedctx. The processor instance should be the star; the host should recede into plumbing.
Root fault: one {offset, state} cursor means two incompatible things — "my fold cache is valid through E" and "all external work is acknowledged through E". A routine state-schema deploy therefore refolds and re-runs processEvent across history, re-driving vendor calls.
Settled semantic model#
The processor interface#
abstract class StreamProcessor<Contract> {
abstract readonly contract: Contract; // schema + slug + consumes
protected validate?(args): void; // OPTIONAL, sync — throw to reject (inline/pre-commit only)
protected reduce(args): State; // OPTIONAL, pure fold, default identity
protected processEvent(args): undefined; // OPTIONAL, SYNCHRONOUS, event is null only for an eventless caught-up call
// operator/inspection: snapshot(), getRuntimeState(), waitUntilEvent(), reReduce(), reprocessFrom(), skipThrough()
}- No public
reconcile. Reconciliation is ordinaryprocessEventlogic underdelivery.caughtUp. It normally rides the last consumed event in a caught-up scan; if that scan contains no consumed event, the runner makes one eventless call (event: null) over the final fold. Per-event dispatch guardsevent !== null. - No author-overridable
processEventBatch. It survives only as the internal Stream-DO→runner wire callback. - A stateless processor implements only
processEvent. A pure fold implements onlyreduce.
The two block primitives are duals of one concept#
"Refuse to let this event through until you're satisfied" — expressed at whichever commit position the runner occupies:
| pre-commit (inline / core) | post-commit (hosted processor) | |
|---|---|---|
| block | validate throws → rejects the append |
blockProcessorWhile(work) awaits → holds the cursor |
| timing | synchronous, before persist | async, after commit |
runInBackground |
shared — register thunk, arm keepalive, advance; author owns recovery | shared |
processEvent stays synchronous; the author explicitly picks blockProcessorWhile (order barrier) or runInBackground (overtaking, recovery-owned). blockProcessorWhile is a strict per-event barrier — event N+1 does not start until N's blocking work resolves. Transport batching never changes this.
Two durable positions (progress)#
type Progress<State> = {
reduction: { reducerVersion: string; reducedThroughOffset: number; state: State };
processing: { acknowledgedThroughOffset: number; cursorRevision: number };
};
// invariant (persisted): reducedThroughOffset <= acknowledgedThroughOffset- Core is the "reduction only, no processing cursor" case; hosted has both. Graceful degradation — same structure, same re-reduce.
- Checkpoint cadence is a runner policy (every event / N / 50ms / idle / size / txn). In memory the runner tracks
completedThroughOffset; it durably persistsacknowledgedThroughOffsetper policy. The gap is the deliberate at-least-once replay window.
Operator controls (each appends an audit event to the journal)#
reReduce()— rebuild reduction cache through the acked cursor, no effects. Fires automatically onreducerVersionchange.reprocessFrom(offset, expectedCursorRevision)— CAS; sets ack =offset-1, bumpscursorRevision, reconstructs state, then re-runsreduce+processEventfromoffset. Snapshot honestly rewinds while it catches up.skipThrough(offset, reason)— manual, audited, fenced escape past a poison event; advances ack without running the effect.
Failure#
blockProcessorWhile permanent failure → retry with backoff, indefinitely (no stalled event; a wedge shows as growing lag). runInBackground is the non-blocking door. skipThrough is the only escape. No auto-DLQ.
Idempotency#
delivery.idempotencyKey(key) is derived deterministically from authorSuppliedKey + sourceOffset + cursorRevision. No random id, no effectRevision, no implicit processor prefix. Crash retry → same key (dedupes). reprocessFrom → new key (genuinely re-emits).
Revival#
- One core
stream/processor-revivedevent, withprocessorSlugin its payload, is appended by the recovery adapter. Declaring it inconsumesis optional and is only needed when the processor reacts to the fact itself. runInBackgroundrequires a recovery adapter when losing the work's outcome would matter. The revival append wakes delivery even when the fact is unconsumed.- Reaching head guarantees the revival turn at zero lag: a consumed event receives
caughtUp: true; otherwise the runner suppliesprocessEvent(event: null, caughtUp: true).
Waiting / snapshot#
waitUntilEvent(offset) — single method, means acknowledged-through. snapshot() is pinned to reducedThroughOffset.
Delivery context (passed to processEvent)#
delivery: { phase: "catching-up" | "live"; highestObservedOffset: number;
caughtUp: boolean; cursorRevision: number;
idempotencyKey(key: string): string }Runner architecture#
StreamProcessorRunner— plain, runtime-neutral object (browser / DO / test). The name is free on current main (only dead comments reference the deleted DO class).- The processor is passed INTO the runner.
runner.openEventBatchCallback()returns{ checkpointOffset, processEventBatch }. The callback reduces/processes one event at a time; transport batching lives entirely inside it. A hosted processor returns this pair fromwakeStreamProcessor; the browser mirror calls the same runner method directly and passes incoming batches to the callback. durability(optional adapter) ={ progress, recovery? }.progress: read/commit the two-cursor record, CAS-fenced bycursorRevision.recovery: arm keepalive + appendstream/processor-revived+handleAlarm. No Cloudflarectxin the runner core.- The host dies (option B — thin registry). The DO holds its processors as named fields + one
StreamProcessorRunnerper processor. A deliberately thin registry owns only: the single-DO-alarm multiplex, slug→runner wake routing, and buildingdurabilityfromctx. This replacescreateStreamProcessorHost/host.add(factory). If the registry grows behavior beyond those three jobs, it has failed. - Two ways to use the same processor contract:
StreamProcessorRunnerprocesses post-commit event batches (Phase 1).StreamCoreProcessorvalidates and reduces synchronously in the append transaction (Phase 2).
Phase 1 — hosted processors (StreamProcessorRunner)#
This is where the net reduction lives. Deletions/migrations measured on origin/main:
| Site | ~lines | Fate |
|---|---|---|
stream-processor.ts public reconcile + caught-up gate |
— | delete |
overridable processEventBatch |
— | delete (keep one internal batch callback) |
github-agent ordering override |
~30 | pure delete (per-event blocking is default) |
agent reconciliation path |
~120 | relocate → processEvent under delivery.caughtUp |
slack-agent reconcile + caught-up status carry |
~107 | relocate + private debounce |
telegram-agent caught-up typing carry |
~30 | private freshness |
capability-host caught-up obligation gate |
~36 | relocate → processEvent under delivery.caughtUp |
repo caught-up creation obligation |
~40 | relocate → processEvent under delivery.caughtUp |
scheduler alarm derivation |
~40 | absorb into runner |
browser-feed / browser-raw-events SQLite batch |
~45 | private transactional committer (writes + progress in ONE txn) |
{offset,state} snapshot |
— | split → two cursors + cursorRevision fencing; refold runs reduce only |
createStreamProcessorHost (4 DOs, host.add) |
— | StreamProcessorRunner per processor + thin registry |
Three DOs (slack, telegram, capability-host) carry hand-written comments explaining how they work around concurrent blockers within a batch — that whole category evaporates when blocking is per-event.
Slice order (each independently reviewable):
- Additive — new
StreamProcessorinterface,Progresstypes,deliverycontext,StreamProcessorRunnerskeleton,durability/progress/recoveryadapter interfaces. Nothing deleted yet. StreamProcessorRunnerper-event loop + two-cursor progress adapter (DO-KV backed) + cadence policy.- In-memory test harness = the executable spec (see below).
- Migrate one processor end-to-end (
agent) as the proof: reconciliation →processEventunderdelivery.caughtUp; verify per-event ordering. - Migrate the rest (capability-host, repo, slack, telegram, scheduler, github).
- Browser transactional committer (feed + raw-events);
reducerVersion→reReduce, output-schema reset→reprocessFrom(1). - Delete the host; wire the thin registry into the 4 DOs.
- Delete the now-dead
reconcile/processEventBatch/ caught-up hooks.
Phase 2 — core unification (InlineRunner) — GATED, do second#
StreamCoreProcessorusesvalidate+reduce+processEventwith the DO's send/alarm capabilities injected as dependencies.validateis the pre-commit append gate.InlineRunner: synchronous, await-free commit path;validatethrows → rejects the append;processEventpost-commit sync; onlyrunInBackground(=ctx.waitUntil, no keepalive); progress = reduction cache only (no processing cursor);reReduce=reducerVersionmismatch replay (folds core'sCORE_STATE_VERSIONdiscard-on-wake into the shared mechanism). Core keepsstream/wokenas its revival.- Deletes ~200–300 lines of bespoke DO plumbing:
#reduce/#reduceCoremode dispatch,#validateAppend, the#processEventswitch scaffolding,#readCoreProcessorState/#checkpointCoreProcessorState/#catchUpCoreProcessorState. - GATE: a commit-path benchmark (append throughput + constructor fold time) must prove zero regression before cutover. The append path is the crown jewel — "do not make this async, no awaits", voice-streams-from-birth, OOM-in-constructor-bricks-the-stream. Ship Phase 1 first so the shared machinery is exercised on the recoverable post-commit path before the stream's own commit point runs on it.
Test harness (the spec)#
In-memory runner as the semantic specification. Must pin:
- transport-batch-division invariance (same journal as one batch / singletons / random partitions → identical outcomes),
- strict
blockProcessorWhileordering;runInBackgroundovertaking, crash()at every boundary; zero-lag revival,- refold runs
reduceonly (noprocessEvent); stale-cursorRevisionfencing, - atomic browser SQLite failure,
validaterejects the append (inline).
Non-goals#
- Physically merging core into one runner — core stays inline, pre-commit (it assigns offsets, owns
maxOffset, and validates appends; it cannot run after commit). Unification is at the interface and shared reduction code, not the runner. - Changing producer or stream-delivery batching — both kept.