fix(storage): clamp FileStorage ranged reads to the object, and sweep orphaned .tmp staging files - #1702
Conversation
…rait contract
StorageRead::read_byte_range documents a ranged read as returning bytes
that "may be shorter than requested if the object is smaller than
range.end". The trait default, MemoryStorage, and the storage proxy (which
inherits the default) all honour that; FileStorage sized its buffer from
range.end - range.start instead, so the established "read to the end"
spelling of mid..u64::MAX became a usize::MAX allocation.
spawn_blocking caught the capacity-overflow panic and relabelled it
Io("spawn_blocking failed: ..."), so the one backend that could not serve
the call was also the one that could not say why.
Take the size off the metadata read the zero-length guard already does on
the open handle, and clamp the length to what the object actually holds.
The read loops already stop at EOF, so an in-range request is byte-identical
to before.
A crash between the File::create in stage_bytes and the rename that follows leaves a full copy of the object on disk under a .tmp name. stage_bytes and write_atomic clean up on their own error paths, but none of that runs for a SIGKILL, an OOM kill or a power loss, and list_prefix filters .tmp out of every listing -- which is why an orphan can never be served as content, and equally why nothing ever removed one. They accumulate, and the crash loop that produces them is often a disk-exhaustion crash loop. Sweep them when the storage is constructed. Storage is shared between processes in a multi-instance deployment, so the sweep is deliberately timid: it never touches a staging file bearing this process's token (exact, since the token is drawn once per process), never one modified inside the 24h threshold, and never one it cannot classify -- an unparseable name, an entry it cannot stat, an mtime in the future all count as live. That threshold is sound here in a way it would not be for an index build (#1635): a staging file's whole life is one write of one in-memory buffer, so unlike a reindex its duration does not grow with the ledger. It is still a heuristic and not a lease -- see the docs for what it does not guarantee. The walk is bounded at 100k entries and runs at most once per base path per process, so opening a large volume cannot become a startup stall. FLUREE_STORAGE_TMP_SWEEP=0 turns it off.
Three fixes from review of #1702, all in the sweep half. 1. Sweep only files this backend's staging writer named. The predicate was ends_with(".tmp"), which is a suffix rather than a namespace. The token rule only recognises tmp_sibling's naming, so every other .tmp file under the base path had the 24h age heuristic as its only protection -- and the base path is shared. FileNameService::new builds a FileStorage on the path FlureeBuilder::build also hands to storage, so the nameservice's tracking file is inside the swept tree by construction, and the indexer's vocab-merge .offsets.tmp / .lens.tmp are index-build temporaries, precisely the long-running shape STALE_STAGING_AGE's own doc argues an age threshold must not judge. A directory of seven such files aged past the threshold swept clean: reclaimed 7, kept 0. The name must now parse as one this writer produced before the file is considered at all. Every segment is shape-checked, so a foreign name with the same arity (a.b.c.tmp) does not slip through on dot count alone; both formats this backend has written still parse, including the pre-b0a9c416a one whose pid lands in the token position. Failing to reclaim an orphan wastes disk; reclaiming someone else's file loses data. 2. Hand the walk to the blocking pool. A recursive read_dir is blocking I/O and FileStorage::new is reachable from create_async_connection and build_from_config's async path, so the walk parked a runtime worker -- measured ~1s warm on local APFS for the default budget, and a readdir on a shared mount is a network round trip. #1620 asked for bounded or backgrounded; it is now both. A caller with no runtime (create_sync_connection) still runs it inline on its own thread. 3. Stop claiming truncation is a deferral. The walk restarts from the base path with no cursor and never removes content, so entries past the budget are not reached on this or any later start. Say that, log truncation at warn rather than folding it in with routine reclaim news at info, and add FLUREE_STORAGE_TMP_SWEEP_BUDGET so the warning has a remedy attached. Kept separate from the on/off switch: FLUREE_STORAGE_FSYNC=1 means on, so reading =1 as a one-entry budget would be off wearing a disguise. Also canonicalize the once-per-base-path key so the guarantee is about the directory rather than how a caller spelled it, and document in the trait what a range starting past the end returns -- noting S3's 416 divergence there rather than leaving the contract silent about it.
…ew nits Reject zero-padded pid and sequence numbers. Neither std::process::id() nor a fetch_add counter ever emits a padded number, so this excludes nothing this backend writes, and it keeps date- and offset-stamped foreign files out of the legacy <name>.<pid>.<seq>.tmp branch: backup.2026.08.tmp and wal.000001.000002.tmp both parsed as ours before, and no longer do. A sequence number legitimately starting at zero still parses; 00 does not. The residual is recorded on staging_token: a foreign name shaped <non-empty>.<decimal>.<decimal>.tmp (dump.1.2.tmp) still parses. No in-tree .tmp producer emits that shape, so it is an operator's own file rather than a collision with anything shipped, and closing it means gating legacy reclaim behind an opt-in -- an upgrade-behavior decision rather than a parser one. Also record why the legacy branch exists at all: staging writes arrived in 85183c9, which v4.1.5 and v4.1.6 both contain, so every store written by a released build produces orphans in that format. Dropping the branch would strand them permanently. Nits: say that the budget variable only sizes the walk, so 0 keeps the default rather than reading as 'do not walk'; name #1712 as the tracking issue for S3's 416 divergence instead of gesturing at it; and give the_walk_runs_inline_without_a_runtime the assertion that carries its coverage -- is_none() alone was also satisfied by a sweep that never ran. Adds a test driving twelve destination shapes through tmp_sibling itself, so tightening the parser cannot silently stop recognising a name a real ledger stages.
bplatz
left a comment
There was a problem hiding this comment.
Approving. I verified both issues on pristine main (fe3c198c8) rather than taking them as given, and both are real: 0..u64::MAX gives Err(Io("spawn_blocking failed: … capacity overflow")) on FileStorage against Ok(10) on MemoryStorage, and planted orphans (current and legacy format, aged) survive construction untouched. Your "lone violator" reading holds too — default impl, memory and proxy all clamp.
The clamp fix is clean: on the branch 0..MAX→10, 5..MAX→5, 99..MAX→0, 0..40e9→10, 3..7→4, identical to MemoryStorage across the board. I also probed the staging parser directly and every claim in the description held — uppercase hex, zero-padded segments and a.b.c.tmp all rejected, dump.1.2.tmp admitted as the stated residual.
One note for the #1671 write-up: it isn't only open-ended ranges. The allocation was sized from the request for any range — 0..40_000_000_000 on a 10-byte file allocated 40 GB of address space and returned a Vec with len 10 and capacity 4e10. Lazy zero pages mean no panic and negligible RSS, so only usize::MAX actually trips capacity overflow, but the oversized reservation was handed to the caller. Your fix covers all of it; worth a line so it isn't later read as narrowly about u64::MAX.
Three things below. The first is a demonstrated defect and I'd want it settled before merge; the other two are a doc sentence each.
On the concurrency stance you asked about: the rules themselves I think are right, and the argument for why an age threshold is sound here but not for #1635 holds up — a staging file's life really is one write_all plus a rename, with no long-running case to get wrong. My concern isn't the rules, it's where they're mounted.
| /// constructing a storage never waits on it. | ||
| pub fn new(base_path: impl Into<std::path::PathBuf>) -> Self { | ||
| let base_path = base_path.into(); | ||
| let _ = Self::sweep_on_construction(&base_path); |
There was a problem hiding this comment.
This is the one I'd change before merge: a destructive, environment-mutating sweep hangs off a bare constructor, and it composes badly with the parser residual you documented on staging_token.
fluree-db-connection/src/lib.rs:400 is a unit test whose whole body asserts a Debug string, and it constructs FileStorage::new("/tmp/test") — a hardcoded shared path. I created /tmp/test, planted mybackup.json.1.2.tmp (exactly the <name>.<decimal>.<decimal>.tmp shape you describe as "an operator's own file rather than a collision with anything we ship"), stamped it 90 days old, and ran that single test:
planted, mtime 90d old: True
test result: ok. 1 passed
ls /tmp/test -> empty
The file was gone. Each decision is defensible alone — the residual is tolerable because the sweep is a deliberate startup action, and sweeping at startup is reasonable — but together they mean constructing a FileStorage silently unlinks operator data.
I'd move it out of new: an explicit call from the connection/startup layer, which is the layer that actually knows it's startup, or a builder step next to with_durability. That also takes env reads, canonicalize and a global mutex back out of a constructor, and makes the /tmp/test test harmless again.
If you'd rather keep it here, then I think the residual has to close rather than stay recorded, since new being reachable from tests and tooling is what turns it from theoretical into the repro above.
| let Some(token) = staging_token(&name) else { | ||
| continue; | ||
| }; | ||
| // Rule 1: ours, whatever its age. |
There was a problem hiding this comment.
Worth saying in the doc that rule 1 is exact only for the current format. A legacy name puts a pid in the token slot, so it can never equal a 16-hex own_token:
staging_token("real.json.<our pid>.0.tmp") -> Some("45116")
own_token -> "3d7b301d75f9a910" // never matches
So for legacy names rule 1 is inert and rule 2 is the only thing standing. The case where that matters is a rolling upgrade — a v4.1.5/v4.1.6 process staging into a shared tree is covered by the heuristic alone. In practice fine, since a staging write won't stay open a day. But the doc presents 1 and 2 as layered, and on the branch that's load-bearing for exactly the deployment shape it was written for.
| /// gives — absence of bytes is not an error here, and callers distinguish | ||
| /// "no bytes" from "no object" by the `NotFound` error, not by this. | ||
| /// | ||
| /// Known gap: `S3Storage` returns 416 rather than an empty vec for a start |
There was a problem hiding this comment.
There are two gaps here, not one. For a zero-length object FileStorage returns NotFound (the #1599 debris guard) where MemoryStorage returns Ok(vec![]):
ZERO file 0..10 -> Err(NotFound("z/empty: … (zero-length blob, treated as absent)"))
ZERO mem 0..10 -> Ok(0)
Deliberate, and I think correct. But this paragraph is here specifically to make the contract authoritative, and naming only S3 reads as exhaustive — which recreates in miniature the thing #1671 was. A clause pointing at the zero-length rule as the other exception would settle it.
…the constructor A destructive sweep mounted on FileStorage::new composed badly with the parser's documented legacy residual: any bare construction — a unit test asserting a Debug string against a hardcoded /tmp/test, a tool inspecting a directory — silently unlinked operator files shaped <name>.<decimal>.<decimal>.tmp. Constructing a storage now touches nothing on disk; the sweep is FileStorage::sweep_orphaned_staging(), called explicitly by the layers that actually know it is startup: - create_sync_connection / create_async_connection (file arms) - FlureeBuilder::build, build_encrypted_internal, build_client_file, and the address-identifier storages in build_local_storage_from_config The Handle::try_current()/spawn_blocking split, the env switches, the once-per-canonicalized-path claim and the entry budget all move with it, so startup behavior is unchanged; what changed is that nothing else triggers it. New pins: construction is asserted pure (an aged orphan survives new), and both the connection and builder startup paths are asserted to still sweep — inline, runtime-free, so nothing races. Doc follow-ups from the same review: rule 1 now states its legacy carve-out (a pid can never equal a 16-hex token, so for legacy names the age heuristic stands alone — exactly the rolling-upgrade shape); the read_byte_range contract names both known divergences (S3 416 on past-EOF start, and FileStorage's zero-length-as-NotFound debris guard) instead of presenting one as exhaustive.
|
Thanks @bplatz — the
Re-running your repro post-fix: planted Both regression directions are pinned: Both doc sentences landed essentially as you framed them: rule 1's legacy carve-out (a pid can never equal a 16-hex token, so rule 2 stands alone for exactly the rolling-upgrade shape), and the contract paragraph now names two known divergences — the S3 416 (#1712) and the zero-length And your allocation finding reproduced exactly — the pre-fix shape on a 10-byte file gives |
Two storage fixes that both live in
fluree-db-core/src/storage/file.rs, so they're riding together rather than as two one-file PRs.Fixes #1671
Fixes #1620
#1671 — ranged reads now clamp, like the trait already said they would
The issue frames this as
FileStorageandProxyStoragedisagreeing. Having gone looking, I think it's actually a bit stronger than that: the trait's own doc comment atfluree-db-core/src/storage.rs:212-214already promises the clamp —— and the default impl (
storage.rs:219-229),MemoryStorage(storage/memory.rs:94-107), and the proxy (which has no override, so it inherits the default) all honour it.FileStoragewas the onlyStorageReadimplementation that sized its buffer fromrange.end - range.startrather than from the object, so this is a lone contract violation rather than a sibling divergence. I think that also answers the open question at the end of the issue — there isn't really a doc decision to make, the doc was already right and the code just wasn't meeting it.Mechanism, and it's worth being precise that this was never only about open-ended ranges:
let len = (range.end - range.start) as usizeran before any I/O, so the allocation was sized from the request for any range.0..40_000_000_000on a 10-byte file allocated 40 GB of address space and handed the caller aVecwith len 10 and capacity 40,000,000,000 — I reproduced that shape directly and got exactlylen=10 capacity=40000000000at ~1.7 MB max RSS, becausevec![0u8; len]gets lazy zero pages that never fault in. So oversized ranges "worked" while quietly reserving whatever the caller asked for, and only a width pastVec's capacity limit actually tripped:mid..u64::MAX— the established "read to the end" spelling, asserted against the proxy atfluree-db-server/tests/proxy_integration.rs:1821— becamevec![0u8; usize::MAX], whose capacity-overflow panicspawn_blockingcaught and relabelledIo("spawn_blocking failed: task panicked ..."). A slightly unkind touch: the one backend that couldn't serve the call was also the one that couldn't say why.The fix is the one suggested in the issue. #1599 already stats the open handle in this same function for its zero-length guard, so the size was sitting right there — I hoisted that single
metadata()call above both guards and clamped with it:The read loops already stopped at EOF, so an in-range request is byte-identical to before.
One deliberate behaviour change worth flagging, since it's the bit I'm least certain about: the zero-length guard used to be
file.metadata().map(|m| m.len() == 0).unwrap_or(false), i.e. it tolerated a stat failure and carried on. It now returnsIoinstead. My reasoning is that the buffer size is derived from that stat, so without it there's nothing to size the read against, and an error naming the failure beats either guessing a length or falling back to trusting the caller'su64. In practice this isfstaton a descriptor the thread owns and doesn't fail — but if the permissive form is preferred, I'm happy to put it back.I checked the other implementations for the same class while I was in there and none of them have it: the default / memory / proxy-filtered paths slice after a full read,
Arc<dyn Storage>and the two routers influree-db-api/src/lib.rsare pure delegation, and S3 and proxy-raw hand the range straight to a remote as an HTTPRangeheader, where the server does the clamping per RFC 7233.One boundary over, though, the backends still don't fully agree, and the trait doc now names both known divergences rather than letting one stand as if it were exhaustive — a contract paragraph with an unnamed exception is exactly the shape #1671 grew from. First: at
start >= object length,FileStorageandMemoryStorageboth returnOk(vec![])— this PR's own test asserts it at99..u64::MAX— while S3 returns 416. That predates this change, isn't in this diff, and is tracked as #1712; fixing it wants someone who can exercise real S3 rather than my reading of RFC 7233. Second: a zero-length object reads asNotFoundfromFileStorage— the #1599 debris guard treats an empty blob as absent on every read surface, this one included — whereMemoryStorageand the default implementation returnOk(vec![]). That one is deliberate and stays deliberate; it's still a divergence in this method's observable behaviour, so the doc says so.#1620 — sweeping staging files a crash left behind
Confirmed neither candidate coverer actually covers this. #1612 cleans up on its own error paths in
stage_bytes/write_atomic/create_new_atomic, and none of those run for aSIGKILL, an OOM kill or a power loss. #1614's sweep plans overlist_prefix, whichcontinues onis_tmp_artifact(file.rs:561at the merge base) — so it structurally can never enumerate one of these files, let alone reclaim it. That filter is exactly what makes an orphan harmless and immortal at the same time.The sweep is
FileStorage::sweep_orphaned_staging(), an explicit startup action — deliberately not mounted on the constructor. That placement is load-bearing, not taste. The parser below tolerates one residual (a foreign<name>.<decimal>.<decimal>.tmpstill parses as a legacy orphan), and the argument for tolerating it leans entirely on the sweep being a deliberate, visible, disable-able startup step. Hung offnew, that argument collapses, becauseFileStorage::newis reachable from any unit test or tool pointed at a directory the process doesn't own —fluree-db-connectionhas a Debug-formatting test that constructsFileStorage::new("/tmp/test"), and with the sweep on the constructor, running that one test unlinks a 90-day-oldmybackup.json.1.2.tmpan operator left under/tmp/test. Each decision is defensible alone; composed, they silently delete operator data. So constructing a storage now touches nothing on disk — which also takes the env reads, thecanonicalizeand the global claim-mutex back out of the constructor — and the layers that actually know it's startup make the call:create_sync_connection/create_async_connection's file arms influree-db-connectionFlureeBuilder::build,build_encrypted_internal,build_client_file, and the address-identifier storages inbuild_local_storage_from_configinfluree-db-apiThat covers every production entry to a file-backed instance — the server and CLI arrive through those builders, and
FileNameService::newshares the very tree the builder already sweeps, with the once-per-canonicalized-path claim making the layered calls idempotent.fluree-search-httpdconstructs read-onlyFileStoragehandles and deliberately does not sweep: a reader has no business unlinking anything in a writer's tree, and no released build ever swept there.The part I'd most like a second opinion on is the concurrency stance, so it's worth being explicit about what it does and doesn't promise. The tree is shared — between processes in a multi-instance deployment, and between subsystems even in one process — and a sweep that guesses wrong pulls a live writer's file out from under it. Three rules:
.tmpis a suffix, not a namespace: the indexer's vocab merge, the disk cache, the nameservice tracking file and the Raft log all stage under it, and the nameservice shares this exact tree —FileNameService::newbuilds aFileStorageon the pathFlureeBuilder::buildalso hands to storage. So a file is only the sweep's business once its name parses as one this writer produced, with every segment shape-checked rather than just the dot count. Rules 1 and 2 apply only to names that pass. Predicating onends_with(".tmp")instead — the obvious cheap version — would leave the age heuristic as the only thing standing betweenvocab_merge's index-build temporaries and an unlink, and a long-running index build is precisely the shape rule 2 is not competent to judge.tmp_siblingembeds a 64-bit token drawn once per process, so a name carrying ours is ours. In-flight and already-leaked are indistinguishable from a directory entry, so both are left alone. This rule is exact — for the current name format. A legacy<name>.<pid>.<seq>.tmpname carries a pid where the token now sits, and a pid can never equal a 16-hex token, so for legacy names rule 1 is inert and rule 2 stands alone. That case isn't a corner, it's the upgrade path itself: a rolling upgrade has a still-running v4.1.5/v4.1.6 process staging into the shared tree in the old format, protected by the age heuristic and nothing else. Fine in practice — a staging write is open for milliseconds against a one-day threshold — but the rules don't layer there, and the docs now say so plainly instead of presenting 1 and 2 as stacked defenses.Anything the sweep can't classify — an unparseable name, an entry it can't stat, an mtime in the future — is kept. Every unknown resolves toward leaving the file alone.
Two notes on that parser, since it's the load-bearing bit. Zero-padded numbers are rejected — nothing here emits one, and it keeps date- and offset-stamped foreign names (
backup.2026.08.tmp,wal.000001.000002.tmp) out of the legacy branch. One residual remains, deliberately: a foreign name shaped<non-empty>.<decimal>.<decimal>.tmp—dump.1.2.tmp— still parses as legacy. I checked all four in-tree.tmpproducers against the parser and none emits that shape, so it's an operator's own file rather than a collision with anything we ship. Closing it properly means gating legacy reclaim behind an opt-in, which is an upgrade-behavior decision rather than a parser one — and with the sweep now reachable only from an explicit startup call an operator can see coming and switch off, rather than from any construction, I think recording it onstaging_tokenis the right resting place.And the legacy branch is load-bearing rather than politeness: staging writes arrived in
85183c9ca, which v4.1.5 and v4.1.6 both contain, so every store written by a currently released build produces orphans in that format. Dropping it would strand them on disk permanently.On why an age threshold is defensible here when #1635 argues (correctly, I think) that it isn't for the index-build sweep: the objection there is that a reindex replays the whole commit chain, so its duration grows with the ledger and no constant can bound it. A staging file doesn't have that shape — its entire life is one
write_allof one already-in-memory buffer, followed immediately by a rename. There's no long-running case to get wrong. I believe that's a genuinely different situation rather than the same heuristic reused, but if it reads as the same heuristic wearing a different hat, I'd rather hear that before this ships than after.What it explicitly does not guarantee: it is not a lease, and it does not coordinate with other processes. A foreign staging write that somehow stayed open for over a day would be unlinked. Even then I think the blast radius is small — on POSIX the writer keeps its open descriptor, so its
write_allandsync_allstill succeed against the now-unlinked inode and only the final rename fails, surfacing as a write error rather than anything torn or wrong landing under a content address. But it is a spurious write error, and I'd rather say that than imply the sweep is airtight. If/when #1635 lands a real lease, this is a natural thing to hang off it.Practicalities: the walk is handed to the blocking pool when there's a runtime to hand it to (the sweep is called from
create_async_connectionand the API's async client builds, so a recursiveread_diron the caller would park a runtime worker — ~1s warm on local APFS for the default budget), and runs inline only for the genuinely synchronous caller (create_sync_connection). It runs at most once per base path per process however many startup layers call it, keyed on the canonicalized path so that's a property of the directory rather than of how someone spelled it.It's also bounded, at 100k directory entries. Exhausting that budget is not a deferral, and it would be easy to describe it as one: the walk restarts from the top with no cursor and never removes content, so if the first 100k entries it meets are content, every later start re-walks those same entries and never reaches the orphans past them. That case logs at
warn(reclaiming logs atinfo— different events), andFLUREE_STORAGE_TMP_SWEEP_BUDGETraises the cap, because a warning an operator can't act on is just noise.FLUREE_STORAGE_TMP_SWEEP=0turns the sweep off entirely, using the same falsey spellingsFLUREE_STORAGE_FSYNCalready accepts. The budget is a separate variable rather than an overload of that one on purpose —FLUREE_STORAGE_FSYNC=1means "on", so readingFLUREE_STORAGE_TMP_SWEEP=1as a one-entry budget would be "off" wearing a disguise.Tests
For #1671 the guard test fails at the parent commit with the exact error from the issue and passes after. There's also a differential test that runs nine ranges — three of them open-ended — through both
FileStorageandMemoryStorageand asserts they agree, since backend agreement is the property that was actually broken.For #1620, both directions the issue asked for, plus both directions of the mounting decision — the constructor must not sweep, and no startup path may lose its sweep:
construction_is_pure_and_the_explicit_sweep_reclaims_an_orphan— two pins in one: an aged orphan survivesFileStorage::new, then the explicit sweep reclaims itfile_connection_startup_sweeps_orphaned_staging(fluree-db-connection) andbuilding_a_file_instance_sweeps_orphaned_staging(fluree-db-api) — the flip-side regression guards: opening a file connection and building a file-backed client each still reclaim a planted stale orphan. Both run without a runtime, so the walk is inline and the assertion can't race it.the_sweep_leaves_a_live_looking_staging_file_alone— a fresh foreign-token file survivesour_own_staging_file_is_never_reclaimed_however_old— rule 1 beats rule 2 even at a zero thresholdthe_sweep_ignores_tmp_files_other_subsystems_wrote— the seven real foreign.tmpshapes, plus an arity case, all aged past the threshold and swept at a zero threshold so age offers no cover and rule 0 is the only thing on trialthe_shape_check_still_reclaims_our_own_orphans— the tightening didn't cost us the orphans the sweep exists for, current format and legacy alike, sitting next to a foreign file that must survivethe_walk_is_handed_to_the_blocking_pool_when_a_runtime_exists/the_walk_runs_inline_without_a_runtime— the hand-off both ways; the async one awaits the returnedJoinHandlerather than polling for the filea_future_mtime_is_treated_as_live,only_files_past_the_threshold_are_reclaimed,the_sweep_never_touches_content,the_walk_is_bounded_by_its_entry_budget,a_base_path_is_swept_at_most_once_per_processAnd the concrete shape that motivated the mounting: with a 90-day-old
mybackup.json.1.2.tmpplanted in/tmp/test, both hardcoded-path unit tests (test_connection_handle_fileinfluree-db-connection/src/lib.rs,test_create_file_storageinfluree-db-connection/src/storage.rs) run green and the file survives. The one test that pointed a real startup path at/tmp/test—test_fluree_builder_file— now builds in a tempdir, since a startup path sweeping is exactly its documented behaviour.I mutation-checked the mounting as well as the rules this time — ten mutants, ten killed, each by the test that owns it: disabling the shape check reddens
the_sweep_ignores_tmp_files_other_subsystems_wrote; disabling the token rule reddensour_own_staging_file_is_never_reclaimed_however_oldand only that; disabling the age rule reddens three; admitting zero-padded decimals reddens the parser test withSome("2026")forbackup.2026.08.tmp; a spawned walk that never runs reddens the blocking-pool test; a gutted inline walk reddens two; letting every claim win reddens the once-per-path test; hanging the sweep back onnewreddens the purity pin; and deleting the wiring call from eithercreate_sync_connectionorFlureeBuilder::buildreddens the corresponding startup pin. The assertions check thereclaimed/keptcounts and not just file existence, so a file that was never examined can't pass as one that was deliberately kept.Gates
cargo test -p fluree-db-core --all-features— 815 lib tests green (46 of them instorage::file), plus integration and doc-testscargo test -p fluree-db-connection --lib(39),cargo test -p fluree-db-api --lib(776),cargo test -p fluree-db-nameservice --lib(163) — the crates whose startup paths now own the sweep callcargo check -p fluree-db-connection --features aws— the async (S3-capable) connection path compiles with its file-arm sweepcargo check -p fluree-db-core --no-default-features --features native --lib— clean, sinceHandle::try_currentneeds tokio'srtand I didn't want that quietly supplied by a dev-dependencycargo clippy -p fluree-db-core --all-features/-p fluree-db-connection/-p fluree-db-api—--all-targets --no-deps, cleancargo fmt --all --check— cleanDocs: the "Staging files left by a crash" section in
docs/operations/storage.mdnow says the sweep is an explicit startup step (constructing a storage handle never deletes anything) and carries the legacy-format carve-out in operator terms, next to the durability table.