Status: FIXED (C2) — implemented + full gate green. The fix design + 2-round red-team is in
recovery-double-apply-fix-c2-design-2026-06-08.md; the implementation =CommittedWatermark'simage_coverage_lsn(set frommax_applied_lsnby the 3 recovery ctors, read-cleared by the first post-recovery checkpoint into the on-diskCheckpoint.checkpoint_lsn, WITHOUT inflating the watermark). u64+i64 RED→GREEN guards intests/persistent_recovery_watermark_seed_l14.rs; 2717-test suite green; unsafe-inventory 0-delta. Uncovered during the Slice-3 L1 recovery-redirect investigation; INDEPENDENT of (and pre-dated) that work. The related steady-state torn-checkpoint variant is the separate task #48.
A PersistentARTrie::<u64> (the byte counter monomorph — libgrammstein's n-gram count type)
that is recovered via open_with_recovery_config (corruption rebuild) — or recover_from_archives
— then checkpoint()'d, then reopened with open(), doubles its recovered BatchIncrement
deltas. A recovered +4 reads back as 8 after the reopen (verified: get_value("counter") == Some(8), expected Some(4)).
recovery_replay_completeness_correspondence.rs) test recovery +
read, but NOT recovery → checkpoint → reopen. So the re-drain on the SECOND open was untested.tests/persistent_recovery_watermark_seed_l14.rs guards use V=i64, which the u64-only
overlay delta applier (overlay_publish_counter → <u64,S> Any downcast, overlay_write_mode.rs:547)
silently NO-OPS — so for i64 the re-drain is dropped and the counter coincidentally stays correct
(the bug masks the bug). For u64 the delta arm works (increment_cas), so the double-apply is real.open_with_recovery_config (mmap_ctor.rs:852) and recover_from_archives
return the apply-loop trie directly (mmap_ctor.rs:1029) — they do NOT re-open through open_inner,
so they do NOT inherit the "F7 FIX C" committed-watermark base seed (open_inner mmap_ctor.rs:507).
Post-recovery watermark()==0.checkpoint() records checkpoint_lsn = committed watermark = 0 (overlay_checkpoint.rs:333).collect_retained_wal_segments_for_rebuild renamed the recovered WAL into the trie's wal_archive
(a .segment file collect_wal_segments enumerates), and the checkpoint at lsn=0 subsumes nothing,
so the archive SURVIVES.open() → reconcile_and_drain_overlay re-enumerates the archive; with
image_checkpoint_lsn=0, every record has lsn > 0, so the delta RE-DRAINS on top of the
checkpoint image (which already includes it) → double-apply.This is the same mechanism "F7 FIX C" fixes for the open_inner Owned→Overlay CONVERSION reopen; it was never applied to the corruption/archive REBUILD ctors.
The naive seed — trie.committed_watermark.mark_committed(max_lsn_in_segments(&segments)) after the
drain — violates the #41 capture-ordering invariant and panics at overlay_checkpoint.rs:295:
assert!(watermark_at_capture <= synced_frontier_at_capture). The recovery ctor builds a FRESH WAL
(synced frontier = 0; the recovered records were applied no-WAL, never appended to the new WAL), so
seeding watermark = max_lsn_in_archive (≥1) > 0 = frontier asserts a committed-but-not-durable LSN.
So the fix must reconcile the watermark with the recovery ctor's fresh-WAL model. Candidate directions (each needs a #41-aware red-team — this is the most data-loss-critical machinery in the system):
watermark = max \le frontier$ holds), or re-stamp the recovered records into the new WAL so the
frontier legitimately reflects them.checkpoint_lsn from the drained-segment max (decoupled from
the watermark) so the reopen skip works without claiming the watermark is durable — but this splits
the checkpoint_lsn from the #41 watermark, which the design deliberately unified.The L1 recovery-redirect ALSO requires genericizing the overlay counter applier over V (the absolute
arm flip.rs:1136 already is; the delta arm flip.rs:1166 must mirror it — read current via
counter_value_to_i128, +delta, i128_to_counter_value, overlay_publish_value). That generic-V fix
is correct + safe (the 283/285 counter/recovery corpus passes) BUT it UNMASKS this double-apply for
i64 too — so the generic-V applier fix and this #41-aware seed are a COUPLED package: neither ships
alone (the generic-V fix without the seed makes i64 reopen worse: 8 instead of the coincidental 4).
let t = PersistentARTrie::<u64>::create(&path)?; // create-flips to overlay
// replace .wal with [ WalRecord::BatchIncrement{ (b"counter", 4) } ]; corrupt the .part header
let (rec, _) = PersistentARTrie::<u64>::open_with_recovery_config(&path, recovery_config())?;
assert_eq!(rec.get_value("counter"), Some(4)); // recovery OK
rec.checkpoint()?; // checkpoint_lsn = watermark = 0
drop(rec);
let re = PersistentARTrie::<u64>::open(&path)?;
assert_eq!(re.get_value("counter"), Some(4)); // FAILS: Some(8) — DOUBLE-APPLY
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |