Skip to content

fix: never delete raw-txn blobs inline; tolerate dangling txn refs; add fluree verify - #1677

Merged
bplatz merged 3 commits into
mainfrom
fix/raw-txn-release-dangling-ref
Aug 25, 2026
Merged

fix: never delete raw-txn blobs inline; tolerate dangling txn refs; add fluree verify#1677
bplatz merged 3 commits into
mainfrom
fix/raw-txn-release-dangling-ref

Conversation

@bplatz

@bplatz bplatz commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Problem

Raw-txn blobs (store_raw_txn) are content-addressed — a byte-identical body maps to the same CID and storage key — but ContentStore::release is an unconditional delete with no reference count. The commit path released the blob on every failure branch: the PendingRawTxnUpload Drop guard (a detached task), build_commit errors (incl. EmptyTransaction), and publish failures. Any transaction whose body matched an already-committed one could therefore delete that commit's blob.

Seen in production: two chunks in flight concurrently; the loser hit the in-process commit-conflict retry in tx_builder, which re-spawns the same upload (same CID). Attempt 1's Drop-guard delete landed after attempt 2's no-op re-put, leaving commit 2e9b2dcd… with a permanent dangling txn pointer. The ledger's state was fine (flakes live in the commit), but fluree clone / export hard-failed on the missing blob, and the ledger was effectively unreplicable. The deterministic variant — an SQS redelivery of an already-committed body → zero flakes → release — needs no race at all.

Changes

Root cause — no inline deletes of raw-txn blobs (fluree-db-transact)

  • Drop guard only cancels an in-flight upload; it never deletes a blob that already landed.
  • Removed the releases on publish failure and after build_commit errors (release_raw_txn_after_build_err and BuildState.txn_id_for_release are gone). Orphans are left for reachability GC.

Readers tolerate a dangling commit.txn — warn and continue instead of failing the chain: export_commit_range (new missing_blobs on ExportCommitsResponse), pack, push-side validate_required_blobs (unprovided blob accepted; provided blobs still hash-verify), merge, and CLI push/clone. Only NotFound is tolerated; other storage errors still propagate.

fluree verify [LEDGER] [--limit N] [--json] backed by Fluree::verify_ledger: walks the commit DAG via a branch-aware store and reports missing commits/parents, undecodable commits, t gaps (primary-parent edges only), missing txn blobs, and a missing index root. Non-zero exit on problems.

Commit-window logging (INFO): head commit temporal resolvedraw txn upload finishedcommit record builtcommit blob storedcommit head published, so a commit-phase stall localizes from logs alone.

Tests

  • duplicate_body_failure_keeps_first_commits_txn_blob — fails 3/3 against the old Drop guard, passes with the fix (multi-thread runtime so the upload completes during staging, as in production).
  • verify_and_export_tolerate_missing_txn_blob — verify report names the commit/t/CID; export succeeds with missing_blobs populated.
  • Existing EmptyTransaction unit test inverted to assert the blob survives.

Docs: docs/cli/verify.md, docs/api/endpoints.md (missing_blobs, push tolerance), README/SUMMARY rows.

…xn refs; add `fluree verify`

Raw-txn blobs are content-addressed (identical body → identical CID) but
`ContentStore::release` is an unconditional delete with no refcount. The
commit path released the blob on every failure branch — the pending-upload
Drop guard (detached task), `build_commit` errors, and publish failures — so
any transaction whose body matched an already-committed one could delete
that commit's blob. Seen in production: an in-process commit-conflict retry
re-spawned the same upload while attempt 1's detached delete landed after,
leaving a commit with a permanent dangling `txn` pointer that broke clone.

- Remove all inline releases of raw-txn blobs; orphans are left for
  reachability GC. Regression test fails on the old guard, passes now.
- Export, pack, push-validate, merge, and CLI push/clone now warn and
  continue on a missing txn blob instead of failing the whole chain.
  `ExportCommitsResponse` gains `missing_blobs`.
- New `Fluree::verify_ledger` + `fluree verify`: walks the commit DAG and
  reports missing commits/parents, undecodable commits, t gaps, missing
  txn blobs, and a missing index root. Exits non-zero on problems.
- INFO-level progress lines through the commit window (head temporal
  resolved, raw txn upload finished, commit record built, commit head
  published) so a future commit-phase hang localizes from logs alone.

@aaj3f aaj3f left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is careful work, and the diagnosis was very nicely described, @bplatz

Two things to consider before merge. First, the PR body and the two doc comments (commit.rs:762, raw_txn_upload.rs:22-25) say orphans are "reclaimed by reachability GC", but I'm not sure what this reachability GC would be as there there is no collector for txn blobs so far as I can tell. gc/sweep.rs:20-24 excludes transactions by design, and nothing else releases ContentKind::Txn. Because the upload is spawned before staging, every distinct failed body (SHACL/policy/parse, novelty cap, exhausted retries) is now a permanent blob, and Fluree AI / solo opts every transaction in. The direction is right — there's no safe inline delete without a refcount — but I'm not following the claim of the comments.

Minimally, update the comments; the real completion would be something like a commit-chain-rooted sweep of txn/, which verify_commit_chain now makes possible, and I'd genuinely like your call on whether that lands here or immediately behind.

Second, clone (sync.rs:1617-1622) tolerates every fetch error — MultiOriginFetcher::fetch folds 404/auth/5xx/network into one FetchFailed and IntegrityFailed is caught too — which contradicts the body's own "only NotFound is tolerated" and turns a corrupt origin blob or a mid-clone network blip into a silent provenance gap. A typed not-found in nameservice-sync matched here (and only here) fixes it; every other tolerant site in the PR already matches NotFound specifically.

The rest is optional though I'd advocate for seeing it folded in rather than punted: a declared missing_blobs on the push request so a client that forgot blobs still gets a 400 (commit_transfer.rs:904-913), distinct exit codes for verify so it can actually be the automation gate the docs describe (commands/verify.rs:66-72), and walking the primary lineage first in verify_commit_chain (verify.rs:151, one-line .rev()).

Adherence to repo commitments:

  • Patterns/abstractions: ⚠️ Extends ContentStore/ExportCommitsResponse cleanly and adds Fluree::verify_ledger in the api crate's idiom; but it introduces "orphan for GC" as a storage contract without connecting it to the existing gc::plan_sweep mechanism that is the repo's actual reclaim abstraction.
  • Performance (speed first, memory second): ✔ No hot query path touched; commit success path gains four info! events per commit and nothing per flake; failure paths lose an await. Disk growth from unreclaimed txn blobs is the one resource concern (above). No performance-degradation risk.
  • Testing: ⚠️ The two incident tests are wired (grp_transact.rs:8-9), run, and go red under mutation; no coverage of push-side tolerance, pack/merge/clone tolerance, or the CLI verify exit path.
  • Conventions: ✔ Multi-line conventional commit; cargo fmt --all -- --check clean; cargo clippy -p fluree-db-transact -p fluree-db-api -p fluree-db-cli --all-targets -- -D warnings clean; docs added (docs/cli/verify.md, endpoints.md, README/SUMMARY rows).

Verified locally at branch HEAD (06de3ed99, base f41e33b2b): fmt clean; nextest -p fluree-db-transact 350/350; nextest -p fluree-db-api --test grp_transact -E 'test(raw_txn)' 3/3 with both new tests seen by name; both mutations red then green on restore; clippy clean at package scope. Note this head has had no CI run (stacked on #1674, which is itself stacked; ci.yml only fires on PRs into main), so please retarget or wait for the stack to fold before relying on green checks.

Happy to talk through any of these

Comment thread fluree-db-transact/src/commit.rs Outdated
/// [`build_commit`] is deliberately left in place: it is
/// content-addressed and may be shared with a commit that did
/// publish, so deleting it here could dangle that commit's
/// `txn` pointer. Orphans are reclaimed by reachability GC.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High / blocking as written — also applies to the module doc at fluree-db-transact/src/raw_txn_upload.rs:22-25.

🟠 The reclaim path these comments (and the PR body) name does not exist — nothing sweeps orphaned txn blobs today, so this PR converts a racy delete into a permanent, unbounded leak.

The only garbage collection in the workspace is fluree-db-indexer/src/gc/ — the manifest-driven collector plus the storage sweep from #1614 — and the sweep's own scope note excludes this content kind explicitly: "Only index artifacts — every branch's index/ prefix plus the ledger's shared dict namespace. Commits, transactions, and config blobs are deliberately excluded: they are reachable through the commit chain, not the index chain, so this sweep cannot prove them unreferenced" (fluree-db-indexer/src/gc/sweep.rs:20-24). After this PR the grep of .release( across fluree-db-*/src finds no caller that ever touches a ContentKind::Txn blob.

Concrete consequence: the upload is spawned before staging (fluree-db-api/src/tx_builder.rs:640 and every maybe_spawn_txn_upload call at :1218-1295), so every distinct body that fails SHACL/policy/parse, hits NoveltyAtMax, or exhausts the retry loop now leaves a blob forever — and solo opts every transaction into store_raw_txn (fluree-lambda-transact/src/handler.rs:1314), so on our own product that is every failed write. Growth is proportional to failed-transaction volume with no bound and no operator lever. To be clear, I agree the old inline deletes had to go — there is no safe inline delete of a content-addressed key without a refcount, and a cross-process SQS redelivery can't be refcounted in-process — so the direction is right; it's the second half of the rule that's missing.

Fix, minimally: change these two comments and the body to say plainly that no reclaim exists yet (a future maintainer reading "reclaimed by reachability GC" will assume it's handled). The real completion is a commit-chain-rooted sweep of the txn/ prefix — plan_sweep already has the enumerate-minus-live shape (sweep.rs:85-120) and verify_commit_chain from this PR is the root-set walk the sweep module said it lacked. Whether that sweep lands in this PR or right behind it is a genuine cost/scheduling decision (it's an O(commits) read over the chain, and someone has to decide when it runs), which is the one case where I'd say a follow-up is legitimate — but the comments can't ship claiming it's already there.

Comment thread fluree-db-cli/src/commands/sync.rs Outdated
))
})?;
}
Err(e) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium / blocking on the body's own stated rule.

🟡 clone now tolerates every fetch failure, not just not-found — including an integrity failure from the origin — which contradicts "only NotFound is tolerated; other storage errors still propagate."

MultiOriginFetcher::fetch (fluree-db-nameservice-sync/src/origin.rs:300-345) folds a 404 together with 401/403, 5xx, and connection errors into a single SyncError::FetchFailed, and separately returns SyncError::IntegrityFailed when an origin serves bytes that don't hash to the requested CID (:318-321). The new Err(e) => arm here catches all of them, prints a warning, and continues. Every other tolerant site in this PR (export_commit_range, pack, merge, CLI push/publish) matches fluree_db_core::Error::NotFound(_) specifically — clone is the one that doesn't.

Failing scenario: a transient network blip (or an expired token) mid-clone now yields a "successful" clone with a provenance gap and one warning line scrolled past, where before it failed loudly and the user re-ran; worse, a corrupt or tampered txn blob on the origin becomes "continuing without it" instead of an integrity error.

Fix: give nameservice-sync a typed not-found — e.g. SyncError::NotFound(cid) surfaced from fetch_object's 404 arm (origin.rs:118-134) and propagated by fetch only when every origin returned it — and match on that here; leave IntegrityFailed and the rest fatal as they were. --no-txns already exists as the explicit "I know, skip them" opt-out.

Comment thread fluree-db-api/src/commit_transfer.rs Outdated
txn_cid = %addr,
"pushed commit references a txn blob the client did not provide; accepting commit without it"
);
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium, optional.

🟡 Push now accepts an undeclared gap, so a client that simply forgot to attach blobs silently creates dangling refs on the receiver where it used to get a 400.

This is more of a question than a suggestion. validate_required_blobs is now a function that always returns Ok and logs, so the receiver can no longer tell "the source honestly has a provenance gap" from "this pusher has a bug" — and the export side of this same PR does declare its gaps (missing_blobs), so the two halves of the protocol are asymmetric. I wonder if we want a mirror-image missing_blobs: Vec<String> (#[serde(default)], additive on the wire) on PushCommitsRequest (:193-201), with the receiver tolerating only declared CIDs; the CLI push already knows exactly which CIDs it couldn't read (sync.rs:776-782) and could populate it. That keeps the tightened invariant for buggy or third-party pushers at no cost to the intended case. It's minor and non-blocking — but if you agree it's right, I'd rather see it folded in now than lost in the backlog.

Comment thread fluree-db-cli/src/commands/verify.rs Outdated
if report.is_healthy() {
Ok(())
} else {
Err(CliError::Config(format!(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low, optional.

🔵 verify's exit status can't distinguish "provenance gap, still clonable" from "chain broken" from "couldn't run", which undercuts the automation-gate pitch in docs/cli/verify.md:34-35.

Everything funnels through CliError::ConfigEXIT_ERROR = 1 (error.rs:7, 150-151) — the same code a nameservice lookup failure or a bad --limit would produce. MissingTxnBlob is, per this very PR, tolerable by clone/pack/push/merge; MissingCommit/UnreadableCommit are not. Automation gating a clone on fluree verify && fluree clone would refuse exactly the clone this PR made work. CliError::ExitCode(n) exists for typed exits; perhaps 0 / 1 (runtime error) / 3 (provenance-only problems) / 4 (chain problems), documented in the table. Minor — but if you agree, I'd rather see it in this PR than tracked-and-forgotten.

}
}
for (idx, parent) in env.parents.iter().enumerate() {
frontier.push((parent.clone(), Some((cid.clone(), env.t, idx == 0))));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low, optional.

🔵 Parents are pushed primary-first and the frontier is a stack, so on a merge DAG the walk descends the merge-parent lineage to genesis before touching the primary lineage.

Two effects: --limit N (documented as "newest first" at docs/cli/verify.md:21) spends its budget on the wrong lineage whenever there's a merge near the head, and a shared ancestor first reached via a non-primary edge is marked visited, so the TGap check on its primary edge never runs. I don't think this is common in practice, and export_commit_range has the same DFS shape (pre-existing, commit_transfer.rs:1427-1429), but the fix is a one-liner:

Suggested change
frontier.push((parent.clone(), Some((cid.clone(), env.t, idx == 0))));
for (idx, parent) in env.parents.iter().enumerate().rev() {

Comment thread fluree-db-api/src/verify.rs Outdated
Err(fluree_db_core::Error::NotFound(_)) => {
let (referenced_by, referenced_by_t) = referenced_by
.map(|(id, t, _)| (id, t))
.unwrap_or_else(|| (cid.clone(), -1));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Nit. When the head itself is missing, referenced_by falls back to the missing CID and -1, so the CLI prints "missing commit X (parent of t=-1 X)". Probably an Option on referenced_by (or a dedicated MissingHead variant) reads better.

// Cancel an upload still in flight. A blob that already landed stays
// in storage — see the module docs for why it must not be deleted.
if let Some(handle) = self.handle.take() {
handle.abort();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise. The cancel-only Drop is genuinely safe, and I traced it rather than trusting it: each attempt spawns an independent put with no shared in-flight state in any store impl; FileStorage::write_bytes_durable runs write_atomic inside spawn_blocking (fluree-db-core/src/storage/file.rs:645-660), so an abort can't leave a truncated file (blocking work runs to completion, rename is atomic); S3 PUT is atomic. So an aborted upload either landed whole or not at all, and can't disturb a concurrent attempt's put of the same CID — which closes the exact interleaving from the incident (the stale-snapshot continue at tx_builder.rs:1637 dropping an unfinished CommitOpts). Both regression tests go red under mutation (details in the verdict).

Base automatically changed from fix/planner-fanout-and-membership-gate to main August 25, 2026 20:58
bplatz added 2 commits August 25, 2026 17:07
Review follow-ups on the raw-txn dangling-ref fix.

- The "orphans are reclaimed by reachability GC" claim was false: nothing
  sweeps txn blobs, so every distinct body that fails staging leaks
  permanently. Say so plainly in `raw_txn_upload` and `commit.rs`, and
  record why a naive `txn/` prefix sweep is unsafe — the raft queued
  transactor writes live QueuedRequest envelopes under the same
  ContentKind::Txn prefix and releases them itself.
- `clone` tolerated every fetch error, including an origin serving bytes
  that fail their hash and an expired token mid-clone, turning a transient
  fault into a silent provenance gap. Add `SyncError::NotFound`, raised
  from `fetch_object`'s 404 arm and returned by `MultiOriginFetcher::fetch`
  only when every eligible origin 404s (a skipped-for-auth origin is not
  evidence of absence). Clone matches that and nothing else.
- `verify` pushed parents primary-first onto a stack, so a merge near the
  head sent the walk down the merge lineage to genesis first — `--limit`
  spent its budget on the wrong lineage and a shared ancestor reached by a
  merge edge never got its t-contiguity check. Walk parents in reverse.
- A missing head printed "parent of t=-1 <itself>"; give it its own
  `MissingHead` problem.
- `verify` collapsed every outcome onto exit 1, so `fluree verify && fluree
  clone` refused exactly the clone this change made work. Classify problems
  as provenance-only vs chain-broken (`VerifySeverity`) and exit 0/1/3/4.
- `PushCommitsRequest` gains `missing_blobs`, mirroring the export side, so
  a sender declares a gap instead of leaving the receiver to infer it.
  Undeclared gaps stay accepted — a sender predating the field cannot
  declare one, and refusing them would re-break the incident ledger — but
  warn instead of logging at debug.
- The commit path gained four INFO events per commit in a crate that had
  none; keep one (`commit head published`) and demote the intra-phase
  breadcrumbs to debug.

Tests: push accepts declared and undeclared txn-blob gaps (red when the
receiver rejects undeclared); all-404 vs 404-plus-401 vs 404-with-skipped
origin classification; severity assertions on the existing gap test.
@bplatz

bplatz commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — all four substantive points confirmed and addressed in 287299e72fdf80c4a1cf6c0abbfbeed8b0e0e8e8 (branch head with the main merge: 9054421d82042940886825aaff9166f501b2fe1d).

GC claim. You're right, and it was the only actually-wrong thing here. The comments and the PR body now say plainly that nothing reclaims txn blobs and that every distinct failed body leaks. I also wrote down a constraint your sketch and I both need to respect: the raft queued transactor writes live QueuedRequest envelopes under ContentKind::Txn into the same per-ledger txn/ prefix (queued_transactor.rs:301,366) and releases them itself, so a commit-chain-rooted sweep of that prefix would delete in-flight queue entries. That makes the sweep a design task, not a chore — deferring it to its own PR off main, and I'd rather it be a candidate list (orphan CIDs recorded at failure time, deleted only when past a grace period and unreferenced) than a blind enumerate-minus-live.

clone. Fixed properly: SyncError::NotFound raised from fetch_object's 404 arm, returned by MultiOriginFetcher::fetch only when every eligible origin 404s — and not when an origin was skipped for unsatisfied auth, since that isn't evidence of absence. Clone matches that variant and nothing else; IntegrityFailed, 401, 5xx and network faults are fatal again. Three classification tests (all-404 / 404+401 / 404+skipped).

verify. .rev() on the parent push, plus a MissingHead variant so the head-missing case stops printing t=-1. Exit codes are in: VerifySeverity on the report (healthy / provenance / chain), CLI exits 0/1/3/4, severity in --json, table in docs/cli/verify.md.

push missing_blobs. Added to PushCommitsRequest and populated by CLI push — but the receiver still accepts undeclared gaps, now at warn instead of debug. Tightening to a 400 today would 400 an older CLI pushing the very ledger from this incident, which is the case the PR exists to unblock; shipping the field first is what makes tightening possible in a later release. Say the word if you'd rather enforce now.

Also demoted the commit-path INFO events — tx_builder had zero before this PR and gained three per commit; kept commit head published, rest are debug!.

Test gap you flagged: added push-side tolerance coverage (declared and undeclared, red when the receiver rejects undeclared). pack/merge tolerance still uncovered.

CI: #1674 landed, GitHub retargeted this to main, and main is merged in — so checks run on this head now.

@bplatz
bplatz merged commit 3ca4cff into main Aug 25, 2026
14 checks passed
@bplatz
bplatz deleted the fix/raw-txn-release-dangling-ref branch August 25, 2026 21:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants