fix(core): make FlakeMeta's Ord agree with its Eq - #1727
Conversation
FlakeMeta derives PartialEq/Eq/Hash over both fields but hand-writes Ord,
and the two had drifted: cmp compared lang only when neither side carried
a list index, so {lang: en, i: 0} and {lang: fr, i: 0} returned Equal
without being equal.
Add lang as a tiebreak after i. Option's derived Ord reproduces the old
explicit arms (None < Some) for both fields, so this is the old
comparator plus a tiebreak — a strict refinement that never reverses a
decision the old relation made.
new_ord_refines_old_ord holds the old comparator verbatim beside the new
one and quantifies both over the (lang, i) domain, so the refinement
claim is checkable rather than asserted: every pair the old comparator
decided is decided identically, and every newly separated pair is one
where Eq already said the values differed. ord_agrees_with_eq and
ord_is_a_total_order pin the two laws going forward.
One edge follows from the tiebreak: FlakeMeta::max() is a range bound,
not a maximum of the type. {lang: Some(_), i: i32::MAX} used to compare
Equal to it and now compares Greater, so an inclusive upper bound built
from it no longer admits that case. Unreachable through the min_for_*/
max_for_* builders, which pin o, dt, t and op to their maxima as well,
but documented at max() rather than left as folklore.
Regression coverage for the shape #1711 collapsed: `{lang: en, i: 0}` and `{lang: fr, i: 0}`. Two existing tests read as if they already covered it and did not, because every meta they build has `i: None` — the one branch the old ordering compared `lang` on. Both grow the missing case: - `fact_state::meta_is_part_of_identity`'s sibling, at the `FactKey` `OrdMap` slot that actually folded, and it also pins that retracting one tag does not tombstone the other. - `local_identity_helpers_match_core_spot_comparator_semantics`, the drift guard, now covers `cmp_meta` / `same_identity` / the SPOT comparator on two tags at one position, plus the check that list index still orders ahead of the tag. End to end, the note in `it_filtered_delete_list_meta.rs` reserved a leg for when the shape became constructible. It is now. The novelty-only sibling test builds it and retracts the tag that sorts first, and a new test asserts the novelty view and the post-index view are EQUAL across three legs: two commits landing at the same position, one commit with two sibling `@list`s, and a cold reload replaying the commit chain. Equality is the sharper assertion. The defect was a novelty↔index divergence — nothing was durably lost, the index build recovered the flake, and what the user saw was one entry before indexing and two after with no write in between. Asserting agreement fails whichever side regresses. All four fail on the pre-fix comparator with the same symptom (`["hello@en"]` where both tags are expected), verified by reverting.
…olds `Segment::range`'s exclusive lower bound is the third site #1711 reached and the only one nothing in the tree pinned. `exclusive_seek_past_a_tagged_bound_keeps_its_sibling_tag` builds a one-segment novelty holding `@en` and `@fr` at list position 0 and seeks strictly past `@en`; pre-fix the seek returns `[]` because the ordering called the two `Equal` and `partition_point` skipped both. It goes through `apply_commit` in a single commit, so `fact_state`'s dedup (updated only after the accept loop) never sees either flake — the assertion is independent of the `FactKey` / `same_identity` route the other regression tests cover, and it fails at the seek rather than at the segment-population guard. The site is latent through today's in-repo callers: both that pass `leftmost: false` build a synthetic bound with `m: None` and `t: i64::MIN`, and `t` is compared before the metadata tiebreak. It is pinned anyway because `Novelty::range_flakes` is `pub` and takes an arbitrary `first`. `FlakeMeta::max`'s doc named the wrong guard. It credited the `o`/`dt`/`t`/`op` maxima of `Flake::max_for_*`, but there are two more bound builders outside them — `predicate_walk_bounds` and `overlay_walk_bounds` in `fluree-db-query` — and at the second the `o`/`dt` half is false: a pattern with a bound object pins `o` to that value and `dt` to `Sid::max()`. The conclusion survives at all six sites, but on `t: i64::MAX` alone, which every comparator reads before `cmp_meta` and no real flake carries. The doc now says that, and says what a new builder would have to do to reach the narrowing. Two comments in `range.rs` described #1711 in the present tense. Their argument is unchanged — hashing the full fact identity is robust precisely because it never consults `Ord` — so the note now says explicitly that `remove_stale_flakes` was already correct and is not a workaround to unwind.
bplatz
left a comment
There was a problem hiding this comment.
Approving. The mutation evidence here is the strongest of the recent batch — I reverted the one-line comparator and ran both suites with --no-fail-fast (the api test is its own binary, so a --test grp_query filter silently runs nothing):
| suite | baseline | legacy comparator |
|---|---|---|
| core + novelty | 847 pass | 842 pass, 5 fail |
it_filtered_delete_list_meta |
9 pass | 7 pass, 2 fail |
Exactly the five and two you cite, and exactly the tests written for this bug. Nothing else in either suite depended on the old fold — that's a far better inertness argument than a green run, and it holds up.
The contract violation is real: the old match consulted lang only in the (None, None) arm of i, so the precondition is both sides carrying i: Some(x) with equal x — two language-tagged values at one @list position. Narrow, and the writeup scopes it accurately rather than overselling.
Perf: nil, as far as I can measure or reason. cmp_meta is the last tiebreak in all four comparators and FlakeMeta is the last component of FactKey, so then_with short-circuits before the added Option<String> compare in almost every call. The one site that reaches it often is bulk_apply_commits' par_sort_unstable_by at lib.rs:1032 (which orders m before t, so every pair sharing (s,p,o) hits it) — still two Option compares on a 2-5 byte tag, on the cold-load path. The structural costs (a few more OrdMap slots, a few more novelty bytes, the seek returning the sibling) are all just the bug not happening.
Worth saying the forward-looking win is underweighted in the description: unblocking the fact_state OrdMap → imbl::HashMap switch takes is_asserted/record from O(log novelty_facts) to amortized O(1) on the per-commit path. That's larger than anything this costs.
One thing to clear before merge: CI's test job is red. it_ledger_lifecycle::ledger_exists_on_file_storage failed (11412/11413 passed). It passes locally on this branch and asserts soft-drop nameservice record state, so there's no plausible path from a metadata comparator to it — almost certainly an unrelated flake. But the gate list in the description says green, and a check that failed isn't a pass. Re-run, or name it as known-flaky.
Two inline notes below.
| } | ||
| } | ||
| } | ||
| self.i |
There was a problem hiding this comment.
The EdgeKey reasoning in the PR description is right and worth keeping — reordering those fields would invalidate every persisted annotation arena — but it's in the wrong place. edge.rs is untouched, so the warning lives only in a PR body. The next person to spot the mismatch will be reading edge.rs, not #1727.
It also sharpens over time: EdgeKey::list_i is documented "v1 always None", so the opposite nesting (lang before list_i) is latent today and goes live when list-occurrence annotations land — which is exactly when someone might "fix" it.
That paragraph belongs as a doc comment on EdgeKey.
| /// and `None < Some`. An inclusive upper bound built from this therefore | ||
| /// excludes a language-tagged value sitting at list index `i32::MAX`. | ||
| /// | ||
| /// Unreachable through every bound builder in the tree — [`Flake::max_spot`], |
There was a problem hiding this comment.
"That doc is the enforcement mechanism" is an honest thing to write, and it's the part I'd want to firm up. The narrowing behaviour is pinned in new_ord_refines_old_ord, but the property a future bound builder has to preserve — pin t to i64::MAX — is enforced only by this prose.
A test quantifying over all six builders (the four Flake::max_* plus predicate_walk_bounds / overlay_walk_bounds) and asserting each pins t == i64::MAX would turn the doc into a gate for roughly ten lines.
Flagging it as a pattern rather than a nit specific to this PR: #1718 (Fluree::ledger() must stay manager-free or the commit path deadlocks) and #1719 ("every 503 here is retryable", keyed on status alone) both landed the same shape recently — a load-bearing invariant whose only guard is a comment.
…a PR body EdgeKey's derived Ord makes declaration order comparison priority, and the edge-annotation arenas persist that order on disk: forward-leaf rows are sorted (EdgeKey, ann_sid, t, op) and branch entries carry first_edge/ last_edge bounds the readers seek in EdgeKey order (annotation_arena/ format.rs sort contract + AnnotationForwardBranchEntry; reader.rs branch cursor + partition_point leaf seeks). Reordering the fields would invalidate every persisted arena, so a reorder is an arena format migration, not a refactor. The lang-before-list_i nesting is also the opposite of FlakeMeta's Ord, and deliberately so — nothing compares an EdgeKey with FlakeMeta::cmp. list_i is v1-always-None, so the divergence only becomes observable when list-occurrence annotations land, which is exactly when someone might be tempted to "harmonize" it. The warning now sits on the struct the next reader will actually be looking at.
FlakeMeta::max()'s doc names the property that keeps its narrowing unreachable — every bound builder pins t to i64::MAX and op to true, both compared before the metadata tiebreak — but nothing held a future builder to it. every_bound_builder_pins_the_sentinel_guard now quantifies over all six builders (the four Flake::max_* in core, with the max_psot alias asserted separately so de-aliasing it can't drop the pin, plus predicate_walk_bounds and overlay_walk_bounds in query, the latter across both of its object arms) and asserts the pin on each. predicate_walk_bounds goes pub(crate) solely so the test can reach it; its one production caller is unchanged. The flake.rs doc now points at the test by name, so the paragraph is a pointer to a gate rather than the gate itself. Verified non-vacuous both ways: dropping the t pin from Flake::max_for_predicate and (separately) from overlay_walk_bounds each fails the test naming that builder; restored, the suite is green.
|
Thanks @bplatz — both items landed, and both were the right call. The The sentinel invariant is now a gate, not prose ( Also took your re-weighting on the forward-looking win — the body now states the On the red |
FlakeMetaderivesPartialEq/Eq/Hashover both of its fields but hand-writesOrd, and the two had quietly drifted apart.cmpcomparedlangonly when neither side carried a list index, so{lang: "en", i: 0}and{lang: "fr", i: 0}came backEqualwhile==said they were different values.Ord's contract says that can't happen, and the places that trusted it are exactly the places that broke.The whole change is a tiebreak, replacing the
matchinfluree-db-core/src/flake.rs:Option's derivedOrdreproduces the old explicit arms (None < Some) on both fields, so this is the old comparator plus a tiebreak — nothing gets reordered.What it actually broke
Three sites, all of which express identity through this ordering rather than through
Eq:fluree-db-novelty/src/fact_state.rs:45—FactKeyis animbl::OrdMapkey, so{en, 0}and{fr, 0}were literally the same map slot.is_assertedansweredtruefor a fact the map had never seen, and the per-commit dedup atfluree-db-novelty/src/lib.rs:886dropped it. Worth noting the key carries not, so this bit across any two commits, not only same-tones.fluree-db-novelty/src/lib.rs:1561—same_identitytestscmp_meta(a, b) == Ordering::Equal, which drives thebulk_apply_commitsdedup walk on the cold-load / commit-replay path.Segment::range's exclusive lower bound (fluree-db-novelty/src/lib.rs:237-268, thepartition_pointat:252). With an ordering that calls the twinsEqual, seeking past the@enflake also skips the distinct@frflake. This one I'd rather understate than oversell: tracing every in-repo caller, the two that passleftmost: false(fluree-db-query/src/fast_path_common.rs:2077andfluree-db-query/src/binary_scan.rs:1575) both build a synthetic bound withm: Noneandt: i64::MIN, andtis compared before the metadata tiebreak — so nothing in the tree reaches it today. It's latent, not live, unlike the first two. It still decides the shape of the fix, though, for a reason that doesn't lean on today's callers:Novelty::range_flakes(:1352) ispuband takes an arbitraryfirst, and "strictly after this flake" is inherently an ordering predicate — no identity helper and no amount of caller discipline can express it. It's pinned by a test now rather than left to a hand-check.The user-visible shape is a novelty↔index divergence, not a lost write
Worth being precise about, because earlier descriptions of this bug (mine included) said the write was silently discarded, and that overstates it.
The persisted index contains no
Flakeand noFlakeMetaat all. It's built fromRunRecordV2(fluree-db-binary-index/src/format/run_record_v2.rs:66), a 32-byte#[repr(C)]POD of integer columns wherem.langis folded intoo_type(tag0b11=rdf:langString, payload =lang_id) andm.iis its owno_icolumn — so the index always kept the two apart. The flake is durable in the commit blob, and the index build recovers it.So what a user saw was the same query against the same ledger returning one entry before indexing and two after, with no write in between — an answer that flips on a background maintenance event. That's worse than a plain missing write in one respect (it's nondeterministic, and read-your-writes is violated until the indexer runs) and better in one that matters for landing this: nothing is durably lost, so there's no data repair and no migration here. The facts simply become visible consistently.
Why it's safe to touch a comparator every crate reads
cmp_metais the last tiebreak in all four index comparators (fluree-db-core/src/comparator.rs:167,179,191,206), afters,p,o,dt,t,op— two flakes only reach it if they're identical in every one of those. Andlang: Some(_)impliesdt == rdf:langStringon every parser path, so the metadata tiebreak is only reachable between two language-tagged flakes of the same lexical form. Within that, exactly one branch changes: both sidesSome(i)with equaliand differinglang.Rather than just assert that,
new_ord_refines_old_ordholds the old comparator verbatim beside the new one and quantifies both over the(lang, i)domain. Every pair the old comparator decided is decided identically, and the entire change is pairs it calledEqualthatEqalready called distinct. That's the blast radius, and it's checkable from the test rather than taken on trust.Nothing on disk is ordered by this relation either. Across
fluree-db-binary-index/srcandfluree-db-indexer/srcthe only reference tofluree_db_core::comparatoris anIndexTypeenum import instats/class_property.rs— no comparator function from core is called on any index write or read path, soFlakeMeta::cmpcan't participate in branch routing, a leaflet seek, a range bound, or a leaflet-skip predicate. I checked it the other way round as well, by building a real persisted indexed ledger under the old comparator and then reading the identical bytes back under the new one (subject crawl, predicate-value lookup, an ordered range withorderBy/limit, a full scan, a bound-object lookup, andselect *crawls of the lang/list-heavy subjects) — results identical. So: no format bump, no reindex, no dual-read, no migration.The one behavioral edge
FlakeMeta::max()is{lang: None, i: Some(i32::MAX)}. Under the old relation{lang: Some(_), i: i32::MAX}comparedEqualto it, so an inclusive upper-bound test admitted it; now it comparesGreaterand is excluded — the inclusive bound narrows by exactly that pathological case.It's unreachable through all six bound builders in the tree: the four
Flake::max_*here, pluspredicate_walk_boundsandoverlay_walk_boundsinfluree-db-query. What guards them ist, not the object — all six pinttoi64::MAXandoptotrue, both compared before the metadata tiebreak in every one of the four comparators, and no real flake carriest == i64::MAX. Theo/dtmaxima look like they're doing the work and mostly aren't:overlay_walk_boundspinsoto the pattern's bound object anddttoSid::max()whenever the pattern carries one, so at that site the object half of the argument is simply false. Since "max()dominates every meta" stops being true here, the doc atFlakeMeta::maxnamestas the load-bearing guard and says what a new bound builder would have to do to reach the narrowing — and the guard is now enforced rather than described:every_bound_builder_pins_the_sentinel_guardinfluree-db-query/src/binary_scan.rsquantifies over all six builders (the fourFlake::max_*— with themax_psotalias asserted separately, so de-aliasing it can't silently drop the pin — pluspredicate_walk_boundsandoverlay_walk_bounds, the latter across both of its object arms) and asserts each pinst == i64::MAXandop == true. The doc points at the test by name, so the paragraph is a pointer to a gate rather than the gate itself.predicate_walk_boundsgoespub(crate)solely so the test can reach it; its one production caller is unchanged. The narrowing itself is pinned innew_ord_refines_old_ordtoo. (min()is unaffected:{lang: None, i: None}sorted below it under both relations.)EdgeKeyis deliberately left alone — and now says so itselffluree-db-core/src/edge.rshas its ownlang/list_ifields whose derivedOrdnests them in the opposite order fromFlakeMeta::cmp's, and that must not be "harmonized": the field order is a persisted on-disk key order for the edge-annotation arenas, and reordering it would invalidate every arena already written. The full argument — including why the mismatch only becomes observable when list-occurrence annotations land, which is exactly when someone might be tempted to fix it — now lives where the next reader will actually be, as a doc comment onEdgeKeyitself, rather than only in this description.remove_stale_flakesis not a workaround to unwindTwo comments in
fluree-db-core/src/range.rsdescribed #1711 in the present tense, and after this they'd read as false, so they move to the past tense. Their argument is untouched and still worth keeping:remove_stale_flakeshashes the full fact identity, which is robust precisely because it never consultsOrdat all.Which is also why it was already correct, and I've said so in the comment rather than leaving it implicit. "The
Ordbug is fixed, so this is no longer necessary" is the wrong thing for the next reader to conclude — droppingmfrom that key is what silently collapses language variants on insert (#1273), a separate hazard this change does nothing about.Tests
fluree-db-core/src/flake.rs— the three laws:new_ord_refines_old_ord,ord_agrees_with_eq,ord_is_a_total_order.fluree-db-novelty— bothfact_state::meta_is_part_of_identityand thelocal_identity_helpers_match_core_spot_comparator_semanticsdrift guard read as though they already covered this and didn't: every meta they build hasi: None, which is the one branch the old ordering comparedlangon. Both grow the missing case, and thefact_stateone also pins that retracting one tag doesn't tombstone the other.fluree-db-novelty/src/lib.rs—exclusive_seek_past_a_tagged_bound_keeps_its_sibling_tagpins the seek directly, since it's the one of the three sites nothing else in the suite would notice regressing. It builds a one-segment novelty holding both tags at list position 0 and seeks strictly past@en; pre-fix that comes back[]. It goes through a singleapply_commiton purpose, sofact_state's dedup — updated only after the accept loop — never sees either flake. That keeps the assertion independent of theFactKey/same_identityroute every other test here takes, and it's why the pre-fix failure lands on the seek rather than on the segment-population guard above it.fluree-db-api/tests/it_filtered_delete_list_meta.rs— the note in there reserved an end-to-end leg for whenever this shape became constructible. It is now, so the novelty-only sibling test builds it and retracts the tag that sorts first, and a new test asserts the novelty view and the post-index view are equal across three legs: two commits landing at the same position, one commit with two sibling@lists, and a cold reload replaying the commit chain. Equality is the sharper assertion here — the divergence was the defect, so agreement fails whichever side regresses.fluree-db-query/src/binary_scan.rs—every_bound_builder_pins_the_sentinel_guard, described above: the six-buildert/oppin as a gate instead of prose. It isn't a comparator regression test (it passes under either comparator, by design), so its non-vacuity check is its own: dropping thetpin fromFlake::max_for_predicate, and separately fromoverlay_walk_bounds, each fails the test naming exactly that builder; restored, the suite is green.Every one of the new and extended comparator regression tests fails on the pre-fix comparator, which I verified by reverting the one-line change and re-running before restoring it: the two api tests with the symptom
["hello@en"]where both tags are expected, the seek with[]where["fr"]is.The number worth recording, though, is the one that didn't move. Under the pre-fix comparator 887 of the 892 tests in
fluree-db-core+fluree-db-noveltystill pass, and 7 of the 9 init_filtered_delete_list_meta— the five and the two being exactly the ones written for this bug. So nothing else in either suite depended on the old fold, which is a considerably better argument that the ordering change is inert than "the tests pass" is.Unblocked, not done here
fluree-db-novelty/src/fact_state.rs:26-31argues for moving itsOrdMaptoimbl::HashMap, and correctly flags that switching key equality fromOrdtoHash/Eqis a semantic change. Now thatFlakeMeta's two relations agree, that switch is a pure performance change as far as metadata goes — and it deserves more weight than a footnote:is_asserted/recordrun per accepted flake on the per-commit path, and the switch takes them from O(log novelty_facts) to amortized O(1). That's a larger effect than anything this PR's tiebreak costs, which is twoOptioncompares over a short tag at the very tail of the key, reached only when everything before it already ties. What's left before making the switch isFlakeValue's cross-representation numerics (Long(3) == Double(3.0) == BigInt(3)), which still wants its own cross-type dedup tests. Deliberately not doing it in this PR, but it's a much better place to make that change from than it was, and I didn't want the option to get lost.Fixes #1711