Skip to content

docs: record the v1 distributed-execution design - #18

Merged
contrasam merged 5 commits into
mainfrom
claude/distribution-design
Jul 25, 2026
Merged

docs: record the v1 distributed-execution design#18
contrasam merged 5 commits into
mainfrom
claude/distribution-design

Conversation

@contrasam

Copy link
Copy Markdown
Contributor

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

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-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown

Greptile Summary

Records the proposed v1 distributed-execution architecture.

  • Establishes storage-backed conditional append as the single-writer correctness fence.
  • Documents lease-based work claiming, Gumbo’s multi-writer prerequisites, and phased implementation.
  • Qualifies recovery guarantees for in-flight model completions and records the remaining duplicate-call window.

Confidence Score: 5/5

The documentation-only PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
docs/distribution.md Documents the distributed-execution invariants, storage primitives, operational model, limitations, and implementation phases.
ROADMAP.md Expands the distributed-execution roadmap and records the in-doubt model-completion work.

Sequence Diagram

sequenceDiagram
    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
Loading

Reviews (5): Last reviewed commit: "docs: don't let conditional append degra..." | Re-trigger Greptile

Comment thread docs/distribution.md
Comment thread docs/distribution.md
claude added 4 commits July 25, 2026 09:21
…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
@contrasam
contrasam merged commit 666f419 into main Jul 25, 2026
3 checks passed
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.

2 participants