Skip to content

durable: defects found in the platform-redesign review #214

Open

Description

@reka18

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. Append treats 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 Append fails stays applied to the live instance (runner.go:360 runs 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

  • WithEventData is 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.Fire dispatches domain effects. Effects from Start's initial entry (runner.go:287 absorbs only StartEffects), 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

  • Start doesn't arm the initial state's after timer (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: RunService records 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/bridge seam) 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.Tick always 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:258 forces 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.

  1. Fencing: should Recover take over the instance (an older Handle's commits fail), or should commits use optimistic concurrency on the step number? Both pass the test. A Recover that refuses while a Handle is held would also need a release API.
  2. Append contract: keep first-writer-wins, or reject a different record for a recorded step? ..._Append_FirstWriterWins in the MemStore and FileStore tests asserts today's behavior.
  3. Store without the dispatch set: keep the documented no-op (effect.go:214, TestRunner_EffectDispatch_NonDispatchStore_NoOp), or refuse the configuration?
  4. Recover while the effect handler is down: keep failing (no worker can resume until the downstream is back), or recover and redispatch later?
  5. 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?
  6. Machine version on recovery: Recover hard-codes its Restore options (runner.go:542), so RejectMachineVersionMismatch can't be enabled and a deploy replays history through new code. Expose the opt-in, or make it strict?
  7. Telemetry on replay: skip middleware while replaying, or run it with a replay marker in the context?
  8. A timer whose transition fails is consumed, and the error appears only in results[i].Err. Re-arm it, return the error from Tick, or leave it?
  9. Checkpoint size: bound the trace history in checkpoints? Doing so changes the byte-identical recovery guarantee the existing tests assert.
  10. Handle concurrency: is a Handle meant to support Fire and Tick from different goroutines (as drainClock's comments describe), or is single-goroutine ownership the contract? The concurrency test assumes the former.
  11. Event data: journaling WithEventData needs a way to read it from state (fireConfig is 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

  • state stays 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

  1. reka18 commented on Oct 22, 2026

    @reka18
    ContributorAuthor

    Proposed answers to the open questions, from the platform-redesign design. Awaiting @joshua-temple's ruling.

    # Question Proposed answer
    1 Fencing Optimistic concurrency on the step number (see the conflict below)
    2 Append contract Reject a different record for a recorded step; first-writer-wins is the two-Handles bug
    3 Store without the dispatch set Refuse the configuration
    4 Recover while the handler is down Recover now, redispatch later; never compact away a failed dispatch
    5 Effect ids Add the instance id
    6 Machine version on recovery Expose RejectMachineVersionMismatch as an opt-in; don't make it strict
    7 Telemetry on replay Run middleware with a replay marker; don't skip it
    8 Failed timer transition Return the error from Tick; don't consume and hide it
    9 Checkpoint size Don't bound it yet; add a checkpoint-size metric and revisit
    10 Handle concurrency Concurrent Fire/Tick is the contract; the platform's timer loop depends on it
    11 Event data Add the additive state API so durable can journal it

    Conflict 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.

  2. joshua-temple commented on Nov 3, 2026

    @joshua-temple
    Collaborator

    Additional specifics for fencing (question 1) and the Store contract, from an out-of-tree backend design. Line refs: crucible main @ 8770dcf.

    • AppendOption carries only WithIdempotencyKey (durable/options.go:26); CheckpointOption carries none (durable/options.go:48-52).
    • A backend that commits atomically on the server side (a scripted commit in a networked key-value store) needs the fence value at the Store boundary.
    • Proposal: whichever answer question 1 takes, expose it as WithFence(token int64) on both AppendOption and CheckpointOption, and add ErrFenced.
    • The Store contract then states: a write whose fence is lower than the highest seen for the instance returns ErrFenced and writes nothing.
    • The runner passes the fence it was recovered with on every Append and Checkpoint.
    • Planned use checkpoints at every exchange commit, so Checkpoint must carry the fence too, not only Append.
    • These cases belong in the shared Store conformance suite proposed separately, so an external backend proves the fence.
  3. reka18 commented on Nov 5, 2026

    @reka18
    ContributorAuthor

    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.

    • Question 1 (changed from optimistic concurrency on the step number): takeover with a fencing token, using the WithFence and ErrFenced shape proposed above, on top of the Append check in question 2. That's my answer to the conflict I flagged.
    • Question 2 (unchanged): reject a different record for a recorded step. The fence is caller-supplied and only refuses a lower token, so a write on the same token still needs this check. durable: export a Store conformance suite for out-of-tree backends #223 expects it too.
    • Question 10 (same answer, new reason): concurrent Fire and Tick are 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-step Append stays 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 keeping Append inline unless the design says otherwise, so the fixes aren't held up. If Append does move, the fix for a failed Append and the lock scope would change.
    • Question 11 (unchanged): add the additive state API so durable can journal WithEventData.
  4. self-assigned this
    on Nov 5, 2026
  5. 176 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions