docs: record the v1 distributed-execution design - #18
Merged
Conversation
Documents where the correctness lives, which is the part that is expensive to revisit once anything is built on it. The decision: the log is the arbiter, not a coordination service. Single-writer-per-execution is enforced by a conditional append (append(id, event, expectedSeq)) rather than a distributed lock. A lock service can tell a node it holds the lock but not that it *still* holds it at the instant it writes — a GC pause or partition between those moments is enough for two nodes to both believe they own the stream. Pushing the check to where the write lands removes that class of failure entirely, and Catalyst's dense per-execution seq is already a natural fence. The consequence matters more than the mechanism: placement becomes an optimisation rather than a correctness dependency. A stale writer is rejected by storage even if the coordination layer is wrong, so the choice of actor system is a late, reversible decision. Surveyed both sibling projects for this. Cajun's ClusterActorSystem is the closest fit for placement and can be swapped in behind KeyedLock — the seam was left for exactly this — but nothing rests on its maturity. Bayou does not distribute at all (single process, no node identity), yet it shares Gumbo with Catalyst, so it is useful within a node and could later consume the same primitives to become clustered itself. That is the argument for putting these primitives in Gumbo rather than in an actor system: it is the layer both already share. The three gaps are all storage-side — conditional append, lease CAS with TTL, and a claimable-work index. The last is the least obvious: there is currently no way to ask the log what needs running, since every read path starts from an id you already hold. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G54yVgLZGZ3mzBv4kPc9F3
Greptile SummaryRecords the proposed v1 distributed-execution architecture.
Confidence Score: 5/5The documentation-only PR appears safe to merge. No blocking failure remains. Important Files Changed
Sequence DiagramsequenceDiagram
participant A as Node A
participant B as Node B
participant L as Shared Log
A->>L: Claim execution lease
L-->>A: Lease granted
A->>L: "append(event, expectedSeq=N)"
L-->>A: "Append accepted, seq=N+1"
Note over A: Node stalls and lease expires
B->>L: Claim expired lease
L-->>B: Lease granted
B->>L: "append(event, expectedSeq=N+1)"
L-->>B: "Append accepted, seq=N+2"
A->>L: "append(event, expectedSeq=N+1)"
L-->>A: Reject stale writer
Reviews (5): Last reviewed commit: "docs: don't let conditional append degra..." | Re-trigger Greptile |
…l append The design doc listed shared-log concurrency as an open question. It is now answered, by experiment rather than by reading. Two JVMs pointed at one Gumbo directory, each appending three events to the same execution, were both handed seq 0,1,2 — and the log afterwards reported three of the six appends. That is the single-writer invariant this document exists to protect, violated silently. The raw files show the damage is narrower than the symptom suggests, which makes it fixable: all six events are physically present in log.dat. What breaks is the index (written per-process, last closer clobbers) and the id space (localId is assigned from a per-process in-memory counter, seeded from another in-memory counter, in both the file and FoundationDB adapters). FoundationDBSequencer does not cover it — it sequences the global seqnum, while localId, which is exactly what Catalyst uses as seq, never passes through the Sequencer at all. There is also no file lock, so a second process opens a live log without complaint. The single-process restart path is sound — a fresh process continued at 3,4,5 after a previous one wrote 0,1,2, because the index is rebuilt from a log scan on open. The gap is concurrency, not durability. This relocates the fix. Catalyst cannot layer conditional append above Gumbo, because Gumbo assigns the id: a Catalyst-side expectedSeq check would race the assignment underneath it. The comparison and the assignment must be atomic in the same store, so conditional append has to be a Gumbo primitive with EventLog merely exposing it — reinforcing on correctness grounds what the Bayou survey already suggested on reuse grounds. Phasing gains a step 0: Gumbo multi-writer safety is a hard prerequisite and is Gumbo-side work. Worth doing regardless of whether Catalyst ever distributes — a log that assigns ids safely across writers and supports compare-and-append is simply a better log, and Bayou benefits from the same work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G54yVgLZGZ3mzBv4kPc9F3
Records why the multi-writer defect exists, which matters more than the symptom because it bounds how much has to change. Gumbo's tags come from Boki, where they are virtual log-stream identifiers — one physical log serving many logical streams. That is the right partitioning for per-execution streams and Catalyst's one-tag-per-execution is the intended usage, so the abstraction is not what needs fixing. The defect is visible in Gumbo's own Boki mapping table: localId was repurposed from per-engine to per-tag while keeping per-engine assignment. In Boki a node-local AtomicLong is correct by construction, since each engine has its own localid space and the sequencer reconciles them into the global seqnum afterwards. Under the redefinition localId became a per-entity cursor shared by every writer of that tag, but the assignment stayed process-local. The semantics moved; the implementation did not. That also explains why seqnum is fine — its distributed story was anticipated and delivered by FoundationDBSequencer, while localId never needed one in Boki. So the fix is not novel design work, it is applying the existing Sequencer pattern at tag granularity. The remaining open question narrows accordingly: not "how", but whether a per-execution FDB key is an acceptable access pattern versus a persisted per-tag counter incremented in the same transaction as the append — the latter also making expectedSeq free, since the comparison and the increment collapse into one operation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G54yVgLZGZ3mzBv4kPc9F3
Two of the three "gaps" in this design were partly solved already; going and
looking shrank the remaining work.
The KV is in Gumbo, not Catalyst or Bayou: PersistenceAdapter's
setTagValue/getTagValue/deleteTagValue, persisted to kv.dat in the file
adapter and a kvSubspace in FoundationDB. Catalyst is already a client of it
for both the idempotency index and snapshots. Leases therefore need a
conditional write on a store that already exists, not a new store.
The claimable-work index was called out in the first draft as the least
obvious and most consequential gap. It is neither. Boudin already solves it on
the same substrate by exploiting Gumbo's multi-tag append: one atomic append
writes to both workflow-history:{workflowId} and workflow-tasks:{taskQueue},
with no separate two-phase write. Catalyst can tag ExecutionCreated into a
task queue alongside its execution tag and get the same property — no new SPI,
no secondary index to keep consistent, and no window where an execution is
recorded but not yet claimable. Gumbo also has push subscriptions, which
Boudin's dispatcher uses instead of polling, so polling latency comes off the
list of limitations and a placement layer has less left to add.
Boudin turns out to be the closest sibling of the three surveyed — its crash
recovery is recognisably the same design as ReplayingContext — and it is
single-worker-process for the same reason Catalyst would be: no lease, claim
or ownership anywhere in its source. So the Gumbo work unblocks all three
projects, Boudin most immediately, since it wants the identical
worker-claiming story.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G54yVgLZGZ3mzBv4kPc9F3
…all claim Both from PR #18 review; both were right. The default method as written would have ignored expectedSeq and written unconditionally, so an implementation that never overrode it would look like it was participating in the fencing protocol while providing none of it — the worst possible failure shape for a correctness primitive, and a direct contradiction of this document's own thesis. The default now throws, matching the convention Gumbo already uses for optional adapter capabilities, plus a supportsConditionalAppend() query so the runtime can refuse distributed execution against a log that cannot fence rather than discovering it by corruption. Source compatibility is kept; silent degradation is not. The zero-duplicate-model-calls claim was also overstated, and measuring it confirmed the reviewer: a log hand-built to end at CompletionRequested with no CompletionReceived resumed and invoked the model again. The guarantee is "no duplicate *recorded* boundaries", not "no duplicate provider calls" — a boundary in flight when the node dies is in doubt, and the provider may already have billed for it. That turned up a real asymmetry worth recording separately in the roadmap: seed() detects a ToolRequested with no ToolCompleted and routes recovery through InDoubtPolicy, but there is no equivalent for model completions — a trailing CompletionRequested sets pendingRequestHash and is otherwise ignored, with no danglingModel counterpart to danglingTool. Distribution does not introduce that window, it just makes it far more frequent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G54yVgLZGZ3mzBv4kPc9F3
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.
Documents where the correctness lives, which is the part that is expensive to
revisit once anything is built on it.
The decision: the log is the arbiter, not a coordination service.
Single-writer-per-execution is enforced by a conditional append
(append(id, event, expectedSeq)) rather than a distributed lock. A lock
service can tell a node it holds the lock but not that it still holds it at
the instant it writes — a GC pause or partition between those moments is
enough for two nodes to both believe they own the stream. Pushing the check
to where the write lands removes that class of failure entirely, and
Catalyst's dense per-execution seq is already a natural fence.
The consequence matters more than the mechanism: placement becomes an
optimisation rather than a correctness dependency. A stale writer is rejected
by storage even if the coordination layer is wrong, so the choice of actor
system is a late, reversible decision.
Surveyed both sibling projects for this. Cajun's ClusterActorSystem is the
closest fit for placement and can be swapped in behind KeyedLock — the seam
was left for exactly this — but nothing rests on its maturity. Bayou does not
distribute at all (single process, no node identity), yet it shares Gumbo
with Catalyst, so it is useful within a node and could later consume the same
primitives to become clustered itself. That is the argument for putting these
primitives in Gumbo rather than in an actor system: it is the layer both
already share.
The three gaps are all storage-side — conditional append, lease CAS with TTL,
and a claimable-work index. The last is the least obvious: there is currently
no way to ask the log what needs running, since every read path starts from an
id you already hold.
Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_01G54yVgLZGZ3mzBv4kPc9F3