Skip to content

durable: FileStore can lose or double-apply steps after a failed write or crash #228

Open

Description

@reka18

Summary

FileStore can lose an acknowledged step or apply steps twice after a failed write or a crash, which contradicts its docs. Three separate gaps below, all reproduced on main @ 8770dcf. Line references are to durable/filestore.go. These come from FileStore's multi-file writes, so a backend that commits atomically isn't affected. I'll follow up with failing tests and a fix in one PR, after the first #214 fixes land.

Findings (in fix order)

  • A torn journal line loses acknowledged steps. A failed write can leave a partial line, and nothing truncates it, so the next Append merges into it and returns success. On reopen the merged line fails to parse and is skipped (:254). No crash is needed: on an HFS+ volume, Handle.Fire hit a real disk-full error, the next Fire succeeded, and the instance recovered at 1 where live was 3. Because the skip continues rather than stops, as :43 says, recovered steps can have gaps such as [0 1 3]. A retry of a lost step is reported as applied (:333). A later Checkpoint in the same process heals it, because it rewrites the journal from memory. TestFileStore_TornTrailingRecord_RecoversCleanly never reopens after the merge, so it misses this.
  • checkpoint.snap and checkpoint.meta aren't atomic together. The snapshot is renamed at :420 and the meta at :423, so a process kill or failed meta write between them pairs the new snapshot with the old step. Recovery then replays steps the snapshot already holds: a self-loop machine recovered 14 where live was 7, and a machine with a real initial transition failed Recover with "invalid transition". The Atomicity contract in store.go and :45-48 say this can't happen. TestFileStore_MissingCheckpointMeta_TreatedAsNoCheckpoint covers only a missing meta file.
  • The journal and the applied ledger can disagree. Append writes the journal at :349 and the ledger at :358, and reopen doesn't seed the ledger from the journal.
    • After a kill between the two, a retry of the same record returns ErrStepOutOfOrder rather than the idempotent no-op the Store contract promises. Only direct Store callers see this: Recover replays the step and carries on.
    • A failed ledger write, with no crash, leaves the step out of memory too, so a retry writes it twice. Through stateless Runner.Fire this gave a count of 3 after 2 acknowledgements.
    • A torn ledger line also loses the next step's key.

Notes

Activity

  1. self-assigned this
    on Nov 5, 2026
  2. joshua-temple commented on Nov 10, 2026

    @joshua-temple
    Collaborator

    One more case for the third finding, reproduced on main @ 494c78a (after #229):

    • A failed ledger write leaves step 1 out of the ledger, while the journal already holds it.
    • A retry with a different record for step 1 then also lands. The fix(durable): add write fencing and reject conflicting steps #229 conflict check reads the ledger, so it doesn't fire.
    • On reopen, Load returns two different records for step 1, and replay applies both.
    • Repro: Append step 0, make applied.log unwritable, Append step 1 "b" (fails), Append step 1 "c" (succeeds), then reopen and Load. Result: "journal holds 2 different records for step 1".
    • Seeding the ledger from the journal on load, as the Notes suggest, would not cover this, because there is no reload between the two Appends. The ledger write needs to happen before the journal sync, or the in-memory ledger needs updating once the journal is synced.
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