Skip to content

fix(persistence): the AOF RDB-preamble load no longer unwires the cold tier (moon#1007) - #1008

Open
TinDang97 wants to merge 1 commit into
mainfrom
fix/cold-wiring-lost-on-aof-preamble-replay
Open

TinDang97 wants to merge 1 commit into
mainfrom
fix/cold-wiring-lost-on-aof-preamble-replay

Conversation

@TinDang97

Copy link
Copy Markdown
Collaborator

Closes #1007.

What breaks today

Recovery Phase 3 rebuilds the cold index correctly. Phase 4b then destroys it: replay_aof loads the MOON preamble that BGREWRITEAOF writes, and rdb::load_from_bytes swaps fresh Database temporaries over the live ones (*live = temp, src/persistence/rdb.rs:359), dropping cold_index and cold_shard_dir. The server serves with a wired-but-empty cold plane: every spilled key reads as an absent key, no error, and DBSIZE agrees.

28,868 cold keys lost at --shards 1 after a BGREWRITEAOF, and lost again on every later boot.

shards BGREWRITEAOF files damaged control key cold probes DBSIZE
1 no yes VALUE 5/5 42205
1 no no VALUE 5/5 38328
1 yes yes nil 0/5 4662
1 yes no nil 0/5 2368
2 yes yes VALUE 5/5 35020
4 yes yes VALUE 5/5 45920

Rows 3 and 4 differ only in whether anything was damaged. The damage is a red herring.

Why only --shards 1

AOF layout, not sharding logic. --shards >= 2 uses the PerShard manifest and recovers through replay_multi_part, which is bracketed — aof_manifest::shard_replay already has take_cold_wiring/restore_cold_wiring, and its doc comment describes this exact hazard. Single-shard multi-part is monoio-only (main.rs:1840: "tokio --shards 1 fresh: no manifest"), so that config rewrites the flat AOF with a preamble and recovers through the unbracketed path.

monoio is exposed for one boot on a legacy-AOF upgrade; it self-heals next boot, but during that window ~29K keys answer nil and any INCR/APPEND mints from zero and corrupts permanently.

The fix

Bracket the preamble load, restoring before the RESP tail so replayed DEL/FLUSH* still tombstone cold (with cold_index == None those are silent no-ops).

Deliberately not in rdb::load_from_bytes: that also serves replica full-sync and DEBUG RELOAD with a foreign dataset, where preserving this node's index would surface stale reads. replay_aof only replays this node's own log. The one site also covers the other three replay_aof callers.

Verification

Red/green proven by reverting only the 8-line bracket and keeping the test:

  • RED 3/3 — assertion failed: the RDB preamble swap must not unwire the cold tier
  • GREEN — passes

Plus 619 persistence:: and 668 storage:: lib tests, clippy and fmt clean.

Relationship to other issues

Follow-ups worth their own issues

  • Give rdb::load* an explicit TieringPolicy { Preserve, Discard } so the compiler names every call site and no future one can forget.
  • *live = temp conflates "hot snapshot" with "live tier topology"; it also silently drops replay_cold_gate and spill_inflight (both empty pre-accept today, so latent).

Not verified

The --appendonly noCONFIG SET appendonly yes route on monoio: the harness could not spill in that configuration at all. A harness limitation, not evidence of safety.

…d tier

Recovery Phase 3 rebuilds the cold index from the shard manifest. Phase 4b's
`replay_aof` then loads the `MOON` preamble that `BGREWRITEAOF` writes at the
head of a rewritten single-file AOF, and `rdb::load_from_bytes` builds fresh
`Database::new()` temporaries and swaps them wholesale over the live ones:

    for (live, mut temp) in databases.iter_mut().zip(temp_dbs.into_iter()) {
        temp.recalculate_memory();
        *live = temp;          // cold_index and cold_shard_dir die here
    }

`cold_index` and `cold_shard_dir` are live-tier TOPOLOGY; the hot snapshot
does not carry them and `Database::new()` leaves them `None`. `main.rs` then
installs a fresh empty `ColdIndex`, so the server serves with a wired-but-
empty cold plane and every spilled key reads as an ABSENT key — no error, and
`DBSIZE`/`INFO keyspace` agree with the loss because `logical_len` counts
`ci.len()`.

Measured at `--shards 1` after any `BGREWRITEAOF`: 28,868 cold keys gone, and
gone again on every subsequent boot. The damaged-file scenario that surfaced
this is a red herring — an undamaged run loses exactly as much.

Only tokio `--shards 1` reaches it. At `--shards >= 2` the PerShard manifest
routes recovery through `replay_multi_part`, and `aof_manifest::shard_replay`
already brackets the identical swap with `take_cold_wiring` /
`restore_cold_wiring` — its doc comment describes this exact hazard. The
legacy single-file path never got the bracket. monoio is exposed for one boot
when upgrading from a legacy AOF; it self-heals on the next boot, but during
that window ~29K keys answer nil and an `INCR`/`APPEND` against one of them
mints from zero and corrupts it permanently.

The preamble load is now bracketed the same way, restored BEFORE the RESP
tail so replayed `DEL`/`UNLINK`/`FLUSH*` still tombstone the cold plane — with
`cold_index == None` those paths are silent no-ops.

Fixed in `replay_aof` rather than in `rdb::load_from_bytes` deliberately.
`replay_aof` only ever replays THIS node's own log, so preserving is
unconditionally correct; the generic loader also serves replica full-sync and
`DEBUG RELOAD` with a FOREIGN dataset, where keeping this node's cold index
would surface stale reads. The one site also covers the other three
`replay_aof` callers (recovery.rs, shard/mod.rs, wal_v3/replay.rs);
`listener.rs`'s bare `rdb::load` is safe because its databases are freshly
constructed.

Proven red/green: with the bracket reverted and the test kept,
`rdb_preamble_replay_keeps_the_rebuilt_cold_index` fails 3/3 on "the RDB
preamble swap must not unwire the cold tier"; with it, passes.

Closes #1007
Unblocks #1004 — its 1-shard leg then reaches the same pre-fix red as the
4-shard leg (line 623, INFO counters absent) instead of losing the control key.

author: Tin Dang
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because the subscription is no longer active. Ask your workspace admin to reactivate the subscription to resume reviews. Manage billing

@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 43 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 71c66b96-b716-4bde-bbb8-d34585728ca8

📥 Commits

Reviewing files that changed from the base of the PR and between 43ad387 and 9aa174f.

📒 Files selected for processing (2)
  • CHANGELOG.md
  • src/persistence/aof/mod.rs

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant