Skip to content

fix(storage): a write replayed after its spill marker is no longer discarded (moon#965) - #1011

Open
TinDang97 wants to merge 2 commits into
mainfrom
fix/965-cold-wins-discards-replayed-write
Open

TinDang97 wants to merge 2 commits into
mainfrom
fix/965-cold-wins-discards-replayed-write

Conversation

@TinDang97

@TinDang97 TinDang97 commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Closes #965.

The issue's title names the wrong mechanism

"AOF tail loss" — but nothing is lost from the AOF. Both writes are on disk and both replay. The newer one is deliberately discarded at the end of recovery, and the server logs it:

Phase 4b cold-plane reconcile (gated=false): 1 hot shadow(s) demoted to cold stubs

Mechanism

runtime-tokio + --shards 1 creates no AofManifest (main.rs:1840), so no MOON.COLDCUT head is ever seeded — yet MOON.SPILLED markers emit unconditionally. The #902 mechanism runs half-armed:

  1. The replayed marker drops the key's hot copy.
  2. The key's next write replays through Database::set's Inserted arm, which by design leaves the cold shadow standing → the key ends replay hot (new) and cold (old).
  3. No cut ⇒ reconcile takes the legacy task feat(commands): blocking list operations (BLPOP/BRPOP/BLMOVE/BLMPOP/BRPOPLPUSH) #56 branch and resolves cold-wins.

This is the same quadrant as #1007 and #914: the multi-shard path is correct, the tokio single-shard path was never fully covered.

Fix

A replayed marker proves the log is #902-era, so it now sets replay_saw_cold_marker, and reconcile resolves hot-wins for that generation with or without a cut. Hot-wins is value-correct for every way a key can end hot∩cold there (written after its marker; a stale entry in a still-listed file; or a marker lost under backpressure, where both planes hold the same value). Pre-#902 logs keep task #56 unchanged; no gate is installed, so replay-time read visibility is untouched. Cost: one bool per replayed marker.

Rejected: seeding a MOON.COLDCUT head for the legacy AOF (#914 — right long-term, but it changes replay-time read visibility, a much larger blast radius); per-key demote tracking (leaves the stale-manifest case exposed and adds a third resolution rule).

The guard that should have caught this — now actually runs

tests/cold_shadow_single_shard_tokio.rs was written for exactly this configuration and was red on main for weeks, but ran nowhere: it was #[ignore]d because it shelled out to redis-cli, which hosted runners don't install. It now speaks RESP through common::Conn and is un-ignored. find_moon_binary() already resolves CARGO_BIN_EXE_moon (built with the test's own features), so every runtime-tokio leg drives a tokio binary with no MOON_BIN to remember.

Verification — with redis-cli removed from PATH and MOON_BIN unset, as CI runs it

test fix inert fix applied
unit a_write_after_its_marker_beats_the_cold_copy_in_a_legacy_generation RED (hot_demoted: 1) GREEN
e2e cold_shadow_single_shard_tokio RED 3/3 through retries — 36, 38, 47 stale probes GREEN

Clippy --all-targets -D warnings clean on both runtimes; fmt clean.

Unblocks

Summary by CodeRabbit

  • Bug Fixes

    • Fixed replay reconciliation so newer hot values take precedence when spilled records are present, even if a corresponding cold-cut record is missing.
    • Preserved existing cold-value behavior for records without either cold marker.
    • Ensured keys explicitly marked as cold remain cold after restart unless rewritten.
  • Tests

    • Expanded single-shard runtime coverage and removed reliance on externally installed command-line tools.

…scarded

moon#965's title says "AOF tail loss". It is not: both writes are on disk and
both replay. The newer one is thrown away at the END of recovery, and the
server logs it doing so.

On `runtime-tokio` with `--shards 1`, `main.rs` deliberately creates no
`AofManifest` (v2 single-file recovery owns single-shard durability), so
nothing ever seeds a `MOON.COLDCUT` head. `MOON.SPILLED` markers, however, are
emitted unconditionally by `ColdMarkerSink::emit`. That configuration has been
running the moon#902 mechanism half-armed — markers without the cut:

  1. The replayed marker drops the key's hot copy (restart-as-cold).
  2. The key's next write replays through `Database::set`'s `Inserted` arm,
     which by design leaves the cold shadow standing (only `Updated`
     invalidates it). The key ends replay hot (new) AND cold (old).
  3. With no cut, `finish_replay_cold_reconcile` takes the legacy task #56
     branch and resolves COLD-wins:
       Phase 4b cold-plane reconcile (gated=false): 1 hot shadow(s) demoted

`replay_cold_spilled` claimed it was "safe to call without a gate … the demote
pass would have made the same decision less precisely". Without the gate the
drop MANUFACTURES the hot∩cold state the demote pass then resolves backwards.

A replayed marker is proof the log is #902-era; whether its head also carries
the cut is an artifact of which runtime wrote it, not of the data. So a marker
now records `replay_saw_cold_marker`, and reconcile resolves hot-wins for that
generation with or without a cut. Hot-wins is value-correct for every way a key
can be hot∩cold there: written after its marker (hot is newest); a stale entry
in a file the manifest still lists (likewise); or its own marker lost under AOF
backpressure — both planes then hold the same value and the cost is
restart-as-cold for that one key, already documented at the emit site. A log
with neither record (pre-#902) keeps task #56 verbatim, and no gate is
installed, so replay-time read visibility is unchanged.

Rejected: seeding a `MOON.COLDCUT` head into the legacy single-file AOF (better
long-term, tracked as moon#914, but it changes replay-time read visibility — a
much larger blast radius needing a correct watermark at generation-open); and
per-key demote tracking (leaves the stale-manifest case exposed and adds a third
resolution rule, keeping the runtimes divergent).

Cost: one bool store per replayed marker, one `mem::take` at end of replay. No
serving-path change, no fsync.

The guard that should have caught this, `tests/cold_shadow_single_shard_tokio.rs`
— written for exactly this configuration — was RED on main and ran nowhere: it
was `#[ignore]`d because it shelled out to `redis-cli`, which hosted runners do
not install. It now speaks RESP through `common::Conn` and is un-ignored.
`find_moon_binary()` already resolves `CARGO_BIN_EXE_moon`, built with the same
features as the test, so every `runtime-tokio` leg drives a tokio binary with
no `MOON_BIN` to remember.

Verified, with `redis-cli` removed from PATH and `MOON_BIN` unset:
  unit  a_write_after_its_marker_beats_the_cold_copy_in_a_legacy_generation
        RED with the fix inert (hot_demoted: 1), GREEN with it
  e2e   cold_shadow_single_shard_tokio
        RED 3/3 through nextest retries — 36, 38, 47 stale probes — GREEN with it
  clippy --all-targets -D warnings clean on both runtimes; fmt clean

Closes #965

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 18, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: bda5b3bc-9171-44b7-bf39-d1f361ea3969

📥 Commits

Reviewing files that changed from the base of the PR and between 6a0da0c and 967282f.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • src/storage/db/cold_replay_gate.rs
  • src/storage/db/mod.rs
  • tests/cold_shadow_single_shard_tokio.rs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Changes

Cold reconciliation

Layer / File(s) Summary
Replay marker tracking and reconciliation
src/storage/db/mod.rs, src/storage/db/cold_replay_gate.rs
The database records replayed MOON.SPILLED markers. Marker-bearing generations now use hot-wins reconciliation even without MOON.COLDCUT; generations with neither record retain legacy demotion.
Recovery test coverage and test transport
src/storage/db/cold_replay_gate.rs, tests/cold_shadow_single_shard_tokio.rs, CHANGELOG.md
Tests cover marker-only reconciliation and unchanged cold retention. The single-shard Tokio test now uses common::Conn RESP helpers and runs without #[ignore].

Priority: ➖ Normal

Estimated code review effort: 2 (Simple) | ~12 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant AOFReplay
  participant Database
  participant ColdReconcile
  participant ColdShadow
  AOFReplay->>Database: replay MOON.SPILLED
  Database->>ColdReconcile: provide marker and gate state
  ColdReconcile->>ColdShadow: preserve newer hot value
  ColdReconcile->>ColdShadow: drop superseded cold entry
Loading

Suggested reviewers: pilotspacex-byte

Merge Risk: ⚪ Minimal · up to 96728

The recovery change preserves newer writes for single-shard Tokio crash recovery while retaining legacy handling for logs without replay markers. No actionable merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The recovery implementation addresses the stale-value defect from #965. replay_cold_spilled records marker presence, and finish_replay_cold_reconcile uses hot-wins reconciliation for marker-only g… Add a CI nextest override for the cold-reconciliation durability tests, including the affected test, with retries = 0. Keep the recovery and regression-test changes.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the primary storage replay fix and references the affected issue.
Description check ✅ Passed The description provides a detailed summary, mechanism, fix rationale, testing results, and reviewer notes. It does not use the template headings or explicitly report all checklist items, performance …
Out of Scope Changes check ✅ Passed The changes stay within #965. The replay marker tracking and hot-wins reconciliation fix the recovery defect. The RESP connection changes remove the ignored test's external redis-cli dependency and …
Docstring Coverage ✅ Passed Docstring coverage is 86.67% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 15 functions across 3 files. (1 skipped: 1 …
Full details: Linked Issues check

Explanation

The recovery implementation addresses the stale-value defect from #965. replay_cold_spilled records marker presence, and finish_replay_cold_reconcile uses hot-wins reconciliation for marker-only generations. Unit coverage and the enabled single-shard Tokio test cover the failure. However, .config/nextest.toml still gives the CI profile two retries and has no cold_reconciliation override. Therefore, the data-integrity assertion can still pass after a retry and be reported as FLAKY, which does not meet #965's requirement to stop masking this failure with retries.

  • Fix all pre-merge checks with AI
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

CHANGELOG.md was the only conflict. Main's CI-sharding entry (#1009) and
this branch's moon#965 entry were both added at the top of [Unreleased].
Both are kept: the CI entry stays under Changed, and the moon#965 entry
moves to Fixed, where a bug fix belongs.

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.

Cold-reconciliation property test reports a stale value after SIGKILL — the signature is AOF tail loss, masked as FLAKY by nextest retries

1 participant