fix(query): let OPTIONAL extend a row that left the shared variable unbound - #1724
Conversation
An `OPTIONAL` after a `UNION` was a no-op over exactly the rows the UNION left the variable unbound on — the query returned rows byte-identical to the same query with the clause deleted. Three things had to be true at once, and each is fixed here: `OptionalOperator::combine_rows` copied every required column verbatim. `unify_check` already accepts an unbound left value against any right value, but nothing then read the right value back, so a row the optional side had matched came out exactly as it went in. It now fills the shared columns the required row left unbound — SPARQL merge, §18.2.4. `PatternOptionalBuilder::cache_key` keyed only on the subject while `substitute_pattern` substitutes subject, predicate and object. Rows sharing a subject but differing in the object served each other's answers out of the LRU. The key is now the full correlation tuple, with "unbound here" as a state of its own. The batched subject probe reads the object slot off the plan-time template rather than the row, which is sound only while the object variable is optional-only. `build_batch` (and `supports_seed_coalescing`) now decline when it is shared with the required side, leaving the shape to the per-row substituted scan; `PlanTreeOptionalBuilder::build_batch` likewise declines a batch carrying an unbound correlation value, which its hash partition cannot express.
The fixed shape declines the batched subject probe, so an indexed view that never got its binary store would make the indexed test a silent duplicate of the novelty one — green on the unfixed engine. Assert the store is there.
…ane too
GroupedPatternOptionalBuilder — the builder for two-or-more chained
single-triple OPTIONALs on one subject — hardcoded unify_instructions()
to &[], so OptionalOperator::shared_merge_cols was empty for it and
combine_rows never patched anything. Its object vars are structurally
optional-only, but its SUBJECT only has to be PRESENT in the required
schema, not bound on every row, so
{ { ?s schema:name ?n } UNION { ex:nikola schema:name ?nn } }
OPTIONAL { ?s schema:email ?e } OPTIONAL { ?s schema:age ?a }
answered four rows of the form [null, "alice@example.org", 50]: the right
cardinality and the right e/a correlation, with the subject never written
back. A solution asserting an email for a subject it declines to name is
worse than a missing one. Pre-existing (the base answered a 21-row
cross-product), but it is the class this change is named for.
The builder now reports its subject as a merge column. Sound because the
BATCHED lane's own schema is optional-only and has no subject to read
back, so build_batch refuses a batch containing an unbound subject —
previously an accident of resolve_subject_id returning None, now an
explicit decline at the place that establishes the invariant. The per-row
chain's output does carry the subject at subject_left_col, which is where
combine_rows picks it up.
Scope PlanTreeOptionalBuilder's whole-batch fallback to a correlation
variable the inner can BIND. corr_cols is every required column the inner
merely REFERENCES, and Pattern::Filter is hash-join safe, so one unbound
FILTER operand took the entire coalesced driving side (up to 512K rows)
off the batched hash-join lane while answering identically — the inner
cannot bind it, so the filter errors under correlated and independent
evaluation alike and the no-match row was already right. Measured on an
IC5-shaped 7,500-row driving side (debug): 46.7s all-bound vs 555.2s with
one UNDEF cell before, 50.8s vs 50.6s after. The gate is
inner_patterns.produced_vars(), the same notion PlanTreeOptionalBuilder::new
already uses, plus an escape hatch for a filter that can still answer true
on an unbound operand (BOUND, COALESCE, IF, ||, XOR, EXISTS) — everything
else propagates the error, && included, since error && true is an error
and error && false is false, never true.
Key the OPTIONAL result cache on the SUBSTITUTED PATTERN rather than on
what substitution reads. substitute_pattern leaves a late-materialised
EncodedSid/EncodedPid/EncodedLit object slot free, so those rows now key
identically to an unbound object and share one scan instead of driving N
identical scans (EncodedSid) or declining to cache at all (EncodedPid,
EncodedLit). A literal object still declines, because substitution really
does push that value down. Correctness is unchanged either way —
unify_check re-applies the row's own correlation when the pending match
drains.
Also record on object_var_shared_with_required why widening the batched
probe was declined, and that the per-row fallback narrows rather than
removes the Binding-equality exposure: #1729 is a live instance of the
arm that silently drops rows.
Tests: the VALUES … UNDEF door from #1713 on SPARQL, its JSON-LD twin and
the PlanTree lane (the merge keys on Binding::Unbound and never asks how
the column got that way, so UNDEF is the same defect without a UNION); the
grouped-lane chain; and the filter-only correlation shape, pinning the ten
rows the scoping must preserve. Plus unit coverage for the grouped merge
column, the bindable-vs-read-only split, filter strictness, and the
free-slot cache key.
OptionalOperator is a hot operator with four builders, each with its own admission gates, its own batched probe, and its own fall-back to a per-row subplan rebuild — and nothing in regression-budget.json covered any of it, so none of the defects this branch fixes could have surfaced in CI. query_hot_optional runs one scenario per lane on an indexed file-backed ledger (the discipline query_hot_property_path uses: build once per scale, full reindex, reuse the snapshot warm): the batched subject probe, the object-correlated per-row scan where a result-cache-hit-rate change shows up, PlanTreeOptionalBuilder's batched hash left-join, and the same lane with a FILTER operand a UNION leaves unbound on half the driving rows. The fourth is a real sentinel rather than decoration: at tiny scale (200 persons, release) it runs in 382us, and with the correlation scoping forced back to every correlation column it runs in 6.01ms — 15.7x, far outside any budget. Registered at tiny 10% / small 5% / medium 3%, matching the other query_hot_* benches.
bplatz
left a comment
There was a problem hiding this comment.
Approving. Two small items inline, neither blocking.
I'd already verified this defect on pristine main when filing the VALUES … UNDEF comment on #1713, so I re-ran both doors here rather than trusting the description:
| merge base | this branch | |
|---|---|---|
VALUES (?s ?f) { (… UNDEF) } + OPTIONAL |
8 rows, ?f Null on every one |
8 rows, exactly equal to the no-VALUES baseline |
| the UNION repro | 10 rows = OPTIONAL-deleted | 13 rows, no longer identical |
The first is the convincing one — ex:alice → ex:brian, ex:cam → ex:alice/ex:brian, ex:liam → ex:alice/ex:brian/ex:cam, Null only where there is genuinely no friend. That's the merge happening, not the row count coincidentally landing. Confirmed #1734's door is still open here (6 rows, alice/brian/cam/liam Null) and agree it's a different mechanism that doesn't belong in a P1 wrong-results fix; I did not independently check the byte-identical-at-merge-base claim for it.
Performance — no meaningful risk, and the one real hazard is the one you measured. Checked rather than assumed:
cache_key: diffed old against new. Common shape wasVec::with_capacity(9)writing 9 bytes, nowVec::with_capacity(16)writing 10 (one extra position tag) — same allocation count per row. Net win elsewhere, since a free-object correlation was previously uncached entirely and now shares one scan across N rows. The literal-object decline was wrong-but-cached before, so nothing correct got slower.- The
build_batchdecline is narrowly scoped (Term::Varshared with the required side) — exactly the shape that returned 21 rows on an indexed ledger. OrdinaryOPTIONAL { ?s :p ?o }keeps the batched lane, so no correct-and-fast case moved. - The
PlanTreescoping toinner_patterns.produced_vars()is the right narrowing and the 50.6 s / 555.2 s table is the right measurement.
The bench work is the most durable part of this and worth saying so explicitly: regression-budget.json had no OptionalOperator coverage at all, so none of these five defects could have been caught by CI, and the fallback lanes are an order of magnitude slower than the probes they replace. 382 µs vs 6.01 ms is a real sentinel, not decoration.
| combined.extend(optional_builder.optional_only_vars()); | ||
| let combined_schema: Arc<[VarId]> = Arc::from(combined.into_boxed_slice()); | ||
|
|
||
| let shared_merge_cols: Vec<(usize, VarId)> = optional_builder |
There was a problem hiding this comment.
The description says shared_merge_cols "is empty for the overwhelmingly common OPTIONAL that shares only already-bound correlation vars." It isn't.
build_unify_instructions (:256) takes the pattern's produced vars, drops the optional-only ones, and keeps whatever remains in the required schema — so for the canonical OPTIONAL { ?s :email ?e }, ?s yields a UnifyInstruction and this vec has one entry.
Doesn't change the conclusion: one matches! per output row against a function that already allocates a Vec<Binding> and clones every binding is nothing. But the sentence claims a stronger property than the code delivers, and it's the kind of line that gets quoted in a later review. "One entry for the canonical shape; the position scan runs only on a column that is actually Unbound" is both true and still a good answer.
| // Fill shared columns the required row left unbound from the optional | ||
| // side. `unify_check` has already accepted this pairing, and it accepts | ||
| // an unbound left value against any right value. | ||
| for &(col, var) in &self.shared_merge_cols { |
There was a problem hiding this comment.
Suggest adding a line to #1734 noting that this merge makes the Unbound/Poisoned split observable rather than theoretical.
Before this change neither door worked, so the behaviour was uniformly wrong. Now it's selectively correct depending on how the null arose — and both render as null in output. Two rows that look identical in a result set behave differently in the next OPTIONAL.
Right call to keep it out of this PR. Worth recording on the issue so whoever picks it up knows the cost of leaving it, since that's the argument for its priority.
The doc said the vec is empty for the common OPTIONAL, but the canonical shape shares its subject, so build_unify_instructions yields one entry. The true cost statement: one is_unbound test per merge column per output row, with the fill scan running only on a column that is actually Unbound.
|
Thanks @bplatz — both landed, and an apology for the slow close-out on this one: I'd corrected the description when your review came in but hadn't replied here, and on a second look your first comment was sharper than I initially credited. The The #1734 note. Posted there essentially as you framed it: before this PR neither door worked, so the behavior was uniformly wrong; now it's selectively correct depending on how the null arose, both cases render as And thank you for the performance verification — the History note so the branch reads clearly: the two commits since your review are an empty CI-refresh commit (working around a stale merge-ref issue after main advanced) and the doc-comment fix above — no behavior changes. |
Fixes #1713.
An
OPTIONALdid nothing at all to the rows on which the required side had left the shared variable unbound. On theseed_values_datasetfixture:returned 10 rows — byte-identical to the same query with the
OPTIONALdeleted outright. §18.2.4 says 13: the UNION emits six solutions binding?fand four leaving it unbound, and a solution with?funbound is compatible with one that binds it, so those four have to be extended rather than passed through.The
UNIONis only how I found it. The merge asks whether the required row's column isBinding::Unboundand never asks how the column got that way, so the scope of the fix is a shared variable the required row left unbound, however it got there — andVALUES … UNDEFlowers to exactly that binding (parse/lower.rs:491). bplatz'sVALUES-with-UNDEFrepro on #1713, which has noUNIONanywhere, is broken at the merge base in the same way and closed here by the same one-place change, on both surfaces and on both builder lanes. That's what the tests now pin, and it's why I think #1713 closes as the class rather than as one repro.Five things had to be true at once, and I think all five are worth naming. The first is the "real" bug; the next three are what made it invisible on one lane and differently wrong on the others; the fifth is a fourth builder with the same defect and a worse symptom, which I found while checking that the first four really did cover every lane.
The merge dropped the value it had just matched.
OptionalOperator::combine_rows(fluree-db-query/src/optional.rs) copied every required column verbatim and then appended only the optional-only columns.unify_checkright above it already does the right thing — it treats an unbound left value as compatible with any right value — but nothing then read that right value back out, so a row the optional side genuinely matched came out exactly as it went in. It now fills the shared columns the required row left unbound from the optional side, which is just SPARQL merge. The patch is driven off a precomputedshared_merge_cols(the builder's own unify columns), for the canonicalOPTIONAL { ?s :email ?e }it holds a single entry (?s), and the cost per output row is oneis_unboundtest per merge column — the fill scan itself only runs on a column that is actuallyUnbound.The result cache keyed on a third of the correlation.
PatternOptionalBuilder::cache_keykeyed only on the subject, whilesubstitute_patternsubstitutes subject, predicate and object. So on the novelty lane the(cam, ?f=alice)row's answer — a one-row existence batch forcam friend alice— was served straight back to the(cam, ?f=unbound)row out of the LRU. That is what made the counts land exactly on the no-OPTIONAL answer, which is a nastier failure mode than being merely wrong: it's the cache making a wrong answer look self-consistent. The key is now the substituted pattern, mirroringsubstitute_patternposition by position: a slot that substitution pushes a value into keys by that value, and a slot it leaves free gets the sameutoken whatever the row held, so two rows that would drive the identical scan share one entry (unify_checkre-applies the row's own correlation when the pending match drains, so sharing is sound). Concretely that means a late-materializedEncodedSid/EncodedPid/EncodedLitin the object slot now shares one scan instead of driving N identical ones or declining to cache at all. For the ordinary shape — left-bound?s, free?o— the key is still just the subject and left-side fan-out reuses right-side results exactly as before.The batched subject probe read the object off the plan-time template.
PatternOptionalBuilder::build_batchprobes(subject, predicate)and takes the object slot fromself.pattern.o, never from the row — sound while the object variable is optional-only, wrong the moment the required side carries values for it. On an indexed ledger the same query returned 21 rows: one bare existence row per matching triple, so a required row that bound?fwas duplicated once per friend instead of filtered, and a required row that left it unbound was still passed through.build_batchandsupports_seed_coalescingnow decline when the object variable is shared with the required side, and the shape falls to the per-row substituted scan, which pins the row's own object exactly (and leaves an unbound one free). I went back and forth on instead wideningemit_object_varso the probe materializes the object and letsunify_checkfilter — it's the faster shape — but that would newly rest object correlation onBindingequality across representations decoded from the index vs. produced upstream, andBinding'sPartialEqanswersfalserather than erroring across those pairs, so it would silently drop rows; #1729 is a live filed instance of exactly that on theLit/dtcarm. For a P1 silent-wrong-results fix I'd rather not take that on. Worth being precise about what declining buys, though, and the code comment now says so: it narrows the exposure rather than removing it, becausesubstitute_patternstill leaves a late-materializedEncodedSid/EncodedPid/EncodedLitobject free andunify_check'sleft_val == right_valis then what enforces the correlation on the per-row path too.PlanTreeOptionalBuilder::build_batchhad the same class of problem one level up. It marked a row with an unbound correlation variable "unmatchable" and emitted a no-match row. A hash partition by correlation key genuinely can't express "compatible with every bucket", and its optional-side batches carry only optional-only vars so there'd be nothing to read the value back off — so that path hands the batch to the per-row lane instead. Scoped, though, to a correlation variable the inner can actually bind.corr_colsis every required column referenced anywhere in the inner patterns, andPattern::Filteris hash-join safe, soOPTIONAL { ?s ex:friend ?f . FILTER(?age > 20) }with?ageunbound on half the rows would otherwise take the whole (coalesced, up to 512K-row) driving side off the lane while changing nothing — the inner can't bind?age, so the filter errors under correlated and independent evaluation alike and the old no-match row was already right. The gate isinner_patterns.produced_vars(), the same notionPlanTreeOptionalBuilder::newalready uses foroptional_vars, plus a small escape hatch: a filter that can still answertruewith an unbound operand (BOUND,COALESCE,IF,||,XOR,EXISTS) keeps the per-row lane, because for those the row genuinely can still join. Everything else propagates the error —&&included, sinceerror && trueis an error anderror && falseisfalse, nevertrue.GroupedPatternOptionalBuilderwas the fourth lane, and it was fabricating solutions. Two-or-more chained single-tripleOPTIONALs on one subject route here (execute/where_plan.rs:2411-2426), and itsunify_instructions()was hardcoded&[]— soshared_merge_colswas empty for it andcombine_rowsnever patched anything. Its object variables are structurally optional-only (collect_grouped_single_triple_optionalsbreaks onrequired_schema.contains(&o_var)), but its subject only has to be present in the required schema, not bound on every row. So:answered nine rows, four of them
[null, "alice@example.org", 50],[null, "brian@example.org", 50]and so on — the right cardinality, the right?e/?acorrelation, and the subject never written back. That's the fabricated-solution flavour bplatz called the nastier failure mode on #1713: a row asserting an email and an age for a subject it declines to name. It isn't a regression (the merge base answered a worse 21-row?e×?across-product for the same query), but it is exactly the class this change is named for, so it's fixed here rather than deferred. The builder now reports its subject as a merge column, and thebuild_batchgate that keeps that sound — the batched lane's own schema is optional-only, so it has no subject to read back — is now an explicit unbound-subject decline rather than an accident ofresolve_subject_idreturningNone. The per-row chain's output does carry the subject atsubject_left_col, which is where the merge picks it up.The remaining door: #1734
Under that sharper framing one ordinary shape is still wrong, and it is not fixed here: two
OPTIONALs on the same variable.answers 6 rows where §18.2.4 wants 9 —
ex:alice/ex:brian/ex:cam/ex:liamall come back[?s, null], so the friends are found and then dropped and the fan-out goes with them. Give the twoOPTIONALs distinct variables and the same fixture answers the correct 9. The reason is that a precedingOPTIONALthat matched nothing recordsBinding::Poisoned, notUnbound(create_poisoned_row,optional.rs),unify_checkrefuses poisoned pairings, and this merge deliberately skips a column that isn'tUnbound. Poison-blocks-matching is a deliberate, documented engine semantic (binding.rs:48-53), so closing that door means deciding whether the blocking behaviour is right at all — which is a design call I don't think belongs inside a P1 silent-wrong-results fix. Filed as #1734 (P1,triage:needs-decision); it is byte-identical at the merge base and here, so nothing in this PR moved it. Closing #1713 on theUnbounddoors and naming #1734 as thePoisonedone seemed more honest than either claiming the whole class or leaving the gap implied.Performance
The
PlanTreefallback is the only hunk that can cost anything, and the scoping above is what keeps it off the rows it buys nothing for. Measured on an IC5-shaped fixture (300 forums × 25 members × 6 posts, a 300-rowVALUESdriving a two-pattern correlatedOPTIONALwith aFILTERon aVALUES-supplied?age, 7,500-row driving side, debug, same box and process):UNDEF?agecellOne unbound filter operand in 300 driving rows was an 11.9× cliff, and every row it slowed down was already answering correctly before and after. The blast radius really is the driving side rather than a batch —
supports_seed_coalescingcan't see data, so the operator coalesces first andbuild_batchdeclines after. Putting theUNDEFat forum 150 of 300 landed roughly half the 7,500 rows on the per-row path, which is the ratio you'd expect againstFLUREE_OPTIONAL_HASH_JOIN=0(that run came in at ~1,060 s, but it overlapped another build on this box so treat the ratio rather than the absolute as the signal). I confirmed the answers are identical either way — the newoptional_reading_an_unbound_filter_operand_pads_the_rowtest passes with the scoping and with it forced off — and that the lane really does stay on, via the operator's own debug counters (batched_builds=1,built_optionals=0with the scoping;built_optionalsnon-zero plus a split batch without it).Memory is neutral:
shared_merge_colsis oneVec<(usize, VarId)>per operator with no per-row allocation, and thecombine_rowsposition scan only runs for a column that is actuallyUnbound.One cost I did not remove: a correlated literal object (
?s :p ?o . OPTIONAL { ?s :q ?o }with?oa literal) now goes uncached, because substitution genuinely pushes that literal down and two rows carrying different literals must not share an entry. Keying it would need a stable byte encoding of everyFlakeValue/datatype pair, which is more than this fix should be buying; the comment at the decline says so. It's wrong-but-cached → right-but-uncached, so nothing regressed against a correct baseline, but it is a real per-row cost on that one shape.Bench
Nothing in
regression-budget.jsoncoveredOptionalOperatorat all, which means CI could not have caught any of the five defects above — and the fallback lanes are an order of magnitude slower than the batched probes they replace, so this is a path where a scoping change is worth gating.fluree-db-api/benches/query_hot_optional.rsadds one scenario per lane — the batched subject probe, the object-correlated per-row scan (where a cache-hit-rate change shows up), thePlanTreebatched hash join, and the unbound-filter-operand shape — on an indexed file-backed ledger, with the same build-once/reindex/warm-snapshot discipline asquery_hot_property_path.rs. Budget entry attiny10% /small5% /medium3%, matching the otherquery_hot_*benches;cargo test -p fluree-bench-support --test workspace_reconcileis green.The fourth scenario is a real sentinel rather than decoration: at
tinyscale (200 persons, release) it runs in 382 µs, and with the correlation scoping forced back to every correlation column it runs in 6.01 ms — 15.7×, far outside any budget.Tests
fluree-db-api/tests/it_optional_after_union.rs, its own binary because the indexed case needs the background indexer. Ten tests:["union", …]then["optional", …]) on novelty, and the same on an indexed view;OPTIONALthat routes toPlanTreeOptionalBuilderinstead;VALUES … UNDEFdoor from bplatz's comment on OPTIONAL after a UNION is a no-op over rows the UNION left unbound (deleting the clause returns identical rows) #1713 — SPARQL, the JSON-LD twin, and a multi-pattern variant on thePlanTreelane. All three return the eight all-null rows he reported at the merge base and the correct eight here, matching the no-VALUESbaseline exactly (the multi-pattern one returned five, all null, so the correlation-key partition was losing multiplicity as well as the binding);binary_store().is_some()) the same query returns the same nine rows with?sbound on every one;Plus unit tests in
optional.rsfor the grouped builder's merge column, the bindable-vs-read-only correlation split, the filter strictness classification (?age > 20anda && bstrict;!BOUND(?age)anda || truenot), and the cache key — that an object slot substitution leaves free keys identically toUnboundwhile a subject slot keys by value.Each integration test asserts the exact row multiset and a property that can't be satisfied by accident: for the UNION form, that deleting the
OPTIONALchanges the answer; for theUNDEFform, that the answer equals the no-VALUESbaseline. A plan-shape or row-count check would have gone green on a half fix — the free-row case was already emitting the right number of rows before this change, just with(null, null)in every one of them.Non-vacuity, per the repo's habit: reverting the
combine_rowsmerge alone turns nine of the ten red — the tenth is the filter-operand test, which pins the scoping rather than the merge and correctly stays green. Reverting only the grouped builder'sunify_instructionsturns exactly the grouped test red and leaves the other nine green.W3C registers
testsuite-sparql/tests/registers/mod.rs:143-144registersalgebra/manifest#nested-opt-1and#nested-opt-2under "correlated-OPTIONAL independence", and #1713 asked whether they'd flip. They don't, and I'm fairly confident that's correct rather than a half fix: they're about where the right operand is evaluated, not how it merges.nested-opt-1wants{ :x3 :q ?w . OPTIONAL { :x2 :p ?v } }evaluated independently, binding?v=2on its own and so matching nothing against?v=1— one solution. EveryOptionalBuilderhere instead seeds the inner subplan from the required row, so?v=1gets substituted, the innerOPTIONALfinds nothing,?w=3/4passes through, and we answer two. That's a genuinely separate defect from the unbound-merge one.I checked rather than assumed: both tests' actual output is byte-identical before and after this change (unregistered them, ran
sparql10_query_eval_testson both sides, diffed). They stay registered, and I extended the register comment with the concrete mechanism so the next person doesn't have to re-derive it. Full--test w3c_sparqlis green — 36/36 — with the register enforced both ways, which is also the evidence that nothing else in the suite moved.Follow-up
Poisoneddoor above. Needs a decision on poison-blocks-matching, not a patch.a_union_binding_the_var_does_not_suppress_the_barrierasserts plan shape rather than rows precisely because pinning rows there would have pinned this defect's output — once both land that should be upgraded to a row assertion, which is then the natural regression check for both issues at once.Gates
cargo test -p fluree-db-apigreen (full sweep — 43 targets, 3,269 tests, not just the query groups);-p fluree-db-querygreen;-p fluree-db-cypher -p fluree-db-sparql -p fluree-db-reasonergreen;cargo test -p fluree-bench-support --test workspace_reconcilegreen;cargo bench -p fluree-db-api --bench query_hot_optional -- --testgreen;testsuite-sparql --test w3c_sparql36/36;cargo clippy --all-targets --no-deps -D warningsclean on both touched crates and intestsuite-sparql;cargo fmt --alllast, in the workspace and intestsuite-sparql/.CI on this head:
fmt,clippy,testsuite-sparql,bench-paths,bench-compareandplanall pass.testreports exactly one failure,it_ledger_lifecycle::ledger_exists_on_file_storage— inherited frommain(43d758610in #1716 changed the semantics and its test wasn't updated), and nothing in this branch touches that path.