Skip to content

fix(storage): cold-tier correctness + reliability (DEL/FLUSH resurrection, expired-read leak, crash durability, orphan sweep, liveness) - #212

Merged
pilotspacex-byte merged 1 commit into
mainfrom
fix/cold-tier-correctness
Jul 6, 2026
Merged

fix(storage): cold-tier correctness + reliability (DEL/FLUSH resurrection, expired-read leak, crash durability, orphan sweep, liveness)#212
pilotspacex-byte merged 1 commit into
mainfrom
fix/cold-tier-correctness

Conversation

@pilotspacex-byte

Copy link
Copy Markdown
Contributor

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 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 via the existing refcount/pending-unlink machinery), and DEL/UNLINK use a new Database::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). New ColdReadOutcome{Hit, Expired, Miss} lets Database::get 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_directory(data/) after publishing.

R3 — startup sweep of crash-orphaned heap files

A crash between spill write and manifest commit leaked unregistered heap-*.mpf/.tmp files 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 persistence now exposes spill_batches_flushed, spill_completions_dropped, spill_last_heartbeat_ms (0 = never ran).

Testing

  • 5 new unit tests (3 in cold_read, 1 in kv_spill, 1 in spill_thread), all confirmed RED before the fixes
  • macOS: full lib suite 3658 passed
  • OrbStack Linux CI-parity matrix green: cargo fmt --check, clippy -D warnings on default and runtime-tokio,jemalloc, cargo test --release (monoio), cargo test --no-default-features --features runtime-tokio,jemalloc (26 suites, 0 failures)
  • Transient cluster-test failures during the first VM run were the pre-existing 100ms-sleep harness flake under load (pass 3/3 in isolation and in the clean re-run)

Not in this PR (documented follow-ups in tmp/OFFLOAD-COMPRESSION-REVIEW.md)

  • D2 BGREWRITEAOF-drops-cold-keys invariant (decision record + regression test)
  • R2 defer-remove on promote, R4 sparse-file compaction
  • EXISTS/TYPE/MOVE cold-transparency gaps (siblings of D1)
  • A/C performance items (batch read-through, compression upgrades)

…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
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix cold-tier correctness: DEL/FLUSH, expiry cleanup, fsync, sweep, metrics

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Make DEL/UNLINK/FLUSH clear cold-tier index entries to prevent value resurrection.
• Reclaim expired-on-disk entries by distinguishing Expired vs Miss in cold reads.
• Improve crash-safety and operability: directory fsync, orphan-file sweep, spill liveness metrics.
Diagram

graph TD
  C["Client commands (GET/DEL/UNLINK/FLUSH/INFO)"] --> DB["Database (hot + read-through)"] --> CI["ColdIndex (refs + pending unlink)"]
  DB --> CR["cold_read (Hit/Expired/Miss)"] --> HF["Heap files (heap-*.mpf)"]
  ST["Spill thread"] --> KS["kv_spill (write + dir fsync)"] --> HF
  RC["Recovery (startup)"] --> MF["Shard manifest"] --> SW["Orphan sweep"] --> HF
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Make cold_read mutate ColdIndex directly
  • ➕ Localizes expiry reclamation logic to cold_read implementation
  • ➕ Avoids adding an outcome enum to propagate state upward
  • ➖ Requires mutable access to ColdIndex in read path, increasing borrow/locking complexity
  • ➖ Harder to keep Database as the single point deciding when deletions are safe
2. Periodic background heap GC instead of startup sweep
  • ➕ Cleans up orphans without waiting for restart
  • ➕ Can amortize filesystem work over time
  • ➖ Adds continuous runtime overhead and new scheduling/failure modes
  • ➖ Still needs manifest-safety gating to avoid destructive deletion on corruption

Recommendation: Current approach is appropriate: (1) returning ColdReadOutcome keeps cold_read side-effect free while still enabling Database to reclaim only confidently-expired entries; (2) directory fsync after publication matches the manifest’s durability model; and (3) startup-only orphan sweeping is a low-risk, well-scoped cleanup gated on successful manifest open.

Files changed (9) +478 / -39

Enhancement (2) +74 / -1
connection.rsExpose spill-thread liveness counters via INFO persistence +7/-1

Expose spill-thread liveness counters via INFO persistence

• Extends the INFO persistence section to include spill batch flush count, dropped completion count, and last heartbeat timestamp sourced from spill_thread statics.

src/command/connection.rs

spill_thread.rsAdd spill-thread heartbeat and batch counters with unit test +67/-0

Add spill-thread heartbeat and batch counters with unit test

• Introduces global liveness/throughput metrics (last heartbeat timestamp and batches flushed) and updates the spill loop and flush path to publish them. Adds a unit test asserting heartbeat publication and batch counter increment after a successful spill.

src/storage/tiered/spill_thread.rs

Bug fix (6) +373 / -38
key.rsCount cold-only keys as removed for DEL and UNLINK +9/-2

Count cold-only keys as removed for DEL and UNLINK

• Switches DEL/UNLINK to a new Database removal API that treats cold-only (spilled) keys as logically present. Preserves UNLINK’s async-drop decision by still returning the hot entry when present.

src/command/key.rs

recovery.rsSweep crash-orphaned heap files after successful manifest open +11/-0

Sweep crash-orphaned heap files after successful manifest open

• Invokes tiered storage orphan sweeping during shard recovery to delete heap files not referenced by the manifest. The sweep is gated on manifest open success and only logs failures to avoid aborting recovery.

src/persistence/recovery.rs

db.rsFix cold-tier deletion/flush semantics and reclaim expired disk entries +60/-15

Fix cold-tier deletion/flush semantics and reclaim expired disk entries

• Updates read-through to consume a ColdReadOutcome and remove only truly-expired cold index entries. Ensures clear()/flush clears cold-tier index state, and ensures remove() also drops any cold copy; adds remove_counting_cold for Redis DEL/UNLINK semantics.

src/storage/db.rs

cold_index.rsReturn presence on remove and add clear_all for FLUSH semantics +18/-2

Return presence on remove and add clear_all for FLUSH semantics

• Changes ColdIndex::remove to return whether the key existed and continues to refcount/queue pending unlinks. Adds clear_all() to drop all keys and queue all backing files for unlink during FLUSHDB/FLUSHALL.

src/storage/tiered/cold_index.rs

cold_read.rsIntroduce ColdReadOutcome and tests for delete/flush and expiry reclamation +174/-19

Introduce ColdReadOutcome and tests for delete/flush and expiry reclamation

• Adds ColdReadOutcome {Hit, Expired, Miss} and an outcome-aware cold_read_through API so callers can reclaim index entries only when expiry is confirmed. Hardens error handling to treat I/O/corruption as Miss, and adds unit tests covering cold resurrection prevention, cold-tier flush, and expired-entry reclamation.

src/storage/tiered/cold_read.rs

kv_spill.rsFsync spill directory entries and sweep crash-orphaned heap files +101/-0

Fsync spill directory entries and sweep crash-orphaned heap files

• Adds fsync_directory(data/) after spill file publication and after tmp->final rename to ensure directory entry durability. Introduces sweep_orphan_heap_files() to remove unregistered heap-*.mpf and heap-*.tmp leftovers at startup, with a unit test validating behavior and safety for unrelated files.

src/storage/tiered/kv_spill.rs

Documentation (1) +31 / -0
CHANGELOG.mdDocument cold-tier correctness fixes and new spill liveness metrics +31/-0

Document cold-tier correctness fixes and new spill liveness metrics

• Adds release notes covering cold-tier deletion/flush correctness, expired-read reclamation, directory fsync durability, and startup orphan sweeping. Documents new INFO persistence spill-thread liveness counters.

CHANGELOG.md

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (2) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 40 rules

Grey Divider


Action required

1. unwrap() missing allow comment 📘 Rule violation ✧ Quality
Description
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.
Code

src/storage/tiered/spill_thread.rs[R589-607]

+    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);
Relevance

⭐⭐ Medium

Unwrap annotation enforced in PR #71, but similar test unwrap-annotation suggestion rejected in PR
#211.

PR-#71
PR-#211

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 302083 requires each .unwrap() to have an immediately preceding justification
comment and an attached #[allow(clippy::unwrap_used)] attribute; the cited new/changed code
contains .unwrap() calls without those annotations.

Rule 302083: Annotate safe unwrap calls with allow and justification
src/storage/tiered/spill_thread.rs[589-607]
src/storage/tiered/cold_read.rs[205-223]
src/storage/tiered/kv_spill.rs[463-480]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## 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



Remediation recommended

2. Unlink lacks dir fsync 🐞 Bug ☼ Reliability
Description
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.
Code

src/storage/tiered/kv_spill.rs[R409-417]

+        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),
+            }
+        }
Relevance

⭐⭐ Medium

Dir-fsync durability fixes accepted (rename) in PR #154; no clear unlink+fsync precedent.

PR-#154

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new startup sweep deletes files via std::fs::remove_file but never calls fsync_directory on
the containing directory. fsync_directory’s doc comment states it is required for unlink metadata
durability, so omitting it means deletions may not persist across a crash.

src/storage/tiered/kv_spill.rs[381-420]
src/persistence/fsync.rs[8-13]
PR-#154

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### 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



Informational

3. Tests added outside mod.rs 📘 Rule violation ▣ Testability
Description
New unit tests were added inside leaf submodule files under a split module (src/storage/tiered/),
rather than being centralized in the directory’s mod.rs. This violates the project convention for
split-module test placement.
Code

src/storage/tiered/cold_read.rs[R233-304]

+    #[test]
+    fn test_del_removes_cold_entry_no_resurrection() {
+        let tmp = tempfile::tempdir().unwrap();
+        let mut db = db_with_spilled_key(tmp.path(), b"doomed", b"value-on-disk", None);
+
+        // Sanity: the key is reachable via cold read-through before DEL.
+        assert!(
+            db.cold_index.as_ref().unwrap().lookup(b"doomed").is_some(),
+            "precondition: key is cold-indexed"
+        );
+
+        let frame = crate::command::key::del(
+            &mut db,
+            &[crate::protocol::Frame::BulkString(Bytes::from_static(
+                b"doomed",
+            ))],
+        );
+        assert_eq!(
+            frame,
+            crate::protocol::Frame::Integer(1),
+            "DEL of a cold-only key must count it as removed"
+        );
+        assert!(
+            db.get(b"doomed").is_none(),
+            "GET after DEL must NOT resurrect the cold value"
+        );
+        assert!(
+            db.cold_index.as_ref().unwrap().lookup(b"doomed").is_none(),
+            "cold index entry must be gone after DEL"
+        );
+        assert!(
+            db.cold_index.as_ref().unwrap().has_pending_unlink(),
+            "last referrer removed: file must be queued for unlink"
+        );
+    }
+
+    /// D1: FLUSHDB/FLUSHALL (`Database::clear`) must clear the cold tier too —
+    /// flushed keys must not remain readable from disk.
+    #[test]
+    fn test_clear_flushes_cold_tier() {
+        let tmp = tempfile::tempdir().unwrap();
+        let mut db = db_with_spilled_key(tmp.path(), b"flushed", b"value-on-disk", None);
+
+        db.clear();
+
+        assert!(
+            db.get(b"flushed").is_none(),
+            "GET after FLUSH must NOT read the cold value back from disk"
+        );
+        assert!(
+            db.cold_index.as_ref().unwrap().has_pending_unlink(),
+            "cold files must be queued for unlink after clear"
+        );
+    }
+
+    /// R1: a cold read that finds the entry EXPIRED must reclaim the index
+    /// entry (and thereby the file refcount) instead of leaking it forever.
+    #[test]
+    fn test_expired_cold_read_reclaims_index_entry() {
+        let tmp = tempfile::tempdir().unwrap();
+        // TTL 1ms in the past relative to the read below.
+        let mut db = db_with_spilled_key(tmp.path(), b"stale", b"old", Some(1));
+
+        assert!(
+            db.get(b"stale").is_none(),
+            "expired cold entry reads as nil"
+        );
+        assert!(
+            db.cold_index.as_ref().unwrap().lookup(b"stale").is_none(),
+            "expired cold entry must be reclaimed from the index on read"
+        );
+    }
Relevance

⭐ Low

Similar “move tests to mod.rs” suggestion was definitely rejected in PR #211.

PR-#211

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 302093 requires split-module unit tests to be located in the module directory’s
mod.rs; the tiered module is split via src/storage/tiered/mod.rs, but the PR adds tests in leaf
files under that directory.

Rule 302093: Keep test code for split Rust modules in mod.rs
src/storage/tiered/mod.rs[1-10]
src/storage/tiered/cold_read.rs[233-304]
src/storage/tiered/kv_spill.rs[462-502]
src/storage/tiered/spill_thread.rs[547-622]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Tests were added in non-`mod.rs` files within a split Rust module directory.

## Issue Context
The module `src/storage/tiered/` is split via `mod.rs` and submodules; compliance requires unit tests for split modules to live in the directory’s `mod.rs`, not in leaf files.

## Fix Focus Areas
- src/storage/tiered/mod.rs[1-10]
- src/storage/tiered/cold_read.rs[233-304]
- src/storage/tiered/kv_spill.rs[462-502]
- src/storage/tiered/spill_thread.rs[547-622]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +589 to +607
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

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

Comment on lines +409 to +417
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),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

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

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@pilotspacex-byte, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 30 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1c1388b1-46ad-4cb5-9e45-50fccdf70898

📥 Commits

Reviewing files that changed from the base of the PR and between eadc8b8 and 92390a7.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • src/command/connection.rs
  • src/command/key.rs
  • src/persistence/recovery.rs
  • src/storage/db.rs
  • src/storage/tiered/cold_index.rs
  • src/storage/tiered/cold_read.rs
  • src/storage/tiered/kv_spill.rs
  • src/storage/tiered/spill_thread.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cold-tier-correctness

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pilotspacex-byte
pilotspacex-byte merged commit 230c95b into main Jul 6, 2026
13 checks passed
TinDang97 added a commit that referenced this pull request Sep 3, 2026
…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
TinDang97 added a commit that referenced this pull request Sep 4, 2026
…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
TinDang97 added a commit that referenced this pull request Sep 4, 2026
…-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
TinDang97 added a commit that referenced this pull request Sep 4, 2026
…-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
TinDang97 added a commit that referenced this pull request Sep 4, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants