Skip to content

fix(persistence): cold recovery re-attaches spilled keys to their own database (#139)#383

Merged
pilotspacex-byte merged 2 commits into
mainfrom
fix/cold-multidb-recovery-139
Jul 18, 2026
Merged

fix(persistence): cold recovery re-attaches spilled keys to their own database (#139)#383
pilotspacex-byte merged 2 commits into
mainfrom
fix/cold-multidb-recovery-139

Conversation

@pilotspacex-byte

@pilotspacex-byte pilotspacex-byte commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Under disk-offload with SELECT N (N>0), the v3 recovery rebuild merged every spilled key into databases[0]'s cold index: SELECT>0 keys answered nil after restart (under appendonly no the cold tier is their ONLY durable copy), and a db0 key sharing a name with a db1 key could recover the wrong database's value into db0. The live spill path was already db-aware end to end; only the on-disk attribution and the recovery attach were db-blind.

Three layers:

  • Manifest: FileEntry gains db_index — a byte-compatible repurpose of the former min_key_hash slot, which every writer in the codebase's history serialized as 0. Old manifests read as db 0 (exactly their real provenance). No version bump, no migration.
  • Spill thread: flush_buffer's sub-batch chunking never crosses a logical-DB boundary, so every spill file is single-db and attributable wholesale. Interleaved multi-DB eviction costs extra files, never correctness.
  • Recovery: rebuild_from_manifest_per_db returns one cold index per database; recover_shard_v3 attaches each to ITS database (+cold_shard_dir) before Phase 4 tail replay, preserving the DEL/FLUSH cold-tombstone contract per db. A manifest referencing a db beyond --databases is skipped with a loud warning, never attached to a wrong db.

Test plan

  • Red/green proven on the VM: with main's recovery attach + this branch's test, the new kill-9 suite FAILS with "db0 probe:0000: recovered value belongs to the wrong database" (the corruption mode, not just the miss); with the fix it passes.
  • New unit tests: flush_buffer_cuts_chunks_at_db_boundaries (interleaved dbs → one file per contiguous same-db run, per-file db matches every entry); test_rebuild_per_db_attributes_files_to_their_databases (files tagged db0/db3 → separate indexes, no cross-db keys, merged wrapper intact)
  • New real-server integration suite crash_recovery_cold_multidb (--ignored): offload WITHOUT AOF, same key names in db0+db1 with db-distinct values, LRU spill, SIGKILL, restart — every readable probe carries its own db's value; db1 recovers ≥half its probes (pre-fix: zero)
  • Full offload regression battery green on VM: disk_offload_no_aof, spill_batch_kill9, cold_del_resurrection, scan_offload_visibility, cold_shadow_overwrite_resurrection, dbsize_offload_logical, cold_collection_visibility, cold_orphan_sweep
  • Lib suite 4391 passed; tokio suite 3568 passed; clippy -D warnings both feature sets

Fixes #139

Summary by CodeRabbit

  • Bug Fixes
    • Fixed disk offload recovery so spilled keys are restored with the correct logical database values after restart.
    • Prevented spill files from mixing key data across databases during recovery.
    • Ensured DEL/EXPIRE/FLUSH operations apply to the correct database during recovery.
    • Kept compatibility with older recovery metadata while improving multi-database attribution.

… database (#139)

Under disk-offload with SELECT N (N>0), the v3 recovery rebuild merged
every spilled key into databases[0]'s cold index. A key spilled from
db 1 was unreachable after restart (nil — and under appendonly=no the
cold tier is its ONLY durable copy), and because rebuild inserts are
keyed by name alone, a db0 key sharing a name with a db1 key could
recover the WRONG database's value into db 0. The live spill path was
already db-aware end to end (SpillRequest.db_index routes completions
to the right per-DB cold index); only the on-disk attribution and the
recovery attach were db-blind.

Fix, three layers:

- Manifest: FileEntry gains db_index — a byte-compatible repurpose of
  the former min_key_hash slot, which every writer in the codebase's
  history serialized as 0. Manifests written before this field read
  back as db 0, exactly matching their actual provenance (the old
  spill buffer was db-blind and recovery attached everything to db 0).
  No version bump, no migration, v1/v2 record layouts unchanged.

- Spill thread: flush_buffer's sub-batch chunking additionally never
  crosses a logical-DB boundary, so every spill file is single-db and
  can be attributed wholesale by its manifest entry. Interleaved
  multi-DB eviction costs extra files, never correctness. The
  per-entry fallback path stamps the request's own db_index.

- Recovery: rebuild_from_manifest_per_db returns one cold index per
  database; recover_shard_v3 attaches each to ITS database (plus
  cold_shard_dir) BEFORE Phase 4 tail replay, preserving the replayed
  DEL/UNLINK/FLUSH*/EXPIRE-past cold-plane tombstone contract per db.
  A manifest referencing a db beyond the configured --databases count
  is skipped with a loud warning — never attached to a wrong database.
  The merged rebuild_from_manifest stays as a thin wrapper for
  single-db callers.

Red/green coverage: flush_buffer db-boundary chunk cut (interleaved
dbs -> one file per contiguous same-db run, per-file db matches every
entry); rebuild_from_manifest_per_db attribution (two files tagged
db 0 / db 3 -> separate indexes, no cross-db keys, merged wrapper
unchanged); new real-server kill-9 integration suite
crash_recovery_cold_multidb (offload WITHOUT AOF, same key names in
db 0 + db 1 with db-distinct values, LRU-spill, SIGKILL, restart:
every readable probe must carry its own db's value, db 1 must recover
>= half its probes — pre-fix db 1 recovers zero).

Fixes #139
author: Tin Dang <tindang.ht97@gmail.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3aa7c0c5-36a9-4376-9efe-4967ed0266af

📥 Commits

Reviewing files that changed from the base of the PR and between 81b6fea and bb11151.

📒 Files selected for processing (10)
  • src/server/conn/tests.rs
  • src/shard/persistence_tick.rs
  • src/shard/timers.rs
  • src/storage/db.rs
  • src/storage/eviction.rs
  • src/storage/tiered/cold_read.rs
  • src/storage/tiered/cold_read_pool.rs
  • src/storage/tiered/kv_spill.rs
  • tests/cold_orphan_sweep.rs
  • tests/crash_matrix_cross_plane/harness.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/storage/tiered/kv_spill.rs

📝 Walkthrough

Walkthrough

Disk-offload manifests now record each spill file’s logical database. Spill batching preserves database boundaries, cold indexes rebuild per database, and recovery attaches them before WAL/AOF replay with compatibility handling for old or out-of-range entries.

Changes

Cold recovery database attribution

Layer / File(s) Summary
Manifest database-index contract
src/persistence/manifest.rs, src/persistence/manifest_sync.rs, src/persistence/recovery.rs, tests/*
FileEntry stores and decodes db_index in the existing serialized slot, with compatibility and fixture updates.
Database-aware spill metadata
src/storage/tiered/*, src/storage/eviction.rs, src/shard/*
Spill APIs and eviction paths propagate database indexes, and buffered spill batches stop at database boundaries.
Per-database cold-index recovery
src/storage/tiered/cold_index.rs, src/persistence/recovery.rs, src/shard/mod.rs, CHANGELOG.md
Manifest heap entries rebuild into per-database cold indexes, which recovery attaches before replay; out-of-range indexes are skipped with a warning.
Recovery and spill-path validation
tests/crash_recovery_cold_multidb.rs, src/storage/tiered/kv_spill.rs, tests/cold_orphan_sweep.rs, tests/crash_matrix_cross_plane/harness.rs
Crash recovery, per-database index isolation, updated spill call sites, and related fixtures validate the new attribution flow.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SpillPipeline
  participant Manifest
  participant ColdIndex
  participant Recovery
  SpillPipeline->>Manifest: write spill file with db_index
  Recovery->>Manifest: read active KvLeaf entries
  Recovery->>ColdIndex: rebuild indexes per db_index
  ColdIndex->>Recovery: return database-indexed cold indexes
  Recovery->>Recovery: attach indexes before WAL/AOF replay
Loading

Possibly related PRs

Suggested reviewers: tindang97

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Clear, concise title that matches the main fix: cold recovery now restores spilled keys to their original database.
Description check ✅ Passed It covers the summary and test plan, and the missing template sections are non-critical for a mostly complete PR description.
Linked Issues check ✅ Passed The changes thread db_index through spill, manifest, and recovery paths and add tests for SELECT>0 recovery, matching #139.
Out of Scope Changes check ✅ Passed The diff stays centered on db_index attribution, recovery, and supporting tests; no clearly unrelated behavioral changes stand out.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cold-multidb-recovery-139

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/persistence/recovery.rs`:
- Around line 329-342: Update the recovery flow around
demote_replayed_cold_shadows so it runs for every recovered database, not only
databases.first_mut(). Iterate through all entries in databases and invoke the
reconciliation for each database after Phase 4b AOF replay, preserving the
existing behavior for database zero.

In `@src/storage/tiered/kv_spill.rs`:
- Around line 193-199: Update spill_to_datafile and its synchronous eviction
caller to accept and propagate the caller’s logical db_index, then assign that
value to the spilled file metadata instead of hardcoding 0. Ensure every call
site supplies the appropriate database index so per-db recovery remains
correctly attributed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a667dcef-db15-42b3-82eb-6e550097d4ee

📥 Commits

Reviewing files that changed from the base of the PR and between 6f9b44a and 81b6fea.

📒 Files selected for processing (13)
  • CHANGELOG.md
  • src/persistence/manifest.rs
  • src/persistence/manifest_sync.rs
  • src/persistence/recovery.rs
  • src/shard/mod.rs
  • src/storage/tiered/cold_index.rs
  • src/storage/tiered/kv_spill.rs
  • src/storage/tiered/spill_thread.rs
  • src/storage/tiered/warm_tier.rs
  • tests/crash_recovery_cold_multidb.rs
  • tests/crash_recovery_spill_batch_kill9.rs
  • tests/crash_recovery_vacuum.rs
  • tests/manifest_gc_tombstones.rs

Comment on lines +329 to +342
for (db_index, cold_idx) in per_db {
info!(
"Shard {}: rebuilt cold index with {} entries",
"Shard {}: rebuilt cold index for db {} with {} entries",
shard_id,
db_index,
cold_idx.len()
);
// Attach to the database BEFORE Phase 4 replay. Replayed
// DEL/UNLINK/FLUSH*/EXPIRE-past must tombstone the cold plane:
// with `cold_index == None`, `remove_counting_cold()`/`clear()`
// are silent no-ops (`as_mut()` on None), so a key deleted in
// the WAL tail resurrects via cold read-through after restart
// whenever the crash lands inside the pre-orphan-sweep window
// (the manifest entry is still Active until the sweep).
if let Some(db0) = databases.first_mut() {
db0.cold_shard_dir = Some(shard_dir.to_path_buf());
match db0.cold_index.as_mut() {
Some(existing) => existing.merge(cold_idx),
None => db0.cold_index = Some(cold_idx),
match databases.get_mut(db_index) {
Some(db) => {
db.cold_shard_dir = Some(shard_dir.to_path_buf());
match db.cold_index.as_mut() {
Some(existing) => existing.merge(cold_idx),
None => db.cold_index = Some(cold_idx),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Reconcile replayed cold shadows for every recovered database.

This now attaches cold indexes to DBs above zero, but Line 737 still calls demote_replayed_cold_shadows() only on databases.first_mut(). After Phase 4b AOF replay, SELECT N keys can therefore remain duplicated in hot and cold storage, restoring the restart memory-accounting regression for N > 0.

Proposed fix
-    if let Some(db0) = databases.first_mut() {
-        let demoted = db0.demote_replayed_cold_shadows();
+    let demoted: usize = databases
+        .iter_mut()
+        .map(crate::storage::Database::demote_replayed_cold_shadows)
+        .sum();
-        if demoted > 0 {
+    if demoted > 0 {
             info!(
                 "Shard {}: demoted {} AOF-replay hot shadow(s) back to cold-only \
                  stubs (Phase 4b fallback path, used_memory truthful after restart, task `#56`)",
                 shard_id, demoted
             );
-        }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/persistence/recovery.rs` around lines 329 - 342, Update the recovery flow
around demote_replayed_cold_shadows so it runs for every recovered database, not
only databases.first_mut(). Iterate through all entries in databases and invoke
the reconciliation for each database after Phase 4b AOF replay, preserving the
existing behavior for database zero.

Comment thread src/storage/tiered/kv_spill.rs
…ex (#139)

Adversarial review of the #139 fix found it incomplete: the SYNC spill
path — persistence tick -> run_eviction (non-cascade branch) ->
evict_one_with_spill -> kv_spill::spill_to_datafile — still hardcoded
db_index: 0 in the manifest FileEntry. An ordinary maxmemory eviction
picking a db>0 victim (below the 0.85x cascade threshold, so the
db-aware async batch path never engages) produced a spill file that
rebuild_from_manifest_per_db re-attached to db 0 on restart: the exact
wrong-database recovery corruption #139 describes, reintroduced
through a path the integration test's filler burst (which always
crosses the cascade threshold) did not exercise.

Fix: spill_to_datafile takes an explicit db_index parameter and stamps
it into the FileEntry; SpillContext gains a db_index field which
run_eviction's per-database loop restamps each iteration (the context
is shared across the loop) and the memory-pressure sync fallback sets
to its loop index. The async batch path already carried the index on
SpillRequest and is unchanged.

Red/green: sync_spill_stamps_victims_database_into_manifest evicts a
victim through try_evict_if_needed_with_spill with db_index 2 and
asserts the manifest entry says 2 — verified failing (entry said 0)
against the pre-fix stamp, passing after.

Drive-by: collapse a pre-existing clippy `collapsible_if` in
tests/crash_matrix_cross_plane/harness.rs (only surfaces under
`clippy --all-targets`, which CI's plain clippy gate skips).

Refs #139 (PR #383 follow-up)
author: Tin Dang <tindang.ht97@gmail.com>
@pilotspacex-byte

Copy link
Copy Markdown
Contributor Author

Adversarial review outcome: the review REFUTED the original commit as incomplete — one confirmed defect: kv_spill::spill_to_datafile hardcoded db_index: 0, so the SYNC spill path (persistence tick → run_eviction non-cascade branch → evict_one_with_spill) stamped every file db 0. An ordinary maxmemory eviction of a db>0 victim below the 0.85× cascade threshold would reintroduce the #139 recovery corruption through a path the integration test's filler burst (always crossing the threshold → async path) didn't exercise.

Fixed in bb11151: spill_to_datafile takes an explicit db_index; SpillContext carries it, restamped per database by run_eviction's loop and set to the loop index in the memory-pressure sync fallback. Red/green: sync_spill_stamps_victims_database_into_manifest (verified failing against the pre-fix stamp — manifest said 0 for a db-2 victim — green after).

Gates re-run post-fix: lib 4392 ✓, both clippys --all-targets ✓, fmt ✓, VM battery (fresh Linux binary): crash_recovery_cold_multidb ✓, crash_recovery_disk_offload_no_aof ✓, crash_recovery_spill_batch_kill9 ✓, scan_offload_visibility ✓, cold_orphan_sweep 5/5 ✓, cold_collection_visibility 4/4 ✓, dbsize_offload_logical 2/2 ✓. All other review vectors (manifest downgrade compat, compact() rewrite, flush_buffer db-cut, warm-tier, WAL replay attribution, async-path SpillRequest sites) were verified clean.

@pilotspacex-byte
pilotspacex-byte merged commit 55a189a into main Jul 18, 2026
8 checks passed
@TinDang97
TinDang97 deleted the fix/cold-multidb-recovery-139 branch July 18, 2026 02:09
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.

disk-offload: cold recovery only restores db0 (SELECT >0 keys lost after restart)

2 participants