fix(storage): cold-tier correctness + reliability (DEL/FLUSH resurrection, expired-read leak, crash durability, orphan sweep, liveness) - #212
Conversation
…ction, expired-read leak, crash durability, orphan sweep, liveness metrics
Five fixes for the disk-offload (cold tier) path, from the offload
architecture review (tmp/OFFLOAD-COMPRESSION-REVIEW.md), each red/green
TDD-proven:
D1 — DEL/UNLINK/FLUSH cold resurrection (P1 bug). Database::remove and
clear() never touched the ColdIndex, so deleting a spilled key left its
index entry alive and the next GET resurrected the deleted value from the
.mpf heap file; DEL even returned 0 for cold-only keys. remove()/clear()
now drop cold entries (queueing file unlinks), and DEL/UNLINK use a new
remove_counting_cold() so cold-only keys count as removed.
R1 — expired cold reads reclaim their index entry. cold_read returned a
bare Option, so an expired-on-disk entry left its index entry + file
refcount leaked forever. New ColdReadOutcome{Hit,Expired,Miss} lets the
Database::get read-through remove the index entry on Expired only —
transient I/O errors (Miss) never drop a key.
D3 — directory fsync after spill publication. Spill writes fsynced the
file but never data/, so a power loss could vanish the directory entry of
a file the (dir-fsynced) manifest references. Both the batch (tmp+rename)
and single-file spill paths now fsync the data directory.
R3 — startup sweep of crash-orphaned heap files. A crash between spill
write and manifest commit leaked unregistered heap-*.mpf/.tmp files
forever (invisible to the cold index). Recovery now unlinks heap files
not registered in the manifest — only after the manifest opened
successfully, so a corrupt-manifest signal can never trigger deletion.
R5 — spill-thread liveness metrics. A silently-dead spill thread was
observable only as unbounded eviction backlog. The spill loop now stamps
a heartbeat and counts flushed batches; INFO persistence exposes
spill_batches_flushed, spill_completions_dropped,
spill_last_heartbeat_ms.
Tests: 5 new unit tests (3 in cold_read, 1 in kv_spill, 1 in
spill_thread), all red before the fix; full lib suite 3658 pass; clippy
clean on default + tokio,jemalloc feature sets.
author: Tin Dang
PR Summary by QodoFix cold-tier correctness: DEL/FLUSH, expiry cleanup, fsync, sweep, metrics
AI Description
Diagram
High-Level Assessment
Files changed (9)
|
Code Review by Qodo
Context used✅ Compliance rules (platform):
40 rules 1. unwrap() missing allow comment
|
| fn test_spill_thread_liveness_metrics() { | ||
| let tmp = tempfile::tempdir().unwrap(); | ||
| let batches_before = spill_batches_flushed_total(); | ||
|
|
||
| let st = SpillThread::new(9); | ||
| let sender = st.sender(); | ||
| sender | ||
| .send(SpillRequest { | ||
| key: Bytes::from_static(b"liveness_key"), | ||
| db_index: 0, | ||
| value_bytes: Bytes::from_static(b"liveness_value"), | ||
| value_type: ValueType::String, | ||
| flags: 0, | ||
| ttl_ms: None, | ||
| file_id: 1, | ||
| shard_dir: tmp.path().to_path_buf(), | ||
| }) | ||
| .unwrap(); | ||
| drop(sender); |
There was a problem hiding this comment.
1. unwrap() missing allow comment 📘 Rule violation ✧ Quality
New .unwrap() calls were added without the required adjacent // ... justification comment and #[allow(clippy::unwrap_used)] attribute in scope. This violates the project’s unwrap audit policy and can cause lint/audit gate failures.
Agent Prompt
## Issue description
New `.unwrap()` calls were introduced without the required `#[allow(clippy::unwrap_used)]` attribute and a one-line justification comment immediately above it.
## Issue Context
PR Compliance requires every `.unwrap()` to be paired with a local allow attribute and an adjacent justification comment.
## Fix Focus Areas
- src/storage/tiered/spill_thread.rs[589-607]
- src/storage/tiered/cold_read.rs[205-223]
- src/storage/tiered/kv_spill.rs[463-480]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| if orphan { | ||
| match std::fs::remove_file(&path) { | ||
| Ok(()) => { | ||
| removed += 1; | ||
| tracing::info!("cold-tier sweep: removed crash-orphaned {}", name); | ||
| } | ||
| Err(e) => warn!("cold-tier sweep: failed to remove {}: {}", name, e), | ||
| } | ||
| } |
There was a problem hiding this comment.
3. Unlink lacks dir fsync 🐞 Bug ☼ Reliability
sweep_orphan_heap_files() removes crash-orphaned heap-* files but does not fsync the data/ directory afterward, so a crash can lose the unlink metadata and leave the orphaned files behind (undermining the sweep’s durability goal). The repo’s own fsync_directory helper explicitly documents directory fsync as required for unlink metadata durability.
Agent Prompt
### Issue description
`sweep_orphan_heap_files()` unlinks files from `{shard_dir}/data` but never fsyncs the directory. On filesystems that require an explicit directory fsync for metadata durability, the unlink can be lost after a crash, leaving the crash-orphaned files behind.
### Issue Context
The project’s own helper documents directory fsync as required for **rename/unlink metadata durability**.
### Fix Focus Areas
- src/storage/tiered/kv_spill.rs[381-420]
### Suggested fix
After the sweep loop, if `removed > 0`, do a best-effort `fsync_directory(&data_dir)`.
- Keep the sweep’s “never abort recovery” contract by logging fsync errors (warn) instead of returning early.
- Optionally, only fsync when at least one unlink succeeded (to avoid extra syscalls on no-op sweeps).
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
|
Warning Review limit reached
Next review available in: 30 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (9)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…sing Executes all three steps of moon#660. A decision taken 2026-07-10 to flip `--disk-offload` from default-on to opt-in was recorded and never carried out; #660 filed that gap. `--disk-offload` now defaults to `disable`. Nothing about the tier changed and nothing is deprecated. `--disk-offload enable` turns it on, existing offload files are left untouched, and they are picked up again when it is re-enabled. `value_parser` now restricts the flag to `enable`/`disable`: only the exact string `enable` turns the tier on, so a typo used to mean "silently off" and would now mean "silently without the tier you upgraded specifically to keep". The reason is not a double-write conflict with the WAL -- spilled segments are independently self-durable and recover on their own. It is RECONCILIATION. Recovery runs Phase 3 (rebuild cold_index from the manifest) then Phase 4 (WAL replay on top, hot shadowing cold), and every bug found in that seam so far has been silent-data-loss class: DEL/FLUSH resurrection and expired-cold leak (#212), BITOP/COPY/DEL/UNLINK resurrection (#213), a spill completion resurrecting a DEL'd key (#459). Each was caught by soak or adversarial review, none by a proof. Step 2 supplies the proof. `tests/cold_reconciliation_property_660.rs` drives seeded random SET/SET PX/DEL/UNLINK/COPY/FLUSHDB sequences over a deliberately small keyspace under real memory pressure, then checks every key the sequence ever touched against a model -- live, and again after SIGKILL and a full Phase-3/Phase-4 recovery. The three failure shapes are named separately because all three have shipped: resurrection, expired-cold leak, lost/stale write. It refuses a vacuous pass, prints the seed, and MOON_660_SEEDS=<n> replays one case. No proptest dependency: a durability default is not the place to also widen the supply chain. Mutation-proved -- gutting `Database::remove_cold_only` reddens it with "RESURRECTION ... prop:key:004 ... v4-19-xxxx", on SEED 5 rather than seed 1. That is recorded in the file: whether a sequence happens to delete a key while it is cold is what the allkeys-lru victim choice decides, so shrinking the seed sweep to save wall-clock would quietly cost most of the file's power. Three of its own bugs were found by measurement while building it, each fixed at the cause rather than tuned around: the non-vacuity guard fired correctly when 4.9 MiB of filler never crossed an 8 MiB cap; raising it to 16 MiB produced -OOM, whose fix is that a REFUSED write must not be applied to the model (`accepted`), so correctness no longer depends on picking a filler size that never trips the cap; and a COPY mismatch that looked like the #610 cold-tier class replayed ALONE and passed, making it a race between the model's clock and the server's on a 300 ms TTL -- the prediction is now asserted only when neither key is volatile, which keeps the cold-source case that would actually catch #610. The flip's blast radius, measured by running all 268 test binaries against it: 264 green, and the one substantive red was `vector_db_isolation`. `vector_persist_dir_for` resolves the index-metadata directory to the disk-offload dir when the tier is on and to `persistence_dir` otherwise, and `persistence_dir` is None under `--appendonly no` with no `--save`. So a server with NO durability configured still persisted its FT.* index definitions, purely because the tier defaulted on. Measured on one binary with the flag explicit on both sides of a restart: --disk-offload disable --appendonly yes -> index survives --disk-offload enable --appendonly no -> index survives --disk-offload disable --appendonly no -> index LOST Only the third row changes, and it is the row where the operator asked for no durability at all, so the new behaviour is the consistent one. It was simply invisible before. That suite now pins `--disk-offload enable` explicitly -- preserving the environment it was written against rather than quietly re-pointing it at a different persistence path -- and a new `ft_index_survives_restart_without_disk_offload` pins the row an upgrading deployment actually lands on (tier off, AOF on), which nothing covered. Mutation-proved: make the non-offload arm of `vector_persist_dir_for` return None and it reddens. Docs carry both operator impacts: the memory one (a server that relied on the default now holds its keyspace in RAM and evicts, or answers -OOM under noeviction, where it previously spilled) and the FT one. BREAKING CHANGE: `--disk-offload` now defaults to `disable`. Pass `--disk-offload enable` to keep the previous behaviour. Refs: #660, #212, #213, #459 author: Tin Dang
…sing Executes all three steps of moon#660. A decision taken 2026-07-10 to flip `--disk-offload` from default-on to opt-in was recorded and never carried out; #660 filed that gap. `--disk-offload` now defaults to `disable`. Nothing about the tier changed and nothing is deprecated. `--disk-offload enable` turns it on, existing offload files are left untouched, and they are picked up again when it is re-enabled. `value_parser` now restricts the flag to `enable`/`disable`: only the exact string `enable` turns the tier on, so a typo used to mean "silently off" and would now mean "silently without the tier you upgraded specifically to keep". The reason is not a double-write conflict with the WAL -- spilled segments are independently self-durable and recover on their own. It is RECONCILIATION. Recovery runs Phase 3 (rebuild cold_index from the manifest) then Phase 4 (WAL replay on top, hot shadowing cold), and every bug found in that seam so far has been silent-data-loss class: DEL/FLUSH resurrection and expired-cold leak (#212), BITOP/COPY/DEL/UNLINK resurrection (#213), a spill completion resurrecting a DEL'd key (#459). Each was caught by soak or adversarial review, none by a proof. Step 2 supplies the proof. `tests/cold_reconciliation_property_660.rs` drives seeded random SET/SET PX/DEL/UNLINK/COPY/FLUSHDB sequences over a deliberately small keyspace under real memory pressure, then checks every key the sequence ever touched against a model -- live, and again after SIGKILL and a full Phase-3/Phase-4 recovery. The three failure shapes are named separately because all three have shipped: resurrection, expired-cold leak, lost/stale write. It refuses a vacuous pass, prints the seed, and MOON_660_SEEDS=<n> replays one case. No proptest dependency: a durability default is not the place to also widen the supply chain. Mutation-proved -- gutting `Database::remove_cold_only` reddens it with "RESURRECTION ... prop:key:004 ... v4-19-xxxx", on SEED 5 rather than seed 1. That is recorded in the file: whether a sequence happens to delete a key while it is cold is what the allkeys-lru victim choice decides, so shrinking the seed sweep to save wall-clock would quietly cost most of the file's power. Three of its own bugs were found by measurement while building it, each fixed at the cause rather than tuned around: the non-vacuity guard fired correctly when 4.9 MiB of filler never crossed an 8 MiB cap; raising it to 16 MiB produced -OOM, whose fix is that a REFUSED write must not be applied to the model (`accepted`), so correctness no longer depends on picking a filler size that never trips the cap; and a COPY mismatch that looked like the #610 cold-tier class replayed ALONE and passed, making it a race between the model's clock and the server's on a 300 ms TTL -- the prediction is now asserted only when neither key is volatile, which keeps the cold-source case that would actually catch #610. The flip's blast radius, measured by running all 268 test binaries against it: 264 green, and the one substantive red was `vector_db_isolation`. `vector_persist_dir_for` resolves the index-metadata directory to the disk-offload dir when the tier is on and to `persistence_dir` otherwise, and `persistence_dir` is None under `--appendonly no` with no `--save`. So a server with NO durability configured still persisted its FT.* index definitions, purely because the tier defaulted on. Measured on one binary with the flag explicit on both sides of a restart: --disk-offload disable --appendonly yes -> index survives --disk-offload enable --appendonly no -> index survives --disk-offload disable --appendonly no -> index LOST Only the third row changes, and it is the row where the operator asked for no durability at all, so the new behaviour is the consistent one. It was simply invisible before. That suite now pins `--disk-offload enable` explicitly -- preserving the environment it was written against rather than quietly re-pointing it at a different persistence path -- and a new `ft_index_survives_restart_without_disk_offload` pins the row an upgrading deployment actually lands on (tier off, AOF on), which nothing covered. Mutation-proved: make the non-offload arm of `vector_persist_dir_for` return None and it reddens. Docs carry both operator impacts: the memory one (a server that relied on the default now holds its keyspace in RAM and evicts, or answers -OOM under noeviction, where it previously spilled) and the FT one. BREAKING CHANGE: `--disk-offload` now defaults to `disable`. Pass `--disk-offload enable` to keep the previous behaviour. Refs: #660, #212, #213, #459 author: Tin Dang
…-offload Splits moon#660 in two and lands only the half that is safe on its own. WHAT LANDS `tests/cold_reconciliation_property_660.rs` — the proof #660 records as the one piece of work worth doing regardless of what happens to the default. Disk offload is a two-source-of-truth durability path, and the hazard is RECONCILIATION, not a double-write conflict with the WAL: recovery runs Phase 3 (rebuild `cold_index` from the manifest) then Phase 4 (WAL replay on top, hot shadowing cold). Every bug found in that seam so far has been silent-data-loss class (#212, #213, #459), and every one was caught by soak or adversarial review — never by a proof that the invariant holds in general. A seeded generator drives writes, deletes and expiries under real memory pressure and asserts the server's answer for every key matches a model, both live and after SIGKILL + full recovery. Failures are named by shape (RESURRECTION, EXPIRED-COLD LEAK, LOST WRITE); `MOON_660_SEEDS` replays any seed. It earned its keep immediately — it is what surfaced the COPY/BITOP single-shard durability bug as a deterministic 3-of-3 CI failure instead of a soak-only ghost. `--disk-offload` also gains `value_parser = ["enable", "disable"]`. Only the exact string `enable` ever turned the tier on, so `--disk-offload enabled` silently meant "without the tier". WHAT IS HELD BACK, AND WHY The default flip to `disable` is NOT here. It is a breaking change with a SILENT failure mode: nothing detects existing offload state when the tier is off. `disk_offload_base` is None, the v3 recovery branch is skipped entirely, `cold_index` is never built, and the operator gets no warning, no error and no INFO field — just a smaller keyspace. `docs/versioning.md` documents upgrade as "stop, swap the binary, start", which against that change is a silent keyspace shrink. The flip needs a startup check that REFUSES to start when offload state exists with the tier off, five doc updates, and a runbook before it can ship. Held on its own branch and tracked; nothing about that work is blocked by this commit, and this commit is what makes the flip provable when it comes. author: Tin Dang
…-offload Splits moon#660 in two and lands only the half that is safe on its own. WHAT LANDS `tests/cold_reconciliation_property_660.rs` — the proof #660 records as the one piece of work worth doing regardless of what happens to the default. Disk offload is a two-source-of-truth durability path, and the hazard is RECONCILIATION, not a double-write conflict with the WAL: recovery runs Phase 3 (rebuild `cold_index` from the manifest) then Phase 4 (WAL replay on top, hot shadowing cold). Every bug found in that seam so far has been silent-data-loss class (#212, #213, #459), and every one was caught by soak or adversarial review — never by a proof that the invariant holds in general. A seeded generator drives writes, deletes and expiries under real memory pressure and asserts the server's answer for every key matches a model, both live and after SIGKILL + full recovery. Failures are named by shape (RESURRECTION, EXPIRED-COLD LEAK, LOST WRITE); `MOON_660_SEEDS` replays any seed. It earned its keep immediately — it is what surfaced the COPY/BITOP single-shard durability bug as a deterministic 3-of-3 CI failure instead of a soak-only ghost. `--disk-offload` also gains `value_parser = ["enable", "disable"]`. Only the exact string `enable` ever turned the tier on, so `--disk-offload enabled` silently meant "without the tier". WHAT IS HELD BACK, AND WHY The default flip to `disable` is NOT here. It is a breaking change with a SILENT failure mode: nothing detects existing offload state when the tier is off. `disk_offload_base` is None, the v3 recovery branch is skipped entirely, `cold_index` is never built, and the operator gets no warning, no error and no INFO field — just a smaller keyspace. `docs/versioning.md` documents upgrade as "stop, swap the binary, start", which against that change is a silent keyspace shrink. The flip needs a startup check that REFUSES to start when offload state exists with the tier off, five doc updates, and a runbook before it can ship. Held on its own branch and tracked; nothing about that work is blocked by this commit, and this commit is what makes the flip provable when it comes. author: Tin Dang
…enable (#812) * perf(server): let inline writes run under the default --disk-offload enable `can_inline_writes` carried the term `ctx.spill_sender.is_none()`. `--disk-offload` defaults to `enable`, which spawns a per-shard `SpillThread` and hands every connection a live sender — so that term was false out of the box and the inline `SET` fast path never ran in the shipped default. (`GET` was unaffected: `can_inline_reads` never carried the term.) The term was a CONFIG predicate standing in for a STATE one. What a live sender changes is eviction ROUTING, and only that: with one, `run_write_eviction_gate` builds `EvictionRun::async_spill`, whose victims are handed to the `SpillThread` under `--appendonly yes`; the inline path can only build `EvictionRun::plain`, whose victims are DELETED. Inlining a write while eviction fires would silently substitute a drop for a spill. So the gate now enforces the actual invariant — the inline write path may run only when eviction provably will not fire — answered per-write by the lock-free `inline_write_can_skip_eviction` pre-gate, hoisted ABOVE the point where the command bytes leave `read_buf` so the bail-out is a true "not handled" rather than a silently lost write. Also adds `!conn.in_cross_txn()`. That defect is INTRODUCED here, not exposed: inside an open TXN the generic leg captures an undo record and a write intent before dispatching and `try_inline_dispatch` does neither, so `SET k original; TXN BEGIN; SET k modified; TXN ABORT; GET k` answered "modified" — an acked abort that rolls back nothing. Found by security review of this branch before it was opened. Corrects the `run_write_eviction_gate` doc, which claimed the no-manifest plain-drop fallback is taken for EVERY write past `maxmemory` under `--disk-offload enable`. It is taken only under `--appendonly no`; with `--appendonly yes` the `AsyncSpill` arm ignores the manifest and spills. New suite `tests/inline_write_spill_gate_660.rs` — 13 tests over --shards 1 and 4, cfg-gated to `runtime-monoio` because `local_inline` is permanently 0 under tokio. Every claim carries a reddening mutation that was applied, observed and reverted; the table in the file header names each one. Refs #660 author: Tin Dang * fix(server): stop inline writes bypassing the write-stall refusal Widening `can_inline_writes` (previous commit) let a plain `SET` reach the inline path in the shipped default. `segment_stall::stall_refusal` — the only producer of `-MOONERR memfull: writes paused until memory pressure recovers`, the MA12 disk-free refusal, and the moon#718 segment-stall refusal — has exactly two call sites, both in GENERIC dispatch. `try_inline_dispatch` has none, so an inlined write answered `+OK` for a write the server had already committed to refusing under memory or disk pressure. Caught by the existing suite, not by review: merge-base 7678156 passes `tests/mem_watchdog.rs` (cases A and B) and `tests/compaction_escape_hatch_718.rs`; the widened gate failed all three. The fix bails to generic dispatch rather than answering the error inline. `stall_refusal` is not a plain boolean — it exempts the commands that are a stall's own remedy (#718's escape hatch) and distinguishes its three sources. Re-deriving that on this path is exactly the drift the shared helper exists to prevent, so the whole decision defers to the leg that owns it. Placed with the eviction pre-gate, above the point where the command bytes leave `read_buf`, so the bail-out is a true "not handled" and not a lost write. Reads are untouched: this is the SET-only branch, and `mem_watchdog` case B asserts GET stays answerable while memfull is engaged. Cost is three Relaxed AtomicBool loads, all false on an unstalled server. Refs #660 author: Tin Dang * fix(server): close four more inline-write gaps found by review Adversarial review of this branch found two further obligations the generic write leg carries and `try_inline_dispatch` did not, and a performance review found two more. Same class as the three already fixed here: an enforcement or side effect that lives in the generic frame loop, which the inline block `continue`s past whenever it consumed the whole buffer. 1. CLIENT PAUSE was bypassed. Measured on one binary under `CLIENT PAUSE 3000 WRITE`: inline SET returned in 0.027s, generic SET (MONITOR attached) in 2.999s, HSET in 2.002s. The pause worked; the inline leg escaped it, so writes landed during a window an operator believes is frozen for failover or backup. Gated on a new lock-free `client_pause::pause_possibly_active()` hint rather than `check_pause` itself, because the latter takes a global RwLock read and a global lock on the write path is what this fast path exists to avoid. It BAILS to generic dispatch, which owns mode/expiry/duration. 2. The -LOADING gate was bypassed. Across a restart with a 40k-document FT index, 6/6 probes while loading:1 answered +OK here and -LOADING on main. 3. Inlined commands were invisible to total_commands_processed (200 plain SETs moved it by 0). `this_thread_commands` is the adaptive idle park's (#373) activity signal, so a shard serving only inlined commands read zero commands/tick and could be classified idle under full load. 4. The shard's cached clock was never refreshed on the inline path. Idle 10s, then SET followed immediately by OBJECT IDLETIME answered 18. Under allkeys-lru every inline-written key carried a frozen stamp, degrading victim selection exactly under memory pressure. Fixed at the same per-batch cadence the generic leg already uses. Also corrects two claims this branch made that measurement did not support: - g2's assertion demanded that ZERO writes inline during an eviction window and blamed any slip on victims being "plain-dropped instead of spilled". The Linux gate caught it: 5 of 2000 inlined. Both halves were wrong. The gate reads PUBLISHED hints that lag under rapid growth, and such a write SKIPS eviction rather than resolving it -- no EvictionRun::plain is ever built, so no drop occurs. g2 now asserts the real safety property (plain drops must not dominate tiering) and bounds the slip at 1% instead of forbidding it. Its window also runs until it has proved its own precondition, after the fixed window tiered on macOS but not on the Linux host (spilled 351 -> 351). - the stall gate's cost comment claimed "three Relaxed AtomicBool loads". Measured: ~7 loads, 2 Acquire, ~4 cache lines, 2 pointer chases, because two of the three sources reach state through OnceLock + an Arc chase. Two deterministic unit guards added for (1) and (2), each proved to redden by deleting its pre-gate. The suite stays 13/13; lib tests 147. Refs #660 author: Tin Dang * test(server): close the review findings on the inline-write gate A test-integrity pass over moon#660 found that the two most recent fixes had shipped with NO guard at all: deleting both the cached-clock refresh and the inline command-counter left the entire suite green. Two more findings were that G1 passed unchanged with inline writes disabled outright, and that `spill_sender_active: true` -- the operand that makes the bail-out fire -- had no unit coverage, every existing call site passing `false`. GROUP 7 adds the missing guards, both mutation-proved rather than assumed: deleting `refresh_now_from_cache` makes a key written microseconds ago report `OBJECT IDLETIME 5s`; deleting `record_inline_commands` advances `total_commands_processed` by 0 while the inline counter climbs by 200. G1 gains an inline-path CONTROL, and `test_inline_set_bails_only_when_a_spill_ sender_is_live` drives `needs_eviction` deterministically by publishing a 1-byte maxmemory so the two calls differ only in the operand under test. The twenty-two unit tests that drive `try_inline_dispatch` are now serialised, because the CLIENT PAUSE guard mutates process-global state the others read. Two claims are WITHDRAWN rather than defended. G2's slip bound now applies at --shards 1 only. The elastic budget lets a lone hot shard borrow its idle siblings' headroom, so at --shards 4 it spends most of a window legitimately under budget and inlines most of it -- measured on the Linux gate at 7,719 of 8,000 writes, with at most 15 plain drops against 156 spills. That is the pre-gate working, and the 1% ceiling asserted against it was wrong, not the server. G2's safety assertion, the one guarding against silent data loss, still runs at both shard counts. G3 is likewise documented as what it is: `remove_cold_only` is unreachable from `try_inline_dispatch`, so G3 reddens identically on merge-base -- a cold-plane guard riding this fixture, not evidence for the inline change -- and it is its RESTART assertion, not its live one, that carries the guard. Two harness defects the same gate surfaced are fixed. `spawn_moon` reserved its admin port OUTSIDE the retry loop, so a held admin port made moon exit at start-up while `spawn_listening` -- which polls the child exactly once, moon#811 -- handed back the corpse; the observable was `read_line` panicking with a bare "read byte". The crash-restart leg had no readiness check at all, only `Client::connect`, which proves a listener accepts and, under SO_REUSEPORT, not even that the peer is the process just spawned. Both now wait for a real +PONG, and `read_line` reports which server died and what it had already sent. Source changes are comment-only: the cross-txn block cited two line numbers that had drifted ~120 lines, and claimed the term closes the MVCC snapshot-visibility hole when only the undo half is demonstrated -- the inline READ path bypasses that filter independently, present identically on merge-base (moon#807). Refs: #660, #807, #811 author: Tin Dang * fix(server): publish the CLIENT PAUSE hint under the lock that clears it Review of PR #812 found a real race in the lock-free pause hint this branch added, and it is the bad direction: a paused write escaping the pause. `pause()` stored `PAUSE_ANY = true` BEFORE acquiring the `PAUSE` write lock, while `unpause()` and `expire_if_needed()` cleared it while holding that lock. A concurrent clear could therefore land in between: 1. `pause()` stores `PAUSE_ANY = true`, then blocks on `PAUSE.write()`. 2. `unpause()` / `expire_if_needed()` takes the lock, clears `active`, and stores `PAUSE_ANY = false`. 3. `pause()` acquires the lock and sets `active = true`. leaving `active == true` with the hint reading `false` for the WHOLE pause window -- so `pause_possibly_active()` answers false and every inline `SET` skips its pause pre-gate during a window an operator believes is frozen. The doc comment asserted this "cannot happen"; it could. Publishing the store inside the lock serialises all three writers and makes the claim true. `test_pause_hint_survives_a_clear_racing_an_activation` makes that interleaving deterministic rather than hoping a stress loop hits it: the test thread holds the write lock so `pause()` blocks exactly where the race lives, lands the clear first, then releases. Mutation-proved -- moving the store back above the lock reddens it with "CLIENT PAUSE is active but the lock-free hint reads false". Also from the same review, two flake sources: * The pause tests and the inline-dispatch tests each had their OWN mutex, and two disjoint mutexes are not serialisation. `test_pause_and_check`'s `pause(5000, ..)` could run concurrently with an inline SET and redden its CONTROL assertion for a reason nothing in its body mentions. There is now one `pause_test_lock()`, next to the state it guards, taken by both modules. * G3's overwrite step demanded a strict `+OK` from writes issued after the filler, where this file's own doc records the inline path answering the fail-loud `-MOONERR AOF backpressure` in roughly 1 run in 3. Those keys are DEL'd immediately after and carry no durability assertion, so the step needs only that the command was answered: `assert_filler_accepted`. `write_probes` stays strict deliberately -- it runs on an empty server before any filler, and its durability IS asserted downstream. Refs: #660 author: Tin Dang * test(storage): add the #660 reconciliation proof, and validate --disk-offload Splits moon#660 in two and lands only the half that is safe on its own. WHAT LANDS `tests/cold_reconciliation_property_660.rs` — the proof #660 records as the one piece of work worth doing regardless of what happens to the default. Disk offload is a two-source-of-truth durability path, and the hazard is RECONCILIATION, not a double-write conflict with the WAL: recovery runs Phase 3 (rebuild `cold_index` from the manifest) then Phase 4 (WAL replay on top, hot shadowing cold). Every bug found in that seam so far has been silent-data-loss class (#212, #213, #459), and every one was caught by soak or adversarial review — never by a proof that the invariant holds in general. A seeded generator drives writes, deletes and expiries under real memory pressure and asserts the server's answer for every key matches a model, both live and after SIGKILL + full recovery. Failures are named by shape (RESURRECTION, EXPIRED-COLD LEAK, LOST WRITE); `MOON_660_SEEDS` replays any seed. It earned its keep immediately — it is what surfaced the COPY/BITOP single-shard durability bug as a deterministic 3-of-3 CI failure instead of a soak-only ghost. `--disk-offload` also gains `value_parser = ["enable", "disable"]`. Only the exact string `enable` ever turned the tier on, so `--disk-offload enabled` silently meant "without the tier". WHAT IS HELD BACK, AND WHY The default flip to `disable` is NOT here. It is a breaking change with a SILENT failure mode: nothing detects existing offload state when the tier is off. `disk_offload_base` is None, the v3 recovery branch is skipped entirely, `cold_index` is never built, and the operator gets no warning, no error and no INFO field — just a smaller keyspace. `docs/versioning.md` documents upgrade as "stop, swap the binary, start", which against that change is a silent keyspace shrink. The flip needs a startup check that REFUSES to start when offload state exists with the tier off, five doc updates, and a runbook before it can ship. Held on its own branch and tracked; nothing about that work is blocked by this commit, and this commit is what makes the flip provable when it comes. author: Tin Dang
Summary
Five fixes for the disk-offload (cold tier) path, from the offload architecture review (
tmp/OFFLOAD-COMPRESSION-REVIEW.md). Each fix was red/green TDD-proven — every new test failed before its fix.D1 — DEL/UNLINK/FLUSH cold resurrection (P1 bug)
Database::remove/clear()never touched theColdIndex, so deleting a spilled key left its index entry alive and the next GET resurrected the deleted value from the.mpfheap file (DEL even returned 0 for cold-only keys).remove()/clear()now drop cold entries (queueing file unlinks via the existing refcount/pending-unlink machinery), and DEL/UNLINK use a newDatabase::remove_counting_cold()so cold-only keys count as removed.R1 — expired cold reads reclaim their index entry
The cold read path returned a bare
Option, so an expired-on-disk entry left its index entry + file refcount leaked forever (nothing else reclaims them — the orphan sweep only checks hot-shadowing). NewColdReadOutcome{Hit, Expired, Miss}letsDatabase::getremove the index entry onExpiredonly; transient I/O errors (Miss) never drop a key.D3 — directory fsync after spill publication
Spill writes fsynced the file but never
data/, so a power loss could vanish the directory entry of a file the (dir-fsynced) manifest references. Both the batch (tmp+rename) and single-file spill paths nowfsync_directory(data/)after publishing.R3 — startup sweep of crash-orphaned heap files
A crash between spill write and manifest commit leaked unregistered
heap-*.mpf/.tmpfiles forever. Recovery now unlinks heap files not registered in the manifest — gated on the manifest opening successfully, so a corrupt-manifest signal can never trigger deletion.R5 — spill-thread liveness metrics
A silently-dead spill thread was observable only as unbounded eviction backlog.
INFO persistencenow exposesspill_batches_flushed,spill_completions_dropped,spill_last_heartbeat_ms(0 = never ran).Testing
cold_read, 1 inkv_spill, 1 inspill_thread), all confirmed RED before the fixescargo fmt --check, clippy-D warningson default andruntime-tokio,jemalloc,cargo test --release(monoio),cargo test --no-default-features --features runtime-tokio,jemalloc(26 suites, 0 failures)Not in this PR (documented follow-ups in tmp/OFFLOAD-COMPRESSION-REVIEW.md)