fix(durable-streams-rust): recovery hardening — fail-stop barriers, crash-safe metadata, rollback-on-error#4710
Conversation
…rash-safe metadata, rollback-on-error Deep failure-mode review (4 focused audits) findings, all data-loss or availability class: FAIL-STOP durability barriers (fsyncgate family): a failed fdatasync/syncfs/ seal must never be retried in place — Linux marks the failed pages clean and clears the fd error state, so the retry falsely succeeds and (a) the committer would ack lost bytes, (b) the next checkpoint would recycle the WAL records that were the only durable copy, (c) a retried seal could graft a zero tail that silently ends replay. All three now abort the process (nothing acked is at risk at first-error time; restart replays the durable log). Committer thread is supervised: a panic aborts instead of freezing durable_lsn forever (silent whole-server DoS). WAL stage write failures retry bounded (data still in hand) then abort — no more permanent watermark gap that wedged the shard. Checkpoint error handling: a failed checkpoint re-registers the drained dirty set — previously the streams' tails proofs were dropped, the NEXT checkpoint recycled their WAL records anyway, and any later restart truncated acked, durable bytes back to a stale frontier. Crash-safe metadata: sidecar tmp is always synced before rename (a torn rename target used to get the sidecar AND the data file deleted at boot); unparsable sidecars now QUARANTINE (.meta.corrupt, stream skipped, data kept) instead of deleting the stream; sidecar read errors fail the boot; recovery data-file open errors fail the boot loudly (EMFILE used to silently drop streams whose WAL records the reset then destroyed); wal/shards and streams/.lanes written tmp+sync+rename+dir-fsync; dir fsyncs added for segment create/roll/reset (same-name inode clobber) and the tails rename before recycle; per-lane .lane mount markers refuse to boot when a lane's device mount is missing (previously the empty mountpoint booted 'fine' and the WAL reset destroyed the lane's acked records); create rolls back the map entry + file + parent refcount when the durable meta write fails (phantom streams whose acked appends the next boot deleted); recovered compaction intents are durably cleared before appends (double-crash file_base mis-derivation shifted all reads). Append rollback: a stage failure now rolls back the data-file write, tail, and producer/seq/close state — 500'd bytes used to be durably resurrected by the next append, and the client's retry was swallowed as a producer duplicate. write_wire truncates partial writes (ENOSPC desynced logical/physical offsets for all later data). Tier: seal cuts at the DURABLE tail, not the writer tail (sealing un-acked bytes made them served-as-durable after a crash and permanently shadowed the retried acked bytes); boot re-offload unlinks the local chunk only after the manifest flip is durable; an unreadable sealed chunk now poisons the read (was: silently omitted interior bytes in a well-formed 200); Meta::capture snapshots segments+sealed_offset under one manifest lock (torn captures persisted manifest holes). 3 new e2e regression tests (stage rollback, sidecar quarantine, lane-mount guard); the stage-failure injection hook moved pre-reservation to model the now-reachable semantics. 109/109 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk
Fault-injection validationThe recovery-hardening fixes in this PR were validated against real I/O faults on a separate, disposable branch — Approach: in-process failpoints at the six durability-critical seams ( 5 scenarios, all green:
Alternatives (tikv The branch is intentionally not for merge — it exists only as a record of the validation. |
Performance validation — no write regressionBuilt a Result: no measurable cost. canonical-write (peak append/s, 256 B; Δ vs #4697):
canonical-mixed-delivery (SSE delivery under a write ladder): memory delivery still tracks writes 1:1 to 140k/s (the 2026-07-02 collapse stays fixed); wal caps at ~64k del/s at full write saturation — same shape as #4697. Full data, split provenance, and per-suite grids: electric-sql/ds-bench#7. Both suites self-tore-down; zero GKE clusters at run end. |
Deep failure-mode & recovery review of the WAL/store/tier stack (four focused audits: durability-barrier errors, recovery/boot robustness, runtime failures, cold tier), with every accepted finding fixed. No instrumentation in this PR — fault-injection tooling used to validate these paths lives on a separate disposable branch.
The central design correction: durability barriers are now fail-stop
Every barrier (committer
fdatasync, checkpointsyncfs/per-file fsync, segmentseal_to) was retryable-in-place. On Linux ≥4.13 a failed fsync marks the dirty pages clean and clears the fd's error state — the retry falsely succeeds, so:All barrier failures now abort the process. This is provably safe: nothing at risk was acked at first-error time (acks gate on the barrier), so restart replay is strictly better than the false-success path. The committer thread is also supervised — a panic aborts instead of freezing
durable_lsn(which silently hung every append until connection-permit exhaustion = total DoS).Data-loss fixes (each with the exact fault sequence in the commit message)
.meta.corrupt), never deleted; sidecar read errors fail the boot.lanemount markers; boot refuses a missing lane mountreset_after_recovery, tails rename before recycle;wal/shards+.lanestmp+sync+rename+dir-fsyncwrite_wiretruncates partial writesfile_base, shifting every later read and replay writeTier fixes
ResolvedSlice::Missing) — previously the slice was silently omitted and a well-formed 200 was served with interior bytes missing, advancing the client past them forever.Meta::capturesnapshotssegments+sealed_offsetunder one manifest lock (torn captures persisted manifest holes below the watermark).Verified sound by the review (no changes needed)
Compaction single-crash matrix; torn-tail replay semantics for acked data; checkpoint-file optionality; steady-state offload ordering; fork/GC refcounting; accept-loop EMFILE backoff.
Deferred (tracked as follow-ups, availability/operational class)
Offload retry sweep + tier telemetry; mutex-poison strategy overhaul (~150
.lock().unwrap()sites — the fail-stop supervision covers the worst blast radius);wait_durabledeadline; ENOSPC recycle-before-tails ordering; SSE reactor thread supervision.Verification
109/109 tests, including 3 new e2e regressions: stage-failure rollback (bytes must not resurrect live or across recovery), corrupt-sidecar quarantine, missing-lane-mount boot refusal. The stage-failure injection hook moved pre-reservation to model the now-reachable semantics (post-reservation write failures retry bounded then abort). A follow-up fault-injection campaign (separate branch) exercises the fsyncgate/torn-write/ENOSPC paths that deterministic tests cannot.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Y3x7bcT9vLGiT4tXZeQpnk