Skip to content

fix(bm25,vector): advance the watermark on an empty change set, don't rebuild - #1661

Open
christophediprima wants to merge 1 commit into
fluree:mainfrom
christophediprima:fix/bm25-empty-changeset-no-resync
Open

fix(bm25,vector): advance the watermark on an empty change set, don't rebuild#1661
christophediprima wants to merge 1 commit into
fluree:mainfrom
christophediprima:fix/bm25-empty-changeset-no-resync

Conversation

@christophediprima

Copy link
Copy Markdown
Contributor

The incremental sync traces the commits in (watermark, ledger_t], asks which subjects they touched, and treats an empty answer as a reason to rebuild the whole index from a full ledger re-query.

An empty answer there is determinate, not a failure to determine. The walk completed, and affected_subjects reported that no commit touched a property the index depends on. Queries whose shape cannot be narrowed at all never reach that point — they decline earlier, and declining is what correctly routes those to a full resync. So an empty set means the index is already correct as of ledger_t, and the only work owed is to record it.

Why this is worth fixing rather than tidying

Whether it matters at all depends entirely on how often a commit misses the indexed properties, and that is a property of the data, not of the index.

An index over a handful of text properties, on a ledger whose write volume is dominated by subjects that carry none of them, hits the empty case on most commits. Each one then rebuilds the entire index — so the cost per sync jumps from O(delta) to O(corpus), and it does so precisely in the situation where there was nothing to do at all.

It also feeds back. A rebuild is slow, the ledger keeps advancing while it runs, so the next window is wider — and, still containing no indexed change, rebuilds again. The index falls further behind the more often it syncs, and the transient state of a rebuild is allocated outside the index cache, so resident memory follows. We hit this on a deployment with exactly that shape: repeated whole-index rebuilds a few seconds apart, a staleness gap that grew monotonically rather than converging, and eventually the process being OOM-killed.

The fix

When the affected set is empty, set the index watermark to ledger_t, persist, and return a no-op result.

The watermark is advanced and persisted rather than left in place. Leaving it is cheaper per pass but never converges: every later sync re-walks a commit range that grows without bound, which on a busy ledger becomes thousands of commit reads per sync — a slower version of the same problem. Persisting costs one index serialization, which is O(index) and bounded, and skips exactly the expensive half of a resync: the full ledger re-query and rebuild.

An alternative would be to append a manifest entry pointing at the existing snapshot id, avoiding even that serialization since Bm25SnapshotEntry already carries index_t. I did not do that: manifest.trim() plus delete_old_snapshots(removed) could then delete a blob a newer entry still references.

The vector graph source has the identical fallback

vector.rs carried the same code and is fixed the same way. Its rebuilds are dearer still, because a resync re-embeds every document.

Testing

fmt and clippy --all --all-features --all-targets -- -D warnings clean. fluree-db-api --lib and the grp_graphsource suite both pass.

One new test, and two things about it worth flagging, because both made this easy to miss.

was_full_resync is the only observable that separates the two paths. Document count and watermark are identical either way, since a resync reaches the same correct index by rebuilding it — so every assertion you would naturally reach for passes against the unfixed code.

And the obvious fixture does not exercise the branch. affected_subjects filters flakes by predicate, so a query that matches on @type makes rdf:type a dependent predicate, and inserting an unrelated typed subject still counts as touching an indexed predicate. The fixture has to touch no dependent predicate at all. Mutation-checked: restoring the old fallback fails the test.

… rebuild

The incremental sync traced the commits in `(watermark, ledger_t]`, asked which
subjects they touched, and treated an EMPTY answer as a reason to rebuild the
whole index from a full ledger re-query.

An empty answer there is determinate, not a failure to determine. The walk
completed, and `affected_subjects` reported that no commit touched a property the
index depends on. Queries whose shape cannot be narrowed at all never reach that
point — they decline earlier, and declining is what correctly routes those to a
full resync. So an empty set means the index is already correct as of `ledger_t`,
and the only work owed is to record it.

On an append-heavy ledger whose writes mostly miss the indexed properties, this
made the cheapest possible window trigger the most expensive possible operation,
once per commit. Measured on a deployment indexing as:content / as:name /
as:summary / as:preferredUsername while the write volume was observations
carrying only sosa: properties:

  - 23 full index rebuilds in 10 minutes across 4 ledgers
  - one of them from a ONE-commit window (old_watermark=15194 ledger_t=15195)
    over a 15,000-commit ledger
  - resident memory reached 23.5 GiB of non-reclaimable anon against a 6 GiB
    index-cache bound, and the process was OOM-killed three times

It also feeds back: a rebuild is slow, the ledger advances hundreds of commits
while it runs, so the next window is wider and — still containing no indexed
change — rebuilds again. The staleness gap grew monotonically under observation.

The watermark is advanced and PERSISTED rather than left in place. Leaving it is
cheaper per pass but never converges: every later sync re-walks a commit range
that grows without bound, which on a busy ledger becomes thousands of commit
reads per sync, a slower version of the same problem. Persisting costs one index
serialization — O(index), bounded — and skips exactly the expensive half of a
resync, the full ledger re-query and rebuild.

Appending a manifest entry that points at the EXISTING snapshot id would avoid
even that serialization, since Bm25SnapshotEntry already carries index_t. Not
done: `manifest.trim()` plus `delete_old_snapshots(removed)` could then delete a
blob a newer entry still references.

The vector graph source had the identical fallback and is fixed the same way. Its
rebuilds are dearer still, because a resync re-embeds every document.

A note on the test, because the first version of it was useless. `was_full_resync`
is the only observable that separates the two paths — doc count and watermark are
identical either way, since a resync arrives at the same correct index by
rebuilding it, which is why this went unnoticed. And the obvious "unrelated write"
does not exercise the path at all: `affected_subjects` filters flakes by
PREDICATE, and a query matching on `@type` makes rdf:type a dependent predicate,
so any typed insert counts as an indexed change. The test passed against the
unfixed code until the fixture dropped `@type` entirely. Mutation-checked:
restoring the old fallback fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

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

@christophediprima this is a good catch and I like the mechanism here. I validated that the the O(corpus)-rebuild-per-empty-window pathology is real, the persist-vs-leave-the-watermark tradeoff is reasoned correctly, and the manifest-entry alternative was rejected for exactly the right blob-lifetime reason.

With that in mind, I want this PR to land, but I'd like to raise a few concerns first.

The premise the PR stands on--"an empty affected set is determinate"--is only true when property-dep extraction is total, but this isn't always true: wildcard selects, nested select projections, and variable predicates all produce deps that silently under-track, and the resync fallback this PR deletes was the correctness net over that gap.

I had Claude build and run the repro both ways: a select {"?x": ["*"]} index goes permanently, silently stale at this HEAD (was_full_resync=false, upserted=0, watermark advanced past the change) but did stay correct at the pre-PR merge base. Silent staleness in an index that is_valid_at then vouches for is wrong-query-results territory, so I have to block on it. The fix, though, is likely small: track a completeness bit in PropertyDeps::from_indexing_query and keep the resync fallback when deps are incomplete or empty (details and a suggested shape in the inline comment).

With that guard in, everything else here is a clear approve.

Adherence to repo commitments:

  • Patterns/abstractions: ✔ extends the existing sync/manifest/retention machinery rather than inventing a parallel path; the empty branch's persist is byte-for-byte the normal tail.
  • Performance (speed first, memory second): ✔ background sync path, not the query engine; converts O(corpus) rebuilds into one bounded O(index) persist; no per-row allocations added. No performance-degradation risk.
  • Testing: ⚠️ the new test is excellent, runs in grp_graphsource (verified by name), and is mutation-checked (verified) — but the un-narrowable query shapes have no coverage, the vector path has no test, and the repro above shows the missing case is the one that bites.
  • Conventions: ✔ thorough multi-line commit body; fmt/clippy clean (default features locally; all-features green in CI); self-describing title.

Verified locally at branch HEAD: grp_graphsource 142/142 green; author's mutation check reproduced (old fallback → new test fails); adversarial wildcard-select repro fails at HEAD and passes at base; cargo fmt --check and cargo clippy -p fluree-db-api --all-targets -- -D warnings clean.

Just be sure to get the completeness guard (or an equivalent decline for un-narrowable deps) into both bm25.rs and vector.rs before this merges — with that in place I'm enthusiastically on board, and happy to re-review quickly.

// problem. Persisting costs one index serialization, which is O(index) and
// bounded, and skips exactly the expensive half of a resync: the full
// ledger re-query and rebuild.
if affected_sids.is_empty() {

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.

🔴 Blocking — the "empty is determinate" premise fails for legal query shapes, converting an expensive-but-correct rebuild into permanent silent staleness.

The whole fix rests on "an empty affected_subjects means no commit touched a property the index depends on." That's only true when PropertyDeps::from_indexing_query extracts every predicate the query can observe, and it doesn't: a select wildcard ({"?x": ["*"]}) becomes a literal dep IRI "*" that never encodes and is silently dropped by CompiledPropertyDeps::compile (index.rs:535-537); an object-valued select entry ({"ex:author": ["ex:name"]}) fails prop.as_str() in extract_select_properties (index.rs:487-495) and is dropped with no recursion, losing both ex:author and ex:name; a variable predicate key returns None. And there is no completeness or is_empty guard anywhere in the sync path — the commit's "queries whose shape cannot be narrowed decline earlier" describes a decline that does not exist in the code (the only pre-walk resync is index_id.is_none() at bm25.rs:867-869).

Consequence, Claude-verified with an executable repro run both ways: create a BM25 index with where {"@id":"?x","@type":"ex:Doc"} / select {"?x": ["*"]} (deps compile down to rdf:type alone), then update ex:title on an existing doc. At this PR's HEAD the sync returns was_full_resync=false, upserted=0, advances the watermark past the change, and the new term is never indexed — later syncs start above it, so the staleness is permanent, and is_valid_at happily serves the stale index as current. With the pre-PR fallback restored the same test passes (was_full_resync=true, upserted=1, term present). This is the worst failure class — silently wrong query results — and it lands on exactly the "index everything on these subjects" configs users write when they don't want to enumerate properties.

To be fair about what's pre-existing: for these query shapes the incremental path was already silently lossy before this PR whenever a window mixed tracked and untracked predicates (the empty fallback only rescued windows that touched no tracked predicate at all). Your change didn't create the extractor's gaps — but the empty→resync fallback was the last correctness net over them, and on low-write-rate ledgers (one-commit windows, the case this PR's own commit message leads with) it was catching most of the damage.

Fix path, and I think it's small: make determinacy a checked property instead of an assumed one. Give PropertyDeps a complete: bool (or an Unnarrowable marker) that from_indexing_query clears whenever it meets "*", a non-string select entry, a variable predicate key, or any construct it doesn't positively recognize — then keep the old resync fallback in both syncs when !deps.complete || deps.is_empty(), and take the new watermark-advance branch only for provably-total deps. That preserves 100% of the win for the enumerated-property configs the PR was tuned on. A unit test asserting a wildcard-select config yields complete=false would pin it. The same guard needs to land in vector.rs:508 — and while you're in there, a vector-side copy of the sync test would be welcome since that path currently ships with no test at all — the feature gating makes it more work, but I'd rather see it land here than in a follow-up issue we all forget about.

(Commenting here because the extractor's gaps live in fluree-db-query/src/bm25/index.rs:394-509, which this diff doesn't touch.)

}

// If no subjects affected, fall back to full resync
// Nothing the index covers changed in this window.

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, and a suggestion. The compile-against-ledger_t choice is what makes the silent encode-skip sound (namespaces are append-only, so a predicate with flakes in the window always encodes). That argument is load-bearing and non-obvious — a one-line comment on the encode_iri closure saying "sound because namespaces are append-only and this ledger is at ledger_t" would keep someone from 'fixing' it against the watermark-time db later.

/// append-heavy ledger whose writes mostly miss the indexed properties, that made
/// the *cheapest* possible window trigger the *most expensive* possible operation,
/// once per commit. In production it produced 23 full rebuilds in 10 minutes and
/// OOM-killed the process three times.

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 test's design notes (why was_full_resync is the only separating observable; why the obvious @type-carrying fixture silently passes against unfixed code) are exactly the kind of trap-documentation that stops a future regression from being written with a green suite. Mutation claim reproduced: restoring the old fallback fails it.

// because a resync re-embeds every document.
if affected_sids.is_empty() {
warn!(
index.watermark.update(&source_ledger_alias, ledger_t);

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.

Observation. The empty-branch persist matches the vector sync's own normal tail (blob write + publisher head), and neither path GCs old vector blobs — pre-existing parity, not introduced here, just noting I checked it.

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