fix(storage): cold index rebuild reports every entry it cannot recover; indexed-but-unreadable is no longer a miss (#875) - #1004
Conversation
`ColdIndex::rebuild_from_manifest_per_db` dropped entries on three silent
paths — a heap file that failed to read (`Err(_) => continue`), a page that
failed magic/type/CRC (`from_bytes` → `None`, next page), and a trailing
partial page (`chunks_exact` remainder) — and every entry lost that way read
afterwards as an ABSENT key: `GET` nil, `EXISTS` 0, indistinguishable to a
client from a key that was never written. Two more silent paths turned up
while confirming those: a slot inside a CRC-valid page that does not decode
(an unknown `ValueType` — a downgrade), and a file truncated on a page
boundary, which no per-page check can see at all.
The rebuild now returns a `ColdRebuildReport` alongside the per-db indexes,
counting each skip by cause, and logs the first 16 of each with `file_id`
(and page/slot). Recovery logs one per-shard summary — `cold index rebuild
clean` at info, or `cold index rebuild DEGRADED` at error with every count —
and folds the totals into `INFO` as
`reclamation_cold_recovery_{files_missing,files_unreadable,files_short,
pages_rejected,partial_page_bytes,entries_rejected}_total` so a monitor can
alarm on the instance that lost data. A valid `KvOverflow` page, which
`KvLeafPage::from_bytes` also rejects, is classified from its header first
and is not a loss.
Per class, the decision:
- `NotFound`: warn, count, and queue the `file_id` on the rebuilt index's
`pending_unlink` so the orphan sweep retires the manifest entry — this is
the sweep's own unlink-before-commit crash window (nothing lost) or an
external removal (nothing recoverable), and without the heal it re-warns
on every boot forever (the moon#546a pattern).
- any other read error: log at error, count, skip the file but NEVER
tombstone it; a restart after the operator fixes it recovers the keys.
A recovery `Err` today falls back to v2 recovery, which discards the
entire v3 replay — strictly worse — and shard init has no refuse-boot
path, so fail-closed is a follow-up, not folded in here.
- corrupt page, partial page, undecodable slot, short file: the bytes are
gone; count and log, keep everything else in the file.
- manifest fails to open (recovery.rs Phase 3): the consequence — no cold
index at all — is now stated at error, not just Phase 2's open failure.
Read side: `ColdReadOutcome::Unreadable(ColdReadFault)` separates "indexed
but the bytes could not be produced" from `Miss`, which now means only "no
index entry". `read_cold_entry` classifies each failure (file missing /
unreadable, page rejected, slot undecodable, overflow broken, value
undecodable), counts it (`reclamation_cold_read_unreadable_total`), and
logs the location rate-limited. `promote_cold_outcome` promotes nothing,
fabricates nothing, and keeps the index entry so a later read retries. The
wire reply for a value read stays nil for now: `Database::get` has no error
channel, and a dispatch-boundary flag would report an IOERR on a write that
had already executed — the `-IOERR` reply with fail-closed writes is a
follow-up rather than a half-wired one.
Evidence: `tests/cold_index_rebuild_silent_drops_875.rs` drives a real
spill → `BGREWRITEAOF` (so the cold file is the only copy, as on any server
that has auto-rewritten) → `SIGKILL` → on-disk damage (one file removed,
one chmod 000, one page CRC broken, one file cut mid-page) → restart. On the
pre-fix binary (`a8eb2efc`) the four keys answer nil with nothing in `INFO`
or the log; the test fails at the first counter lookup. Unit coverage for
every loss class in `cold_index_rebuild_tests.rs`, and for the read-side
split in `cold_read.rs`.
Stacked on #996 (`fix/983-coldindex-duplicate-resolution`), which must merge
first.
Refs moon#875
author: Tin Dang
…ines `tracing_subscriber::fmt()` writes colour codes even into a file, so `file_id=15212` arrives split by escape sequences; match on the stripped text. Test-only. Refs moon#875 author: Tin Dang
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…r nil
A cold read-through that finds the key INDEXED but its bytes unreadable
(file missing or unreadable, page corrupt, slot undecodable, overflow
chain broken) used to be folded into the plain miss: `GET` answered nil,
`INCR` minted a counter from zero, `APPEND`/`HSET`/`LPUSH`/… fabricated a
fresh value that shadowed the cold copy until the orphan sweep reclaimed
the file for good. "Key not found" is a legitimate answer a client acts
on, so no layer above could tell the loss from a key never written — the
read-side half of moon#875.
Mechanism, kept off the accessor signatures: `Database::cold_fault`
(`AtomicU8`; the `Database` lives in an `RwLock` slot and must stay
`Sync`) is raised by the two read-through funnels — `promote_cold_outcome`
on `ColdReadOutcome::Unreadable` and `get_cold_value` (the `&self`
shared-read path) — and consumed by whoever answers the client:
- the six fabricating accessors (`get_or_create*`, listpack/intset
siblings) refuse with `Database::cold_fault_error()` BEFORE
`insert_fresh`, so nothing shadows the cold bytes;
- `INCR*`/`INCRBYFLOAT` (via `incr_absent` → the general path), `APPEND`,
`SETRANGE`, `GETSET`, `SET … GET|KEEPTTL` refuse before their `set`;
- `command::cold_fault_gate` on `dispatch` and `dispatch_read` turns any
remaining reply into the error — the catch-all for every
`Database::get`-shaped read, which has no error channel. One relaxed
load per command when nothing is pending. `try_inline_dispatch` stands
down on any cold location and cannot raise the flag, so the third path
needs no gate.
The reply is `-IOERR cold tier: key is indexed but its data could not be
read (see server log)` (static bytes). `EXISTS`/`DBSIZE`/`TYPE` keep
saying the key is present (index-driven), the index entry is retained so
a later read heals, plain `SET` still overwrites (it never reads the old
value) and `DEL` discards — the two escape hatches.
Every raise happens inside the raising command's own execution and every
dispatch takes the flag, so it cannot outlive its command on a shard
thread. On the tokio shared-read path a second connection's `dispatch_read`
can theoretically interleave between the async GET pre-warm's raise and
this connection's read; the pre-warm's own re-read re-raises for the right
connection, and the other one gets a spurious IOERR on a disk that is
already failing — documented, not hidden.
Evidence: `live_damage_answers_ioerr_not_nil_{1,4}_shards` tiers three
keys, removes one file and chmods another under the RUNNING server, then:
pre-fix `a8eb2efc` answers `$-1` for the indexed key (RED); fixed answers
`-IOERR` for GET/APPEND/INCR, counts every read in
`reclamation_cold_read_unreadable_total`, serves the untouched key, and
after `chmod 644` serves the ORIGINAL value (the refused APPEND fabricated
nothing). Dispatch-level unit tests cover both paths, eleven refusing
writes, the PING-after-IOERR non-leak, and SET/DEL as escape hatches.
Refs moon#875
author: Tin Dang
…ard; CHANGELOG for the -IOERR reply An attack that inverted the guard at the generic `get_or_create::<K>` site stayed green: HSET/LPUSH/SADD/ZADD all take the listpack/intset siblings first, so that site was never reached by the test. Streams have no compact sibling, so XADD reaches it directly. Refs moon#875 author: Tin Dang
Independent verification — my own build of
|
Summary
Fixes moon#875, both halves.
Rebuild side.
ColdIndex::rebuild_from_manifest_per_dbskipped, with no log line, no counter and no error: a heap file that failed to read (Err(_) => continue), a page that failed its magic/type/CRC check (from_bytes→None, next page), and a trailing partial page (chunks_exactdiscards the remainder). All three confirmed independently at the cited lines. Two more silent paths found on the way, both now covered: a slot inside a CRC-valid page that does not decode (KvLeafPage::get→None: an unknownValueType, i.e. a downgrade), and a file truncated on a page boundary, invisible to every per-page check and detectable only against the manifest'sbyte_size. One framing correction to the issue:KvLeafPage::from_bytesalso returnsNonefor a perfectly validKvOverflowpage, so "warn on everyNone" would have reported every large value as corruption; the rebuild classifies the header first.Read side — the part that decides whether this class is detectable. A cold read-through that found the key indexed but its bytes unreadable was folded into the plain miss:
GETnil,INCRminted a counter from zero,HSET/LPUSH/APPEND/… fabricated a fresh value that shadowed the cold copy until the orphan sweep reclaimed the file for good. Now such a key answers-IOERR cold tier: key is indexed but its data could not be read (see server log)on both dispatch paths, writes refuse before mutating, the index entry is retained so the read heals when the bytes come back, and the fault is counted (reclamation_cold_read_unreadable_total) and logged with its exact location.Decision per rebuild path (not one blanket rule)
NotFoundwarn+files_missing+ thefile_idqueued on the rebuilt index'spending_unlinkso the orphan sweep retires the manifest entryerror+files_unreadable; file skipped, never tombstonedErrtoday falls back to v2 recovery, discarding the whole v3 replay — strictly worse — and shard init has no refuse-boot path (#996 hit the same wall). Follow-up.byte_sizeerror+files_short/short_file_bytesbyte_size > 0so a legacy zero-stamped entry cannot false-alarm.error+pages_rejected; page skippederror+partial_page_bytesdebug_assert!— a dev build must not panic on disk damage during recovery.error+entries_rejectederrorstating the consequencePer-file/page lines are capped at 16 per cause per rebuild (with the file path); totals go into one per-shard summary (
cold index rebuild clean/cold index rebuild DEGRADED) and intoINFO:reclamation_cold_recovery_{files_missing,files_unreadable,files_short,pages_rejected,partial_page_bytes,entries_rejected}_total.The read-side reply: what and why
ColdReadOutcome::Missnow means only "no index entry" — the one outcome that is absence. NewUnreadable(ColdReadFault { location, reason })covers file missing / unreadable, page rejected, slot undecodable, overflow chain broken, body undecodable.Mechanism, kept off the accessor signatures (
Database::get(&mut self) -> Option<&Entry>has no error channel and dozens of callers):Database::cold_fault(AtomicU8— theDatabaselives in anRwLockslot and must staySync) is raised by the two read-through funnels (promote_cold_outcome,get_cold_value) and consumed by whoever answers:get_or_create*and the listpack/intset siblings) refuse with the error beforeinsert_fresh;INCR*/INCRBYFLOAT,APPEND,SETRANGE,GETSET,SET … GET|KEEPTTLrefuse before theirset;command::cold_fault_gateondispatchanddispatch_readturns any remaining reply into the error — the catch-all for everyDatabase::get-shaped read. One relaxed load per command when nothing is pending. The third path,try_inline_dispatch, stands down on any cold location and cannot raise the flag.Why
-IOERRand not nil: "key not found" is a legitimate answer a client acts on (re-derive, overwrite, report missing), so a lost entry answering nil is undetectable above the storage layer; an error is not. Why not fail atEXISTS/TYPE/DBSIZE: they are index-driven and correct — the key is present. Escape hatches: plainSETstill overwrites (it never reads the old value),DELdiscards. Known imperfection, documented in the commit: on the tokio shared-read path another connection'sdispatch_readcan theoretically interleave between the async GET pre-warm's raise and this connection's read; the pre-warm's own re-read re-raises for the right connection and the other gets a spurious IOERR on a disk that is already failing.Evidence
Pre-fix control:
$HOME/ab856-target/release/moon(release build ofa8eb2efc). Fixed:release-fastbuild ofc347a7b9. macOS host; no benchmark numbers.Integration —
tests/cold_index_rebuild_silent_drops_875.rs,MOON_BINpinned,--test-threads=1:a8eb2efcrebuild_reports_every_drop_{1,4}_shard(s)— spill →BGREWRITEAOF(cold file becomes the only copy) →SIGKILL→ one file removed, onechmod 000, one page CRC broken, one file cut 100 B into a page → restartGET -> None; EXISTS -> false, then FAILED:INFO reclamation has no reclamation_cold_recovery_files_missing_total fieldfiles_missing=1 files_unreadable=1 pages_rejected=1 partial_page_bytes=100 files_short=1, every damaged file named in the log with its path,DEGRADEDsummary; afterchmod 644+ restart the unreadable key reads back — oklive_damage_answers_ioerr_not_nil_{1,4}_shard(s)— three keys tiered; one file removed and onechmod 000under the running serverGETon the indexed key →$-1— FAILEDGET→-IOERR …,APPEND→-IOERR,INCR→-IOERR,EXISTS1,reclamation_cold_read_unreadable_total=7/6, control key served,PINGunaffected, afterchmod 644the original value is served (the refusedAPPENDfabricated nothing),DEL→ 1 then nil — okTotals: pre-fix
0 passed; 4 failed; fixed4 passed; 0 failed(both shard counts).The nil answers in the rebuild test are printed on purpose: the bytes are gone, no rebuild can conjure them; what changes is that the loss is now visible. The live test is where the reply itself changes.
Harness lessons: (a) under
--appendonly yeswithout a rewrite the AOF's ownSETrebuilds the key hot andMOON.SPILLEDonly drops it if the index still maps the key to that file — cold damage is masked until the first rewrite; (b)tracing_subscriber::fmt()writes to stdout, with ANSI codes even into a file (the parent harness sent stdout to/dev/null).Unit —
cold_index_rebuild_tests.rs(one test per loss class, corpus from the spill thread's own writers),cold_read.rs(MissvsUnreadable, index retained, and dispatch-level: GET/STRLEN on both paths → IOERR, twelve refusing writes leave nothing hot and the index entry in place,PINGafter IOERR unaffected,SET/DELescape hatches, the value returns once the file is back): 21 passed incold_read::tests+cold_index_rebuild_tests; 72 passed acrosscold_index cold_read info_reclamation kv_spill::tests.Attack (each mutation applied, compiled, targeted tests run, file restored from a pristine copy;
git diff HEAD --stat→ 0 lines afterwards; restored tree re-run green):NotFoundno longer countedmissing_file_is_counted_and_queued_for_manifest_retirementoverflow_pages_are_expected_not_rejectedpending_unlink_lentail = 0)trailing_partial_page_is_counted_in_bytesMissindexed_but_unreadable_key_answers_ioerr…get_or_create<K>guard invertedXADD(streams have no compact sibling) to the refusing-writes list; re-attack: RED — same test (the accessor fabricated a stream while the flag was still pending)INCRmints a counter over unreadable bytesGates (final tree
c03956fb):cargo fmt --checkexit 0 ·cargo clippy --all-targets -- -D warningsexit 0 ·cargo check --all-targets --no-default-features --features runtime-tokio,jemallocexit 0. Not run:scripts/ci-local.sh(VM legs), hosted matrix dispatch, Linux/io_uring,redis-serveroracle (the-IOERRreply has no Redis counterpart — Redis has no cold tier; the prefix is Redis's own for a failed disk read).Out of scope, flagged
files_shortdetector would fire on a Spill file_id seed fails open: a failed cold-dir scan restarts numbering at 1 and the next spill renames over a live heap file #997 overwrite whose manifestbyte_sizeno longer matches — a useful side effect, not a fix.tracing_subscriber::fmt()emits ANSI escapes into a non-TTY stdout; an operator grepping a log file hits the same thing the harness did. Cheap to fix (with_ansi(is_terminal)), separate change.Database.Refs moon#875