Repository navigation
Description
Summary
An adversarial review of durable as the session journal for the voice-ordering platform found defects in fencing, concurrency, replay fidelity, effect dispatch, timers, step numbering and growth. Every defect below has a failing test in #213. Each test asserts what any correct fix must produce, so it can move into the fix's PR unchanged. Line references are main @ 8770dcf. Relates to #179.
Impact
A recovered instance can silently differ from the live one: a different state, a lost acknowledged step, a missing timer, or an effect applied zero times. Two workers on one instance can both commit. Several of these surface only after a crash, deploy or lease handoff, which is exactly when durable is meant to hold. (durable restores the decision history; the platform restores the rest of the session state separately and reconciles with the downstream order system, so these defects surface as a wrong decision history, not a wrong session row.)
Findings (in the agreed fix order)
Fencing
- Two Handles on one instance both get acknowledged Fires, and recovery yields a state neither computed.
Appendtreats a different record for a recorded step as an idempotent no-op (store.go:87,memstore.go:117), and nothing invalidates an older Handle. Test:TestRecover_TwoHandlesOnOneInstance_JournalMatchesAnAcknowledgedHistory - A Fire whose
Appendfails stays applied to the live instance (runner.go:360runs the kernel before the append), so the next acknowledged Fire leaves live ahead of the journal. Test:TestRunner_FireAfterFailedAppend_MatchesJournal
Handle concurrency
- Fire during the documented "Tick from its own timer loop" pattern loses acknowledged steps on 2+ CPUs. Handle has no lock around
nextStep(runner.go:372, 389, 415, 437) or the kernel instance. Test:TestRunner_FireDuringTimerLoop_LosesNoAcknowledgedSteps
Replay fidelity
-
WithEventDatais dropped on recovery: the record keeps only the trace payload (runner.go:381) and replay re-fires without options (runner.go:620). Test:TestRecover_FireWithEventData_MatchesLive - A service result whose settle was canceled with the turn's context is journaled anyway (
service.go:119) and committed on recovery, while the live instance never saw it. Test:TestService_ResultFromCanceledTurn_NotCommittedOnRecover
Effect dispatch
- Only
Handle.Firedispatches domain effects. Effects fromStart's initial entry (runner.go:287absorbs onlyStartEffects), timer transitions (Tick,runner.go:412), service results (RunService,service.go:119) and actor delivery (actor.go:105) never reach the handler. Tests:TestRunner_EffectDispatch_Start_AppliesEntryEffect,TestRunner_EffectDispatch_TimerTransition_AppliesEffect,TestRunner_EffectDispatch_ServiceSettle_AppliesEffect - An effect whose dispatch failed is compacted away by the next checkpoint and never retried. Test:
TestRunner_EffectDispatch_FailedThenCheckpointed_AppliedOnce
Timers
-
Startdoesn't arm the initial state'saftertimer (runner.go:287, same path as above). Test:TestClock_StartInTimedState_ArmsTimer - A timer persisted by a checkpoint and fired in the tail is missed on replay: persisted timers are re-armed only after the tail replays (
runner.go:668). Test:TestTimerCheckpoint_FiredAfterCheckpoint_ByteIdentical - A timer armed by a service result is lost on replay:
RunServicerecords no clock read for the arm (service.go:119) and replay doesn't absorb the settle's timer effects (runner.go:589). Test:TestService_TimerArmedBySettle_ReplaysOnRecover
Step numbering
- When a checkpoint compacts the tail, recovery derives the next step from the trace count (
runner.go:698), which undercounts tick barriers. The next Fire reuses a recorded step and is dropped with a nil error. Test:TestRecover_StepAfterTickCheckpoint_IsPersisted
Telemetry on replay
- Middleware (the
sink/bridgeseam) runs again for every replayed step (runner.go:620), so recovery re-emits spans and sink events. No test until open question 7 is decided.
Growth
- Every tick appends a record even when no timer is due:
Scheduler.Tickalways reads the clock (state/driver.go:116), so the "records nothing" guard (runner.go:429) never applies. Test:TestClock_TickWithNothingDue_AppendsNoRecord - Checkpoints carry the full trace history (
runner.go:258forces unbounded history): 8 KB at step 50, 157 KB at step 1000. No test until open question 9 is decided.
Open questions
These are contract decisions the fixes depend on. The tests deliberately accept either answer where one exists. 1, 10 and 11 block the first checkpoints.
- Fencing: should
Recovertake over the instance (an older Handle's commits fail), or should commits use optimistic concurrency on the step number? Both pass the test. ARecoverthat refuses while a Handle is held would also need a release API. Appendcontract: keep first-writer-wins, or reject a different record for a recorded step?..._Append_FirstWriterWinsin the MemStore and FileStore tests asserts today's behavior.- Store without the dispatch set: keep the documented no-op (
effect.go:214,TestRunner_EffectDispatch_NonDispatchStore_NoOp), or refuse the configuration? - Recover while the effect handler is down: keep failing (no worker can resume until the downstream is back), or recover and redispatch later?
- Effect ids (
step#ordinal#kind,effect.go:150) repeat across instances, so they can't serve as downstream idempotency keys. Add the instance id (needs a dispatched-set migration), or pass the instance to the handler? - Machine version on recovery:
Recoverhard-codes itsRestoreoptions (runner.go:542), soRejectMachineVersionMismatchcan't be enabled and a deploy replays history through new code. Expose the opt-in, or make it strict? - Telemetry on replay: skip middleware while replaying, or run it with a replay marker in the context?
- A timer whose transition fails is consumed, and the error appears only in
results[i].Err. Re-arm it, return the error fromTick, or leave it? - Checkpoint size: bound the trace history in checkpoints? Doing so changes the byte-identical recovery guarantee the existing tests assert.
- Handle concurrency: is a Handle meant to support
FireandTickfrom different goroutines (asdrainClock's comments describe), or is single-goroutine ownership the contract? The concurrency test assumes the former. - Event data: journaling
WithEventDataneeds a way to read it fromstate(fireConfigis unexported,state/options.go:261), which is an additive API on the frozen v1 module. Add it, or have a Handle refuse the option? The test accepts either.
Notes
statestays at its frozen v1 contract. Question 11 is the only one that touches it, and only additively.- Struct service results coming back as JSON maps is the documented caveat (
doc.go, Caveats) and is not listed here.
Activity
reka18 commentedon Oct 22, 2026
Proposed answers to the open questions, from the platform-redesign design. Awaiting @joshua-temple's ruling.
AppendcontractRejectMachineVersionMismatchas an opt-in; don't make it strictTick; don't consume and hide itFire/Tickis the contract; the platform's timer loop depends on itstateAPI sodurablecan journal itConflict on question 1: the design's written session plane specifies a lease fencing token: an increasing number issued with each lease and carried on every append, so the store rejects a stale leaseholder. That's takeover fencing, not optimistic concurrency on the step. Both pass
TestRecover_TwoHandlesOnOneInstance_JournalMatchesAnAcknowledgedHistory, but the two answers disagree and need a ruling.The blocking ones for the first fix checkpoints are 1, 2, 10 and 11.
joshua-temple commentedon Nov 3, 2026
Additional specifics for fencing (question 1) and the Store contract, from an out-of-tree backend design. Line refs: crucible
main@ 8770dcf.AppendOptioncarries onlyWithIdempotencyKey(durable/options.go:26);CheckpointOptioncarries none (durable/options.go:48-52).WithFence(token int64)on bothAppendOptionandCheckpointOption, and addErrFenced.ErrFencedand writes nothing.reka18 commentedon Nov 5, 2026
I'm taking the fixes in this issue. Below are my current answers to the four questions that gate the first fixes (1, 2, 10 and 11), updating my table above, and I'll start from them so the fixes aren't held up. Say here if you'd rule any differently. Questions 3 to 9 don't gate the first fixes: my proposals on them stand for now, and I'll revisit 7 (telemetry on replay) before fixing replay telemetry.
WithFenceandErrFencedshape proposed above, on top of theAppendcheck in question 2. That's my answer to the conflict I flagged.FireandTickare the contract, so the Handle gets a lock and the concurrency test stands. My earlier reason was the platform's timer loop. The platform design now gives each session one owner goroutine, an actor, so I no longer rest the answer on that loop; the reason is now other hosts using the documented timer-loop pattern. How the lock is scoped, and whether the per-stepAppendstays inline or moves off that actor's loop with the blocking work in the state: bound the ActorSystem mailbox and add an overflow policy #215 comment, are still open. I'm keepingAppendinline unless the design says otherwise, so the fixes aren't held up. IfAppenddoes move, the fix for a failedAppendand the lock scope would change.stateAPI sodurablecan journalWithEventData.fix(durable): add write fencing, reject conflicting steps, journal ev…
fix(durable): add write fencing and reject conflicting steps
fix(durable): tighten the fencing contract and refused first writes
fix(durable): journal WithEventData payloads for recovery
fix(durable): carry the next step through a checkpoint
fix(durable): lock the Handle and refuse steps after a failed Append
fix(durable): don't journal a service result from a canceled turn
fix(durable): dispatch effects from Start, Tick and RunService
fix(durable): keep timers across recovery and skip idle ticks
fix(durable): land checkpoints when tick barriers step over the interval
176 remaining items
fix(durable): lock the Handle and refuse steps after a failed Append
fix(durable): don't journal a step whose context ended partway
fix(durable): dispatch effects from Start, Tick and RunService
fix(durable): keep timers across recovery and skip idle ticks
fix(durable): land checkpoints when tick barriers step over the interval
fix(durable): add write fencing and reject conflicting steps (#229)
fix(durable): land checkpoints when tick barriers step over the interval
fix(durable): journal WithEventData payloads for recovery (#230)
fix(durable): carry the next step through a checkpoint
fix(durable): carry the next step through a checkpoint (#232)
fix(durable): lock the Handle and refuse steps after a failed Append
fix(durable): don't journal a step whose context ended partway
fix(durable): dispatch effects from Start, Tick and RunService
fix(durable): keep timers across recovery and skip idle ticks
fix(durable): land checkpoints when tick barriers step over the interval