fix: link the GC chain on full rebuild (a rebuild orphans the ledger's whole index history) - #1600
Conversation
Every full rebuild severed the index chain, orphaning the ledger's entire prior
history in one stroke. `rebuild.rs` published its root with
`None, // GC chain deferred for V3 milestone`, so the new root carried no
`prev_index`. A root with no `prev_index` starts a fresh lineage: every earlier
version becomes unreachable, and since nothing in the indexer reclaims by
reachability, their artifacts can never be freed by any retention setting.
This is the mechanism behind the unbounded growth the preceding commits could only
mitigate. Measured with a graph reconstruction over one live ledger's 226 root
blobs (`examples/root_graph.rs`, added here):
roots on disk 226
reachable from head 15
ORPHANED 211
fork points 0 <- not a publish race
distinct index_t 211 <- no version built twice
roots with no prev_index: 2 <- at index_t 6739 and 8627, NEITHER genesis
The unreachable range ends at 8623 and the head segment begins at 8627 — so the
root at 8627 orphaned 211 versions by itself. Six earlier theories (forking, lost
publish races, GC failing to release roots, release errors, the truncation loop,
the backlog draining) were each eliminated by measurement; this is what was left,
and the graph names it directly.
It is also a failure AMPLIFIER, not just a leak. Rebuild is the FALLBACK path,
taken whenever incremental indexing aborts — observed live as
`Incremental index aborted: V6 store load for class attribution failed: Cannot
allocate memory`. So every transient memory-pressure event silently converted a
recoverable error into permanent, unreclaimable disk. Which then increased page
cache pressure and made the next ENOMEM more likely: a closed loop.
The fix wires in the `compute_garbage_from_prev_root` helper that already existed
for this purpose, dead, labelled "Use when: rebuild.rs Phase F.7 is refactored to
use this shared helper" — and changes `encode_and_write_root_v6` to take the
previous head id and derive the chain ITSELF rather than accept a pre-built
context. That is the point: a caller can no longer omit the link by forgetting it,
only by there genuinely being no previous head.
Also separates two concerns that shared a failure path. The `prev_index` link and
the garbage manifest are independent facts, and the helper returned `None` if
either CAS expansion failed — trading the whole history to avoid an imperfect
manifest. Now the link is established as soon as the previous root decodes, and
the diff is best-effort on top: failing to record garbage leaks blobs a later
orphan sweep can still find, whereas failing to link loses everything permanently.
An unreadable head still yields `None`, since without decoding it there is no
`index_t` to point at — but that path now warns loudly rather than passing
silently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Heads up — #1614 touches the same lines and looks like a superset of this change. We hit this independently and have been carrying the identical Comparing the branches: this one passes I applied our six-patch stack against both branches: One thing #1614 does not absorb: I grepped it for |
|
Thanks for the contribution @christophediprima, but I'm closing this pull request in favor of the recently merged #1614. The fixes on that branch are a superset of those here, except for the tool defined in |
"expect 1 = genesis" is wrong for the healthy steady state: genesis has no prev_index by construction, but once GC has legitimately truncated past genesis the correct answer is 0, which is why #1600's writeup had to explain in prose that the 0 in its "after" output was not a regression. Say it in the output instead, and note in the comment that two or more is the case that means something.
Decodes every .fir6 root in a copied roots directory, rebuilds the DAG they form through prev_index, and reports which of three shapes a ledger is in: one broken chain, multiple lineages, or roots never linked in at all. Those have different fixes upstream, which is why the distinction is worth a tool rather than an inspection. Carried over from fluree#1600, whose fix is superseded by the reclamation work now on main. The diagnostic is not — nothing on main reconstructs the chain offline, and it is what ended the investigation. On a live ledger it reported 226 roots, 15 reachable, 211 orphaned, with fork points 0 and 211 distinct index_t values, which eliminated both the publish-race and built-twice explanations; two roots carried no prev_index at all, at 6739 and 8627, neither of them genesis. Six earlier theories had each been eliminated by measurement before this named the cause directly. Run against a roots directory copied out of a pod: cargo run -p fluree-db-indexer --example root_graph -- <roots-dir> [head-digest] Diagnostic only — an example target, not built by a plain cargo build, and not shipped behaviour. Refs fluree#1600, fluree#1548
Summary
fluree-db-indexer/src/build/rebuild.rspublishes every full-rebuild root with noprev_index:A root with no
prev_indexstarts a fresh lineage. Every index version before it becomes unreachable in one stroke — referenced by no root and listed in no garbage manifest. Since nothing in the indexer reclaims by reachability, those artifacts can never be freed by any retention setting, at any value. It is not a slow leak: one rebuild discards the ledger's entire index history, permanently.Why this is worse than "rebuilds are rare"
Two amplifiers make it self-feeding rather than occasional:
ENOMEMduring an incremental index falls back to a full rebuild. So a transient allocation failure — one that would otherwise be retried harmlessly — permanently strands the ledger's history instead.The end state is unrecoverable without external intervention: GC must write in order to free anything, so once the volume is full, every index attempt fails, GC never runs, and it does not recover on its own. Ours sat dead for ~34 hours while reporting healthy.
Measured, before and after, on the same live ledger
Reconstructed by decoding every root blob's
index_tandprev_indexout of live storage (examples/root_graph.rs, added in this PR) on a ledger under continuous ingest.Before, on the unpatched build:
93% of index versions unreachable. The two lines that pin the diagnosis are
fork points: 0and0 share a t with another root: those rule out the obvious competing-builds explanation (two builds from one parent, loser abandoned), which is the theory we spent a day on. A second root with no parent link at all is the signature of this bug specifically.After, same ledger, same command, patched build:
211 unreachable → 0.
Reading the two residuals, since neither is a defect:
no prev_index: 0rather than 1 is correct here — GC has legitimately truncated past genesis, so the oldest retained root points at a collected parent, which is the same fact asdangling: 1. One dangling pointer at the tail is what a bounded chain looks like; seven scattered through the middle is what a broken one looks like.Being precise about what this fix accounts for: the 226 → 21 drop is this fix plus a separate sweep that reclaimed the already-stranded artifacts. The
0 UNREACHABLEis this fix alone — no sweep can make a root reachable.What changed, and why the shape matters
The naive fix is to pass
prev_root_idat the one call site. That is not sufficient, because of how the two concerns were coupled:encode_and_write_root_v6tookgc_ctx: Option<GarbageContext>, and that singleOptioncarried two unrelated things: the cheapprev_indexlink, and the expensive garbage diff computed by walking both roots' CAS id sets. Any failure in the diff — an unreadable prior root, a missing leaf — dropped the link too. So the chain could break silently even where a caller did pass a previous root.This PR:
prev_indexas soon as the previous root decodes, and computes the garbage diff best-effort on top of that. The link no longer depends on the diff succeeding.prev_root_id: Option<ContentId>, deriving the garbage context internally. A caller can no longer pass a previous root and forget the link — the shape that produced this bug is no longer expressible.compute_garbage_from_prev_root, which existed but was unreachable.Tests
decodable_prev_head_always_yields_a_prev_index_linkunreadable_prev_head_yields_no_link_rather_than_a_bogus_onefluree-db-indexer: 341 passed, 2 ignored.fmtclean;clippy --all-features --all-targetsclean on the changed crates.Honest note on coverage: we could not construct a case where the garbage diff fails while the previous root decodes, because
collect_root_cas_ids_expandedtolerates missing leaves. So the "diff failed but link still set" branch is correct by construction rather than by test. Called out rather than papered over with a test that asserts something weaker than its name suggests.examples/root_graph.rsAdded because this bug was not diagnosable from logs or counts. GC reported
chain_len=21while the ledger held 226 root files, and no amount of reasoning about the truncation loop explained the gap — six hypotheses, all about deletion or concurrency, all wrong, because the bug was in creation.Every root carries
index_tandprev_index, so the real structure is recoverable. The tool decodes a roots directory and reports which of three shapes it is — one broken chain, multiple lineages, or unchained roots — because those have entirely different fixes.Kept in the PR as a diagnostic, not as shipped behaviour. If you would rather it not live in the repo, we will drop it, but it is what turned a week of speculation into one measurement.
Notes for review
warn!on a rebuild that cannot chain. Right now a severed chain is invisible until you decode the root graph. This PR does not add that, to keep the change minimal, but a deployment would have caught the problem in a day instead of a week.