fix(storage): a missing SSTable component is a defect, not a later problem - #375
Conversation
…oblem
Startup repair checked whether a critical component was zero-byte and
quarantined the generation if so, but let a MISSING component fall through:
Err(_) => {
// Missing component — will fail in open_sstable_from_dir.
}
It does fail there. What reaches the operator is
storage: I/O error: No such file or directory (os error 2)
naming no table, no generation and no path — and it fails EVERY read of that
table rather than skipping the one generation. Missing is strictly worse than
empty, and was the one case not caught.
Observed on the ferrosa-memory native cluster: agent_memory
.mobile_control_cursor_state and .knowledge_by_state were unreadable on all
three nodes. The cursor allocator reverted to an older surviving value and
regressed ~10,600 cursors, which surfaced as a mobile client refusing a
high-water below the one it had already committed. The client guard was right;
the store had moved backwards under it.
The decision moves to sstable_health.rs as a pure function over probed
components, because the judgement needed no engine, manifest or disk — only the
walk around it did. Missing, zero-byte and unreadable all withhold the
generation now. Rows.db stays out of the critical set: the writer legitimately
emits it empty for simple partitions, and treating it as critical would
quarantine healthy SSTables.
Both startup log lines name the component and why it is unusable, so the next
occurrence does not need someone reading generation directories on disk to find
out which file is gone.
Tests: 8 cases in sstable_health::tests, written against the old behaviour
first — a_missing_data_file_is_unusable and
the_first_defect_in_order_is_the_one_reported both failed before the fix, the
latter reporting a zero-byte Partitions.db while stepping over a missing
Data.db. Full crate suite 1089 passed, 0 failed, 0 ignored; fmt and clippy
-D warnings clean.
Not addressed: the manifest still references the quarantined generation, so it
is re-probed and re-quarantined on every boot, and what removed the files in
the first place is still unknown. Both recorded in ST-18.
|
Root cause found, and it is a second instance of the same asymmetry — pushed as The triggerBen's hint (new tables, look at what changed) sent me to the on-disk layout, which was a dead end — plenty of healthy tables mix flat and nested generations. The node log had it instead: Three SSTables in the post-swap set, and one generation on disk. The other two are Why it was permanentThat skip is
Immortal. Two tables sat like that for days while a complete generation sat on disk next to them. That is why a restart would have "fixed" it and why I initially reached for Note the sibling branch: when The fixPlanning now quarantines before skipping, reusing the existing in-memory quarantine that the read path already consults in three places. Reads skip the generation instead of failing on it, and the quarantine record carries the token range so anti-entropy repair can refill it. The test
Before the fix its first assertion passed — planning does skip it — and the second failed. That gap is the defect, stated as a test. Full crate suite: 1090 passed, 0 failed, 0 ignored. fmt and clippy Scope of the two commits
The first covers the boot path, the second the running engine. Neither explains what removed the component files — that is still open on |
The defect
Startup SSTable repair checked whether a critical component was zero-byte and quarantined the generation if so, but let a missing component fall through:
It does fail there. What reaches the operator is:
No table, no generation, no path — and it fails every read of that table, not just the one generation. Missing is strictly worse than zero-byte, and it was the one case not caught.
How it showed up
On the ferrosa-memory native cluster,
agent_memory.mobile_control_cursor_stateandagent_memory.knowledge_by_statewere unreadable on all three nodes. 87 of 89 tables were fine, including the 80,917-entity corpus, so this was not general corruption — it was two tables whose manifests referenced generations whose component files were gone.The downstream damage is what made it expensive to diagnose. The cursor allocator lost its recent value, reverted to an older surviving generation, and regressed ~10,600 cursors. A mobile client then refused a high-water below the cursor it had already committed, and its event feed went silent. The client's guard was correct; the store had moved backwards underneath it. Working that out took reading generation directories on disk, because the error named nothing.
The change
The decision moves into
sstable_health.rsas a pure function over probed components. It needed no engine, manifest or disk — only the directory walk around it did — and it was the part that was wrong.Rows.dbstays out of the critical set: the writer legitimately emits it empty for simple partitions, and treating it as critical would quarantine healthy SSTablesTests
Written against the old behaviour first, so the defect showed up as red rather than as an assertion I made about it. Two failed before the fix:
The second is the sharper one: given a missing
Data.dband a zero-bytePartitions.db, the old logic stepped over the missing file and reported the empty one.8 cases total, covering the healthy generation, both critical components, each defect kind, deterministic ordering when several are defective, and a guard that
Rows.dbis not critical.Verification
cargo test -p ferrosa-storage --lib— 1089 passed, 0 failed, 0 ignoredcargo fmt --check— passescargo clippy -p ferrosa-storage --all-targets -- -D warnings— passesCrate docs updated as the repo requires:
README.mdgains the module, andspecs/fmea.mdgains ST-18 (RPN 256 → 24).What this does not fix
Stated plainly, and recorded in ST-18:
t_d573e7c3), including whether the absent S3 path matters — the compaction warning explicitly mentions "no remote component length hook confirmed object-storage availability", and this cluster has no working upload path, so there is no remote copy to fall back to.