Repository navigation
query: index-walk range semi-join for correlated range probes, plus four join-chain fixes (BSBM Explore Q5) - #1815
Conversation
2c34ab5 to
8572ea1
Compare
2457d2d to
a1d79e3
Compare
… four generic join-chain fixes (BSBM Explore Q5) BSBM Explore Q5 (products similar to an anchor: shared feature, two numeric properties within +-k of the anchor's) grew ~65x from 1M to 100M products and was 56-59% of the Explore mix. The cost is per-candidate CPU in the join chain over a candidate set of ~13% of all products, not I/O. Generic fixes, on for every query: - planner: among equal-cardinality bound-subject probes, place first the one whose new variable a deferred FILTER pins (equality, IN, or a two-sided range with comparands free of the variable). Q5 evaluated its two range filters after paying for an rdfs:label lookup on every candidate. A one-sided bound does not count: BSBM Q7's `?date > now` keeps half the offers, and hoisting that probe ahead of the selective vendor/country check cost 15% on Q7 at 100M. - where_plan: insert the WHERE-level early Distinct only where a live variable dies at that step, instead of after every step once any has. - distinct: normalize keys through Cow and a reused scratch vector, so a duplicate costs a hash probe and no allocation. - eval: try the encoded IRI equality fast path before the bool-predicate LRU for `?v = / != <iri>` shapes (the 256-entry LRU thrashed over thousands of candidates). RangeSemiJoinOperator (fluree-db-query/src/range_semijoin.rs): a probe `?s <p> ?v . FILTER(range on ?v)` whose value is read by exactly one range filter with bounds computable from earlier-bound variables, and by nothing else, is folded behind the step that binds `?s` under the same where_dedup_safe license the early dedup uses (it is a semi-join), never in history mode, and only when the planner expects at least 256 driving rows. Per batch it takes the union of the rows' intervals, walks the predicate's POST leaflets for that envelope (directory-key pruning, binary search of the sorted key column of predicate- and type-homogeneous leaflets, only the subject and key columns decoded) into a subject -> values map, and keeps a driving row iff one of its values passes the row's own interval exactly. The walk serves batches whose bounds are all inline numerics; a batch with a date or string bound, a walk that would cover more than max(4k, 16 x driving rows) rows, or a context where raw leaflets are not authoritative (novelty overlay, time-travel, dataset scope, restricting policy) is answered by the batched subject probes the nested loop would have used, so the lane never costs more than the chain it replaced. Walked and driving rows are charged fuel per batch. The fold is stamped `range-semijoin` for the routing tests and honours FLUREE_DISABLE_QUERY_FAST_PATHS. Measured (result counts identical across every cell; 34 real products byte-identical at 1M and 10M): - 10M local, Q5: 5.5 ms -> 3.5 ms with the generic fixes -> 2.0 ms with the semi-join (-64%); 1M unchanged (below the driving-row gate). - 100M EC2 (m7a.4xlarge, official 4.2.0 vs HEAD-from-source vs candidate, two interleaved rounds): Q5 43.4 -> 19.1 ms at c1 and 50.9 -> 22.8 ms at c16; Explore mix throughput +34% (c1) / +35% (c16); every other Explore query within noise of base after the tie-break was narrowed. - 1M local through the BSBM driver, two interleaved rounds: Explore mix +1-2% (Q5 0.51 -> 0.43 ms from the generic fixes; the fold stays below its driving-row gate), BI mix flat within 2% on all eight queries. Tests: it_range_semijoin.rs computes expected rows in Rust over a generated fixture (strict boundaries, multi-valued numerics, a non-numeric value, a missing property, duplicate candidates, a novelty-only product, a projected value that must keep the real join, a wide range that must cap the walk, a date window that must take the probe path) and asserts routing through the stamps; planner tests pin the tie-break and its one-sided exclusion; it_query_explain pins the Distinct placement.
8572ea1 to
7554ec8
Compare
aaj3f
left a comment
There was a problem hiding this comment.
This looks good to me @bplatz and makes sense in relation to the BSBM Explore Q5 that's mentioned. I didn't personally find any issues but Claude noticed a few misses / allowances for wrong results, so I'll include that review verbatim below:
This is a really nice piece of engine work — the four generic fixes are each small, verifiable and clearly positive (the Cow distinct in particular), the fold's liveness analysis under where_dedup_safe held up against everything I threw at it, and the indexed Q5 shape on your own fixture runs 2.1× faster with only the fold toggled. I'm requesting changes for two silent-wrong-results bugs in the fold and one performance regression, all three reproduced at head against the generic pipeline: (1) the POST walk gates on OType::is_numeric(), which is "inline only", so every typed xsd:decimal value is invisible to it — an in-window decimal product is returned generically and dropped under the fold (range_semijoin.rs:625, :745); (2) a constant FILTER(?s1 > 150) on the folded variable is consumed by the pushdown and then never applied, because the folded triple never reaches build_scan_or_join — Q5 + that filter goes from 9 rows to 34 (where_plan.rs:3362); (3) wherever the probe lane declines (no binary store, non-root policy, multi-ledger, eager materialization) every driving row re-plans and re-opens a seeded operator tree, which on the pure-novelty view of your fixture is 4.2× slower than the chain it replaces (range_semijoin.rs:885) — so "never costs more than the chain it replaced" doesn't hold in exactly the contexts Solo users hit most. Fixes are small for the first two (admit NUM_BIG_OVERFLOW into the mixed-path gate; refuse or absorb pushdown bounds on the folded var) and the third wants a batch-level seeded fallback instead of a row-level one; the fixture needs a decimal product and a constant+correlated filter case so both stay fixed. A few questions in the inline notes (does the walk fire at all at 10M/100M given the 16×-first-batch cap; the rebuild-on-envelope-growth shape; tie-break precedence over the BI-1 hash-scan rule) that I'd love your read on but aren't blocking.
Adherence to repo commitments:
- Patterns/abstractions:
⚠️ Extends the existing lanes (subject_probe_lane_plan,batched_subject_probe_binary,prepare_leaf_for_scan,PreparedBoolExpression, thefast_path_outcomestamps, thewhere_dedup_safelicense) rather than inventing parallel ones — but the declined-batch fallback re-plans per row where every sibling lane degrades to a per-row scan. - Performance (speed first, memory second): ✖ CRITICAL — 4.2× regression where the probe lane declines (measured, fold-only toggle); indexed path 2.1× faster on the fixture; arena bounded by 16 × driving rows at ~150 B/row; no bench covers the fold.
- Testing:
⚠️ The new integration test, the explain test and the planner tests run and each goes red under a targeted mutation — but the fixture has no decimal value and no constant+correlated filter, the two shapes that are wrong; no CI ran (stacked PR); W3C suite not runnable locally (submodule absent). - Conventions: ✔
cargo fmtclean;clippy --all-targets -D warningsclean onfluree-db-queryandfluree-db-api(native); thorough multi-line commit body; self-describing title.
Verified locally at branch HEAD (7554ec85f, no CI on this stacked PR): fmt clean; clippy -D warnings --all-targets --features native clean on both touched crates; it_range_semijoin 1/1, it_query_explain 14/14, planner unit tests 109/109 (new ones seen by name); three mutations each turned the intended test red; a 15-shape differential probe against set_fast_paths_disabled(true) found the two divergences above and nothing else.
Just be sure to get the decimal gate and the pushdown interaction in (with fixture coverage) and the fallback down to batch granularity before this merges — and since this is stacked on #1814, it needs a retarget to main once that lands so CI actually runs on it. Happy to talk through any of these.
Notes with no line in this diff to anchor to:
Benches — 🔵 optional. No bench exercises the fold: query_hot_bsbm's "Q5" is the constant price-range shape (FILTER(?price >= 5000 && ?price <= 25000), consumed by the pushdown) and query_overlay_matrix's star is a constant range too, so a walk regression would not show up nightly. A query_hot_bsbm Explore-Q5 shape (correlated ±k bounds under SELECT DISTINCT, Medium/Large scale) is a genuine scope addition, so a follow-up is defensible here — but you're the one who knows the shape, and after merge it tends not to happen.
| let key_range = match (entry.p_const, entry.o_type_const) { | ||
| (Some(_), Some(ot)) => { | ||
| let o_type = OType::from_u16(ot); | ||
| if !o_type.is_numeric() { |
There was a problem hiding this comment.
fluree-db-query/src/range_semijoin.rs:625 — 🔴 blocking. The POST walk silently drops every xsd:decimal (and out-of-i64 integer) value, so the fold returns fewer rows than the chain it replaces.
Both walk passes gate on OType::is_numeric() — here for a homogeneous leaflet and again at :745 for the row-by-row mixed path — and that predicate is documented as "integer or float, inline only" (o_type.rs:329–333). NUM_BIG_OVERFLOW (0x800B) is outside both ranges, and the indexer routes every typed xsd:decimal to that arena (o_type.rs:93–96, "no stored row carries this o_type"), along with BigInt. So a leaflet or row holding a decimal is continued before it can reach the decode-and-compare path that key_bounds explicitly leaves for it ("None: the kind is not range-keyed (arena decimals), so rows are compared by decoded value instead"). The probe path does handle them — WalkValue::from_binding yields Other(binding) and value_passes re-evaluates the original filter — so the walk and the probe lane disagree with each other and with the generic pipeline.
Claude caught this with a differential probe against set_fast_paths_disabled(true): adding one product ex:DEC with ex:n1 "150.5"^^xsd:decimal (inside the ±120 window) to the PR's fixture, the Q5 shape returns 35 rows generically and 34 under the fold — ["ex:DEC","dec_in"] missing — with every batch stamped proceed. Same loss under a double-valued anchor and under SELECT ?product (COUNT(DISTINCT ?label) AS ?c) … GROUP BY ?product. Financial data is exactly where correlated range windows show up, and it is exactly where values are typed decimal.
Fix: admit the arena in both gates so those leaflets take the mixed path (key_bounds already returns None for NumBigArena, decode_set = mixed already carries OType/PId, and the mixed loop already does decode_value_v3 → bounds.matches → WalkValue::Other(materialized_object_binding(...))):
let walkable = |ot: OType| ot.is_numeric() || ot == OType::NUM_BIG_OVERFLOW;
// :625 if !walkable(o_type) { continue; }
// :745 if !walkable(OType::from_u16(ot)) { continue; }And please add a decimal-valued product (in-window and out-of-window) to it_range_semijoin.rs's products() — the fixture's only non-integer value is the string "abc", which is why this passed.
There was a problem hiding this comment.
Addressed in e400a8c
Both gates now admit NUM_BIG_OVERFLOW. Added in/out-of-window decimal and large-integer coverage. Also widened integer walk keys outward for decimal bounds beyond f64 integer precision, with a regression test.
| continue; | ||
| } | ||
| let mut readers = pending_filters.iter().filter(|f| { | ||
| !pushdown_consumed.contains(&f.original_idx) && f.expr.referenced_vars().contains(v) |
There was a problem hiding this comment.
fluree-db-query/src/execute/where_plan.rs:3362 — 🔴 blocking. A constant FILTER on the folded value variable is dropped whenever the pushdown consumed it, so the fold returns rows the filter should have removed.
extract_bounds_from_filters (pushdown.rs:18–47) turns FILTER(?s1 > 150) into pushdown.object_bounds[?s1] and records the filter in consumed_indices; it does this independently of the correlated filter on the same variable. collect_range_semijoin_folds then counts readers of ?v excluding consumed filters, so the correlated range is the sole reader, the probe folds, and the two places the constant bound could still be applied are both bypassed: the folded triple never reaches build_scan_or_join(…, &pushdown.object_bounds, …) (:1628), and apply_deferred_patterns skips consumed filters by design. The RangeSemiJoinCondition carries only the correlated bounds.
Claude caught this: the PR's Q5 shape plus FILTER (?s1 > 150) returns 9 rows generically and 34 under the fold (25 extra — ex:A at 150, ex:G at 120, ex:K at 110/130, ex:N19, the double anchor, nineteen bulk products), on the walked path and on the probe path (pure-novelty view) alike, because the loss is at plan time.
Fix — the minimal one is to refuse the fold when the pushdown already owns bounds on ?v; the fuller one intersects those bounds into the condition (lower/upper as constant expressions with the pushdown's strictness) so the shape still folds:
// in collect_range_semijoin_folds, alongside the read_elsewhere check
if pushdown_bounds.contains_key(v) {
continue;
}(pushdown_consumed is already threaded in; passing &pushdown.object_bounds next to it is a one-line signature change.) And a FILTER (?s1 > 150) variant belongs in the fixture — it is the most natural way a user tightens a Q5-shaped query.
There was a problem hiding this comment.
Addressed in e400a8c
Applied the minimal guard: a pushed-down constant bound keeps its enforcing join. Added >, >=, <, <=, and = variants on indexed and novelty views; reproduced the extra-row failure on the pre-fix commit.
| /// Exact evaluation of the original probe + filter seeded with one row, | ||
| /// for rows no batched path can answer (an unbound or unresolvable | ||
| /// subject). | ||
| async fn row_passes_exactly( |
There was a problem hiding this comment.
fluree-db-query/src/range_semijoin.rs:885 — 🔴 CRITICAL (performance). Wherever the batched probe lane declines, the fold re-plans and re-opens an operator tree per driving row, and measures 4.2× slower than the chain it replaced.
probe_batch computes subject_probe_lane_plan and, on Decline (:803, :807), leaves probed = None; every kept row then takes the _ => arm at :870–872 into row_passes_exactly, which calls build_where_operators_seeded (full block collection, reorder, pushdown extraction, operator construction) and open per row. The index path does the same for unbound subjects at :1137. The lane declines for: no binary store at all (an un-indexed or novelty-only ledger — and the planner deliberately folds there, since the ≥256 driving-row gate needs stats), a non-root policy (fast_path_common.rs:2601 declines before anything else), multi-ledger, and eager_materialization (reasoning, federated). None of those are knowable at plan time, so the fold happens and the fallback pays per row. The nested loop degrades to a per-row scan in the same contexts, not a per-row plan.
Claude measured it with the fold alone toggled (all other fast paths on): the PR's fixture Q5 shape on the pure-novelty view, 5 runs each, twice: generic 177 ms / 180 ms, fold 757 ms / 761 ms; the range semijoin exhausted event reads probed=1521 fallback=1772 walked=0 probe_batches=6. Indexed, the same shape is 2.1× faster (19–20 ms vs 42 ms), so the operator is doing its job where the lane serves — the regression is confined to the declined contexts, but "restricting policy" is every non-root Solo user, and the body's "so the lane never costs more than the chain it replaced" does not hold there.
Fix: make the declined fallback batch-level rather than row-level — seed one chain per batch (SeedOperator::from_batch(&batch) over the probe + filter, projecting the subject), drain it once, and mark kept subjects — which is the generic, overlay- and policy-correct pipeline planned once per batch. Independently, refusing the fold when ctx.stats.is_none() would remove the un-indexed case outright (no stats ⇒ no index ⇒ no walk possible anyway), though that changes the "pure novelty" assertion in the test.
There was a problem hiding this comment.
Addressed in e400a8c
The generic probe/filter is now planned once per fallback batch. A private row ID preserves separate bounds for repeated subjects and handles initially unbound subjects. In contexts where the fast probe lane cannot serve, the existing Distinct removes duplicate driving rows under the fold’s multiplicity-insensitive license.
Local 1,500-product correlated-Q5 fixtures improved from 29.1–32.6 ms to 6.72–7.56 ms on novelty and from 46.3–46.6 ms to 16.3–18.9 ms with non-root policy. Tests cover differing bounds, unbound/poisoned/missing subjects, and subject-denying policy. The PR body includes methodology and the generic-pipeline diagnostic.
| let mixed = narrow | ||
| .union(ColumnSet::single(ColumnId::OType)) | ||
| .union(ColumnSet::single(ColumnId::PId)); | ||
| let cap = walk_row_floor().max(WALK_ROWS_PER_DRIVING_ROW * self.probed_rows); |
There was a problem hiding this comment.
fluree-db-query/src/range_semijoin.rs:602 — 🟡 question. This is more of a question than a suggestion: does the walk actually fire on Q5 at 10M/100M? The cap is max(4k, 16 × cumulative driving rows), so on the first batch it is 16× one batch, and BSBM's ±120 window over productPropertyNumeric1 (uniform 1..2000) covers ~12% of all products — ~1.2M rows at 10M, ~12M at 100M. Unless the first batch carries ≥75k rows, the first walk caps, walk_off latches, and the whole query runs on probe_batch. If that is what happened in the 100M runs, the measured gain is the probe lane's (which is still real: deduped subject probes, no row materialization, no re-dedup) and the ~600 lines of walk are exercised only at the fixture's scale. The range semijoin index built by POST walk debug event would settle it; if the walk doesn't fire at scale we may want the cap to lean on the planner's driving estimate (known at fold time) rather than rows seen so far.
There was a problem hiding this comment.
Added actual runtime routing checks to the correlated benchmark in e400a8c. At 10k generated products both conditions walk (four condition-batches); at 100k one walks and one probes (13 each), before and after these fixes.
Your cap concern is valid. I have not rerun the production 10M/100M datasets and cannot attribute those historical gains to POST walks. I left the cap conservative, and updated the PR body to distinguish the historical results from the new local routing and timing evidence. Using the planner estimate to authorize larger walks should be a separate measured change.
| .as_ref() | ||
| .is_some_and(|index| index.envelope.covers(&batch_envelope)); | ||
| if !covered && !self.walk_off[c] { | ||
| let target = match &self.indexes[c] { |
There was a problem hiding this comment.
fluree-db-query/src/range_semijoin.rs:1073 — 🟡 question. I don't think this bites Q5 (one anchor ⇒ one envelope ⇒ one walk), but a batch whose envelope isn't covered rebuilds the index from scratch over index.envelope ∪ batch_envelope, with a cap that grows as 16 × cumulative rows. A driving stream whose bounds drift monotonically (a variable anchor whose value arrives in scan order) would re-walk the cumulative envelope every batch — roughly Σ k·w over batches, quadratic in batch count — and charge fuel for each rebuild. I tried to provoke it with a 1,200-product self-join and got a single walk because the hash join scrambled the order, so this is unverified. Walking only the delta (the part of the new envelope the old one doesn't cover) or latching walk_off after a handful of rebuilds would be cheap insurance if you agree it's a real shape.
There was a problem hiding this comment.
Addressed in e400a8c
Each condition now allows at most four walk attempts, then latches over to probes. A deterministic ten-batch expanding-envelope test verifies four successful walks and exact results from the later probe batches.
| } else { | ||
| let subject_var = folds[0].subject_var; | ||
| let conditions = folds.into_iter().map(|f| f.condition).collect(); | ||
| Box::new(RangeSemiJoinOperator::new( |
There was a problem hiding this comment.
fluree-db-query/src/execute/where_plan.rs:1643 — ⚪ nit / question. Folded triples never reach hash_planner.before_step, and RangeSemiJoinOperator::estimated_rows returns the child's estimate, so the label probe behind the fold is costed against the pre-semi-join stream. Not wrong, and I don't think it changes Q5's plan, but the fold exists to exploit a selectivity the planner then can't see; folding step_est × (range width / value range) in would be cheap if stats have what's needed.
There was a problem hiding this comment.
I would defer this one. Keeping the child estimate is conservative and does not affect correctness; deriving useful correlated-range selectivity needs more than assuming a uniform value distribution. These fixes leave that estimate unchanged, and the PR body calls out the limitation.
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn correlated_range_probes_fold_into_a_semijoin_with_exact_rows() { |
There was a problem hiding this comment.
fluree-db-api/tests/it_range_semijoin.rs:396 — ⚪ nit. The test sets FLUREE_RANGE_SEMIJOIN_WALK_FLOOR at the top and notes it is the only test in the binary, because walk_row_floor() is a process-wide OnceLock. That is fine today; a one-line comment at the [[test]] entry in Cargo.toml saying "keep this binary single-test" would stop the next contributor from adding a second test and getting a flaky floor.
| let mut columns: Vec<Vec<Binding>> = (0..num_cols).map(|_| Vec::new()).collect(); | ||
| // Reused across rows: a duplicate row costs a hash and a probe, no | ||
| // allocation; only rows that survive are copied into an owned key. | ||
| let mut scratch: Vec<Cow<'_, Binding>> = Vec::with_capacity(num_cols); |
There was a problem hiding this comment.
fluree-db-query/src/distinct.rs:180 — praise. The Cow scratch key is exactly right: cleared per row, hashed as the stored Vec<Binding> hashes (Cow<B> hashes as B; both Vecs length-prefix), compared element-wise, and only materialized for a vacant entry. A duplicate costs a hash and a probe and nothing else. Verified by reading against hashbrown's rehash path.
| row: &R, | ||
| ctx: Option<&ExecutionContext<'_>>, | ||
| ) -> Result<bool> { | ||
| if let (Some(op), Expression::Call { args, .. }) = (self.iri_eq_op, &self.expr) { |
There was a problem hiding this comment.
fluree-db-query/src/eval/helpers.rs:196 — praise. Trying fast_eq_ne_for_iri_bindings before the LRU is a pure reorder: it is the same function the uncached path already calls first (compare.rs:384), and the LRU only ever cached that path's answer, so =/!= against unbound, literal-vs-IRI and blank nodes are unchanged.
| /// `?v` (so the semi-join's keep/drop equals probe+filter+early-dedup under | ||
| /// `where_dedup_safe`). Every fold shares one subject variable. | ||
| #[allow(clippy::too_many_arguments)] | ||
| fn collect_range_semijoin_folds( |
There was a problem hiding this comment.
fluree-db-query/src/execute/where_plan.rs:3298 — praise. The liveness analysis behind the fold is careful and I could not break the license: COUNT(*) and bag projections refuse it; a projected, ordered, OPTIONAL-read, twice-filtered, re-joined or COUNT(DISTINCT ?s1)-read value keeps that probe a real join while the still-dead sibling folds; UNION and MINUS bodies come out identical to the generic pipeline. Double-vs-integer bounds at strict boundaries, an infinite upper bound and one-sided correlated ranges all matched too.
Summary
BSBM Explore Q5 (products similar to an anchor: a shared feature and two numeric properties within ±k of the anchor's) grew ~65x from 1M to 100M products and was 56–59% of the Explore mix. The cost is per-candidate CPU in the join chain over a candidate set of ~13% of all products, not I/O.
Four generic fixes, on for every query:
IN, or a two-sided range with comparands free of the variable). Q5 evaluated its two range filters after paying for anrdfs:labellookup on every candidate. A one-sided bound does not count: BSBM Q7's?date > nowkeeps half the offers, and hoisting that probe ahead of the selective vendor/country check cost 15% on Q7 at 100M.Cowand a reused scratch vector, so a duplicate costs a hash probe and no allocation.?v = / != <iri>shapes (the 256-entry LRU thrashed over thousands of candidates).RangeSemiJoinOperator(fluree-db-query/src/range_semijoin.rs): a probe?s <p> ?v . FILTER(range on ?v)whose value has no pushed-down constant bounds and is read by exactly one range filter with bounds computable from earlier-bound variables, and by nothing else, is folded behind the step that binds?s, under the samewhere_dedup_safelicense the early dedup uses (it is a semi-join), never in history mode, with a 256-row minimum when a driving estimate is available. Per batch it takes the union of the rows' intervals, walks the predicate's POST leaflets for that envelope (directory-key pruning, binary search of the sorted key column of predicate- and type-homogeneous leaflets, only the subject and key columns decoded) into a subject → values map, and keeps a driving row iff one of its values passes the row's own interval exactly. The walk serves numeric-bound batches, using inline range keys where possible and decoding arena-backed decimals/large integers; a batch with a date or string bound, a walk that would cover more thanmax(4k, 16 × driving rows)rows, or a context where raw leaflets are not authoritative (novelty overlay, time-travel, dataset scope, restricting policy) is answered by the batched subject probes the nested loop would have used, and when those probes decline, a single generic probe/filter plan serves each batch with a private row ID preserving per-row bounds. In contexts where the fast probe lane cannot serve, the existing Distinct operator removes duplicate driving rows before fallback work, under the fold's multiplicity-insensitive license. Walked and driving rows are charged fuel per batch. Each condition allows at most four walks before switching to probes, bounding repeated envelope rebuilds; rare materialized values are boxed to keep inline arena entries small. The fold is stampedrange-semijoinfor the routing tests and honoursFLUREE_DISABLE_QUERY_FAST_PATHS.Original measurements (before review fixes)
Result counts identical across every cell; 34 real products byte-identical at 1M and 10M.
100M is official 4.2.0 vs HEAD-from-source vs this tree, two interleaved rounds; every other Explore query within noise of base after the tie-break was narrowed. At 1M through the BSBM driver (two interleaved rounds) the Explore mix is +1–2% (Q5 0.51 → 0.43 ms from the generic fixes; the fold stays below its driving-row gate) and the BI mix is flat within 2% on all eight queries.
These historical 10M/100M runs have not been repeated after the review fixes; they do not establish which range-semi-join lane ran at those scales. See the local review-validation measurements below.
Tests
fluree-db-api/tests/it_range_semijoin.rscomputes expected rows in Rust over a generated fixture (strict boundaries, multi-valued numerics, a non-numeric value, a missing property, duplicate candidates, a novelty-only product, a projected value that must keep the real join, a wide range that must cap the walk, a date window that must take the probe path) and asserts routing through the stamps.it_query_explainpins the Distinct placement.-D warningsand fmt clean.Review regression coverage
>,>=,<,<=,=) combined with correlated ranges on indexed and novelty views.query_hot_bsbm, with actual runtime routing checks outside timed iterations; indexed scale plus novelty and policy fallback fixtures. This is separate from the existing constant-price Q5 benchmark.Both wrong-result regressions were reproduced using the expanded fixture on pre-fix commit
c859037bf: the constant bound admitted extra rows, and the indexed query omitteddecimal_in.Local measurements after review fixes
Same generated correlated-Q5 fixture and benchmark source in separate builds of pre-fix
c859037bfand the corrected tree, using optimizeddev-fast(opt-level 2). Untimed preflights check result counts against an independent calculation and record actual execution routes; timed iterations have tracing disabled. These are local diagnostics, not new production BSBM results.Ranges are Criterion point estimates across runs, not confidence intervals. Initial 2-second 100k runs were 2.32 ms before / 2.58 ms after; the two interleaved 5-second repeats above did not reproduce that slowdown. There is no repeatable indexed regression in this local matrix, but the 10M/100M production workload still needs remeasurement for a production-scale conclusion.
At 10k, routing records four walked condition-batches and no probe condition-batches. At 100k, both versions record 13 walked and 13 probe condition-batches: one range walks, the other hits the conservative first-batch cap and uses probes. We leave that cap unchanged and make no claim that the historical 10M/100M gains came from walks. Novelty and policy cases use the generic fallback; after early input dedup they need four condition-batches rather than eight.
As a diagnostic, disabling all query shape fast paths on the corrected 10k fixture gives 0.920 ms indexed, 6.46 ms novelty, and 16.0 ms policy. This is not a fold-only comparison; it shows the corrected fallback is close to the generic pipeline rather than several times slower.
Reproduce with
FLUREE_BENCH_SCALE=medium cargo bench -p fluree-db-api --bench query_hot_bsbm --profile dev-fast -- query_range_semijoin --warm-up-time 0.5 --measurement-time 2 --noplot(uselargefor 100k indexed products; fallback fixtures remain capped at 1,500).The constant-bound correctness guard may cost more for those variants because it retains the enforcing join, and arena numeric support performs checks that were previously skipped incorrectly. Neither changes the eligibility of the original integer correlated-Q5 shape. We retain the conservative child cardinality estimate; range selectivity and a fresh large-scale BI-1 tie-break comparison remain follow-up work, not claims made by these fixes.