Summary
eviction::next_spill_file_id_seed fails open: when it cannot scan the cold data
directory it returns 1 and logs a warning, even though higher-numbered spill files
exist on disk. The spill counter then restarts low, and because a spill writes to
{shard_dir}/data/heap-{file_id:06}.mpf via a .tmp + rename, the next spill
silently replaces an existing file of the same name — destroying cold data that is still
referenced.
This is the same defect class as moon#893 (Warm vector segment file_id reuse makes boot recovery delete the segment directory it just attached), in the KV cold tier rather than
the vector engine. That one is tracked; this one was not.
The two paths that reset the seed
src/storage/eviction.rs:954-989:
let entries = match std::fs::read_dir(&data_dir) {
Ok(e) => e,
Err(e) => {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(..., "spill file_id seed: could not scan cold dir; defaulting to 1");
}
return 1; // <-- higher-numbered files may exist RIGHT NOW
}
};
read_dir fails with anything other than NotFound — permissions, transient I/O, a
full or wedged filesystem. Warns, returns 1.
- Any
heap-*.mpf whose name does not parse is skipped, so max_id can come back below
the true maximum even on a successful scan.
NotFound really is the benign fresh-server case and is correctly silent. The other
errors are not benign, and 1 is the most dangerous possible answer to them.
Why the consequence is data loss, not just confusion
src/storage/tiered/kv_spill.rs:441-450 writes the batch to heap-{file_id:06}.tmp,
fsyncs, then renames onto heap-{file_id:06}.mpf. A POSIX rename over an existing path
replaces it silently. So with a reset seed:
disk before: heap-000001.mpf (live, referenced by the cold index)
... heap-000050.mpf
seed resets to 1
next spill: writes heap-000001.tmp, renames onto heap-000001.mpf
disk after: the original heap-000001.mpf is GONE
Every key whose only copy lived in the replaced file is unrecoverable, and the cold index
still points at it. Reads for those keys fail or return wrong data after the next restart.
How this surfaced
Found while reviewing PR #996 (moon#983), which makes cold-index duplicate resolution
order by file_id instead of manifest push order. That fix's correctness argument rests on
file_id being monotonic across restarts, so I checked the seeding function.
To be clear about scope: this does not weaken PR #996 and should not block it. Under a
reset seed the older copy is destroyed by the rename before ordering is ever consulted,
so there are not two copies to order — the ordering fix makes no scenario worse. The
author reached the same conclusion independently and documented the caveat on
ColdLocation::recency_key rather than expanding scope, which was the right call. This is
the separate follow-up.
Suggested fix
Do not fail open on a durability-critical counter. On a non-NotFound scan error, either
propagate the error or refuse to start, rather than returning a seed that is known-unsafe.
Note the author's finding that there is no error path out of shard init today, so this may
need one — which is itself worth knowing.
Two cheaper hardening steps that stand on their own:
- Refuse to clobber. Create the final file with
O_EXCL semantics, or check for
existence before the rename, so a seed bug degrades into a loud error instead of silent
destruction. A spill overwriting an existing heap-*.mpf is never correct, regardless of
how the file_id was chosen.
- Count unparseable
heap-* names during the scan and refuse to seed (rather than
under-seed) if any are present.
The first is the valuable one: it converts this entire class — including any future
file_id bug — from data loss into a startup failure.
Summary
eviction::next_spill_file_id_seedfails open: when it cannot scan the cold datadirectory it returns
1and logs a warning, even though higher-numbered spill filesexist on disk. The spill counter then restarts low, and because a spill writes to
{shard_dir}/data/heap-{file_id:06}.mpfvia a.tmp+ rename, the next spillsilently replaces an existing file of the same name — destroying cold data that is still
referenced.
This is the same defect class as moon#893 (
Warm vector segment file_id reuse makes boot recovery delete the segment directory it just attached), in the KV cold tier rather thanthe vector engine. That one is tracked; this one was not.
The two paths that reset the seed
src/storage/eviction.rs:954-989:read_dirfails with anything other thanNotFound— permissions, transient I/O, afull or wedged filesystem. Warns, returns
1.heap-*.mpfwhose name does not parse is skipped, somax_idcan come back belowthe true maximum even on a successful scan.
NotFoundreally is the benign fresh-server case and is correctly silent. The othererrors are not benign, and
1is the most dangerous possible answer to them.Why the consequence is data loss, not just confusion
src/storage/tiered/kv_spill.rs:441-450writes the batch toheap-{file_id:06}.tmp,fsyncs, then renames onto
heap-{file_id:06}.mpf. A POSIX rename over an existing pathreplaces it silently. So with a reset seed:
Every key whose only copy lived in the replaced file is unrecoverable, and the cold index
still points at it. Reads for those keys fail or return wrong data after the next restart.
How this surfaced
Found while reviewing PR #996 (moon#983), which makes cold-index duplicate resolution
order by
file_idinstead of manifest push order. That fix's correctness argument rests onfile_idbeing monotonic across restarts, so I checked the seeding function.To be clear about scope: this does not weaken PR #996 and should not block it. Under a
reset seed the older copy is destroyed by the rename before ordering is ever consulted,
so there are not two copies to order — the ordering fix makes no scenario worse. The
author reached the same conclusion independently and documented the caveat on
ColdLocation::recency_keyrather than expanding scope, which was the right call. This isthe separate follow-up.
Suggested fix
Do not fail open on a durability-critical counter. On a non-
NotFoundscan error, eitherpropagate the error or refuse to start, rather than returning a seed that is known-unsafe.
Note the author's finding that there is no error path out of shard init today, so this may
need one — which is itself worth knowing.
Two cheaper hardening steps that stand on their own:
O_EXCLsemantics, or check forexistence before the rename, so a seed bug degrades into a loud error instead of silent
destruction. A spill overwriting an existing
heap-*.mpfis never correct, regardless ofhow the file_id was chosen.
heap-*names during the scan and refuse to seed (rather thanunder-seed) if any are present.
The first is the valuable one: it converts this entire class — including any future
file_id bug — from data loss into a startup failure.