Skip to content

fix(indexer): merge novelty into emptied-by-retract leaflets without failing - #1603

Merged
bplatz merged 3 commits into
mainfrom
fix/incremental-empty-leaflet-sid
Aug 10, 2026
Merged

fix(indexer): merge novelty into emptied-by-retract leaflets without failing#1603
bplatz merged 3 commits into
mainfrom
fix/incremental-empty-leaflet-sid

Conversation

@bplatz

@bplatz bplatz commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Problem

On churn-heavy workloads (identity purge/recluster cycles, materialize delete-then-rewrite), roughly every third incremental index build fails with:

incremental indexing failed, falling back to full rebuild
error: Storage write error: missing column block for SId

Each fallback replays the entire commit history (~130–150s on a ledger with ~1.2M asserts and growing), instead of the ~5–8s the incremental would have cost. Observed live on testoco-5: 6 forced full rebuilds of _identity in one day, all with this error.

Root cause

When an incremental build retracts every row in a leaflet, update_leaf deliberately keeps the leaflet — preserving its key range for branch routing and its history sidecar for time-travel — but writes it via empty_encoded_leaflet_with_keys with row_count: 0 and column_refs: Vec::new() (no column blocks at all). That is valid V3.

A later incremental that routes novelty back into that leaflet's key range calls merge_and_encode_leaflet, which loaded the existing columns with ColumnProjection::all() and no zero-row guard. find_ref(ColumnId::SId) finds nothing → missing column block for SId → the whole incremental aborts and the indexer falls back to a full rebuild.

The full rebuild only writes live rows, so the empty leaflets vanish — which is why the next couple of incrementals succeed before the cycle repeats. Existing tests covered writing the emptied leaflet but never fed it back through a second update_leaf.

Fix

Guard the zero-row case in merge_and_encode_leaflet: an emptied leaflet contributes ColumnBatch::empty() instead of demanding column blocks that were never written. Merge then proceeds normally (novelty vs. zero base rows), and the leaflet is re-encoded with the new rows.

Read-path-only change — no index format bump; existing indexes containing empty leaflets become mergeable as-is.

Testing

  • New two-generation regression test test_update_after_retract_all_reinserts_into_empty_leaflet (build → retract-all → re-insert into the emptied leaflet). Reproduces the exact production error without the fix; passes with it.
  • Full fluree-db-indexer suite green (335 lib + integration tests), clippy and fmt clean.

@bplatz
bplatz requested review from aaj3f and zonotope August 7, 2026 11:29
…failing

A leaflet emptied by retract-all is written with zero column blocks
(empty_encoded_leaflet_with_keys) — valid V3, preserving key range and
history sidecar. But merge_and_encode_leaflet demanded
ColumnProjection::all() with no row_count==0 guard, so the next
incremental routing novelty into that leaflet failed with
"missing column block for SId" and the indexing lambda fell back to a
full rebuild. On churn-heavy workloads (identity purge/recluster,
materialize delete-then-rewrite) this forced a ~130-150s full-history
rebuild every ~3rd incremental.

Guard the zero-row case with ColumnBatch::empty() and add a
two-generation regression test (retract-all, then re-insert into the
emptied leaflet).
@bplatz
bplatz force-pushed the fix/incremental-empty-leaflet-sid branch from 38e5263 to 8f14da4 Compare August 7, 2026 15:22

@zonotope zonotope 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.

lgtm except for one test improvement, and as long as that improvement doesn't reveal anything.

.expect("merging novelty into an emptied-by-retract leaflet must not fail");
assert_eq!(gen2.leaves.len(), 1);
assert_eq!(gen2.leaves[0].info.total_rows, 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.

This test doesn't verify that gen1's history doesn't survive into gen2's sidecar. It also doesn't verify that the merged row's content round-trips. There should at least be a time travel read at t == 1 at the end.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d68b7f9

@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 a nice fix, @bplatz

The two things the test doesn't assert are the two I'd have worried about — whether the generation-1 retraction history survives the generation-2 merge, and whether ordering holds when the emptied leaflet is in the middle of a leaf rather than alone — so I wrote both and mutation-checked them; both pass at HEAD and both go red on the reverted guard. Ordering is fine for a structural reason worth recording: novelty is sliced by the next sibling's first_key at both the leaflet (incremental_leaf.rs:228-286) and leaf (incremental_branch.rs:651-678) levels, so when a repopulated leaflet's key range collapses from the preserved span to the new rows' span, the preceding sibling's half-open interval just widens and nothing is orphaned.

Only note I'd genuinely like your read on is the optional one about whether that guard eventually belongs inside load_leaflet_columns — six copies of it now, and this PR exists because one was missing — but I can argue the call-site convention just as well, and it's not a reason to hold this.

Adherence to repo commitments:

  • Patterns/abstractions: ✔ Reuses the existing ColumnBatch::empty() mechanism and matches the read path's guard verbatim; invents nothing orthogonal.
  • Performance (speed first, memory second): ✔ Hot path touched, but the common case gains one predictable u32 compare ahead of a zstd decode, and ColumnBatch::empty() allocates nothing. Strongly net-positive: removes a ~130–150s full rebuild on ~1-in-3 incrementals and reduces garbage, since a rebuild retires the whole prior index tree while an incremental retires only touched leaves. No performance-degradation risk.
  • Testing: ✔ Real two-generation regression test that goes red on revert with the exact production error; full indexer suite green. ⚠️ only in that its assertions stop at row counts — see the optional note.

Approving so you can merge whenever you're ready — none of the notes above are blocking, though the test-strengthening one is cheap enough that it might be worth folding in while you're here.

ColumnBatch::empty()
} else {
let projection = ColumnProjection::all();
load_leaflet_columns(

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.

Optional, more of a question than a suggestion. With this change the row_count == 0 → ColumnBatch::empty() guard now lives at six call sites — fluree-db-query/src/join.rs:1831, :2723, :3313, :3601, fluree-db-binary-index/src/read/binary_cursor.rs:382, and now here — plus a continue-shaped cousin at fluree-db-indexer/src/build/incremental.rs:4042. This PR exists precisely because one caller forgot it, so I wonder if the guard wants to move into load_leaflet_columns as an early if entry.row_count == 0 { return Ok(ColumnBatch::empty()); }, which would make the whole class of bug unrepeatable for the next caller. I can argue the other side too: the call-site guard also skips the projection setup and, in the cached variants, a cache probe, and call-site guarding is clearly the established convention rather than an accident — so matching it is defensible and arguably the right call for a fix that wants to be surgical. Definitely not something to hold this PR for; happy to punt it to a follow-up issue if you think the shared-reader version is worth doing at all.

};
let gen2 = update_leaf(&input)
.expect("merging novelty into an emptied-by-retract leaflet must not fail");
assert_eq!(gen2.leaves.len(), 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.

Optional. The test's teeth are real (I reverted the guard and it goes red with the exact missing column block for SId production error, and it's the only failure in the suite), but its assertions stop at leaves.len() and total_rows, which leaves the two consequences I'd actually worry about unpinned: that generation-1's retraction history survives into generation 2, and that ordering holds when the emptied leaflet is a middle leaflet rather than the only one. I wrote both and they pass — so this is a "lock in what already works" suggestion, not a bug report. Sketching them in case they're useful:

// (a) history survival — every gen-1 sidecar entry must still be findable after gen 2
let h1 = all_history(&g1.leaf_bytes, g1.sidecar_bytes.as_deref());
assert!(!h1.is_empty(), "gen1 must record retraction history");
// ... run gen 2 ...
let h2 = all_history(&g2.leaf_bytes, g2.sidecar_bytes.as_deref());
for e in &h1 {
    assert!(h2.iter().any(|x| x.s_id == e.s_id && x.t == e.t && x.op == e.op));
}

// (b) ordering across the boundary — 6 rows at leaflet_target_rows: 2,
//     empty the MIDDLE leaflet (s=30,40), then re-insert s=35
assert_eq!(all_s_ids(&g2.leaf_bytes), vec![10, 20, 35, 50, 60]);

Both go red on the reverted guard with the same error, so they'd be regression-proofing rather than duplicate coverage. Entirely reasonable to skip (b) if the single-leaflet case feels like enough.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d68b7f9

&projection,
input.order,
)?;
// 1. Load existing leaflet columns. A leaflet emptied by a prior

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 on the PR body's wording, purely for the record and not worth an edit. "The full rebuild only writes live rows, so the empty leaflets vanish" reads, on a first pass, like the fallback might have been costing us time-travel fidelity as well as time. It isn't — I checked, because if it were, this would be a much more urgent merge. index_build.rs:236-262 resolves each identity's whole event log and files the non-final transitions into the sidecar, and build_skips_never_asserted_retract_lifecycles (index_build.rs:1085) pins it with the fixture comment s=10: asserted@1, retracted@2 -> no row; history keeps both events. So the emptied leaflet structures vanish on rebuild but the history does not, and the pre-PR bug was cost-only. Might be worth a clause in the description so nobody later reads a data-loss story into it.

Commenting here because a PR-description note has no diff line of its own, and this is the guard it's about.

…eaflet merge

The regression test's assertions stopped at leaflet and row counts, leaving
the two consequences of merging into a zero-row leaflet unpinned: that the
prior generation's history survives into the new sidecar, and that ordering
holds when the emptied leaflet sits in the middle of a leaf.

Assert the merged row round-trips, that every gen-1 history event (both the
superseded assert and the retract) is still present after gen 2, and that
time travel to t=1 reconstructs the pre-retraction state while t=5 reads
empty. Add a companion test that empties a middle leaflet and re-inserts
into its collapsed key range.

Both tests fail with the production "missing column block for SId" error on
the reverted guard; the history assertion also catches dropping existing
history in the zero-row branch, which nothing else in the suite covers.
@bplatz
bplatz merged commit 22aa4e0 into main Aug 10, 2026
2 checks passed
@bplatz
bplatz deleted the fix/incremental-empty-leaflet-sid branch August 10, 2026 17:47
q-qp-p pushed a commit to q-qp-p/fluree-db that referenced this pull request Aug 16, 2026
…tion

The preserved partition is the same zero-column-block shape the incremental
writer produces via empty_encoded_leaflet_with_keys, so a later incremental
routing novelty back into it depends on the row_count == 0 guard added in
fluree#1603. Reverting that guard turns the new indexer test red with "missing
column block for SId", which is the failure fluree#1603 fixed.
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.

3 participants