Repository navigation
Conversation
…dex states Pins the answers a single-triple OPTIONAL and the equality surfaces must give whichever form a row carries a term in, on a ledger that is - loaded fresh with the index covering every commit (overlay epoch 0), - written through a cached handle that then adopted an index (novelty drained, epoch nonzero: a long-running server after #1967), or - carrying novelty the index does not cover. Each case checks a hand-written expectation in all three states, and that the OPTIONAL's lookup ran with its correlated slot bound (no scan of the triple with that slot left a variable). SPARQL, JSON-LD, the WHERE of a SPARQL UPDATE, and Cypher's OPTIONAL MATCH. Red on main: on the encoded lane the OPTIONAL drops matches whose correlated value is an encoded predicate (`?p rdfs:label ?l` after `ex:s1 ?p ?o`) or an encoded subject used as a predicate, and scans the whole predicate for an encoded object. DISTINCT, GROUP BY, MINUS, EXISTS, NOT EXISTS and a trailing VALUES treat one IRI met as a predicate and as a subject as two terms. With novelty pending, an OPTIONAL after a batched join drops every match.
…triple
A single-triple OPTIONAL substituted a correlated value into its pattern
only when the value was decoded, plus an encoded subject in the subject
slot. Any other encoded value (late-materialized: an indexed ledger with
no live novelty, or a batched join lane at any time) was left a variable.
The OPTIONAL then scanned the whole predicate and left the correlation to
`unify_check`, which compares with `==`:
- an IRI reached as a predicate (`EncodedPid`) and met as a subject or
object (`EncodedSid`), or the reverse, compares unequal, so the match
was silently dropped (`ex:s1 ?p ?o . OPTIONAL { ?p rdfs:label ?l }`
never found a label);
- an encoded object from a batched join against the scan's decoded
object (novelty pending) compares unequal too, dropping every match;
- where the forms agreed the answer was right but cost rows x the
predicate's size, and the scan was cached under the one key every row
shared (#1973: a 13 s delete).
The nested-loop join already decoded every encoded form into every slot.
Its per-slot substitution is now a shared function both operators call,
so the two copies cannot drift again; the join's behaviour is unchanged.
OPTIONAL uses it, answers no match without a scan when a value cannot
fill its slot (a literal as a subject), and refuses an encoded value it
cannot decode rather than leave the slot free.
The scan cache key follows: every value substitution binds keys by its
identity in every position (encoded values by their encoded id), so two
rows share a cached scan only when they would run the same lookup. The
unit test that pinned an encoded object keying as free now pins the
opposite.
The scan encodes one IRI twice: as `EncodedPid` where a pattern reaches it as a predicate, as `EncodedSid` where it is a subject or object. Decoded producers carry it as `Sid`/`Iri`. `normalize_for_key` mapped decoded IRIs to `EncodedSid` but left `EncodedPid` alone, and the hash join's `join_key` kept its own copy of the rule, so on the encoded lane one IRI met as a predicate and as a subject was two terms to DISTINCT, GROUP BY, MINUS, EXISTS / NOT EXISTS, a trailing VALUES and hash joins: duplicate rows, split groups, MINUS removing nothing, EXISTS finding nothing. (`Iri` against `EncodedSid` also keyed apart in the hash join.) One rule now, `canonical_iri_id`: an IRI keys by its persisted subject id when it has one, else by its predicate id. `encoded_equivalent` and `join_key` both go through it. The store memoizes each predicate's subject id on first use, so a key over `EncodedPid` values pays a load per row; an all-`EncodedSid` key and a decoded key with a subject id take the same path as before. OPTIONAL's `unify_check` compares with `same_term`: equal or same-form values answer as before, and only a pair of different forms is canonicalized, through the same normalization.
…ST probe
`?s ... OPTIONAL { ?c <p> ?s }` correlates on the OPTIONAL triple's
object. With the substitution fix each required row ran its own bound
lookup; now the whole required batch is answered by one sorted pass over
OPST, binding the optional-only subject, like the subject-keyed probe
does for `OPTIONAL { ?s <p> ?o }`. The required side is coalesced into
that one probe when the lane is admitted.
The OPST walk is the nested-loop join's bound-object lane, extracted
into `for_each_object_probe_match` and shared rather than copied: one
leaf read per touched leaf, a binary search per object inside each
leaflet, and the same novelty merge (`ObjectProbeOps`). The join's lane
calls it with its existing emit path; its rows and fuel are unchanged.
The OPTIONAL lane is admitted under the same gates as the join's
(single ledger, encoded output allowed, a mergeable overlay, no history
range or policy-filtered predicate) and declines to the per-row lookups
when a row's object is not a ref with an id (unbound, a literal, an IRI
the dictionaries do not hold). It stamps `optional_object_probe` so a
test pins that it answered.
An EXISTS inside an expression (`FILTER(EXISTS {...} || ...)`) is
answered from a cached subject set of its predicate when the index has
no live novelty. The probe took only a decoded `Sid`, but in exactly
that state the scan emits subjects encoded, so every row declined and
ran the EXISTS body as a seeded subplan. An `EncodedSid` carries the
persisted subject id the cache holds, so it now probes directly.
The test counts the seeded subplans a query runs (a nested-loop join
opened per declined row): three before, none after, on the fresh and
the adopted-index states.
The per-row path returns a batch as soon as one required row's matches are drained, so each holds a row or a few, but every column was allocated with `batch_size` (1,000) slots. Any consumer that buffers the output kept all of them: #1973 measured 160-171 KB per required row, a 43 GB OOM on a 61k-row vocabulary page. Columns of a batch under half full are now shrunk to their rows before the batch is built. The batched lanes still fill whole batches; this only changes the small ones.
Adds COUNT(DISTINCT), JSON-LD not-exists, groupBy and values, and a Cypher count over OPTIONAL MATCH to the mixed-form cases, all with hand-written expectations in the three index states. Also pins the surfaces that already treated the forms as one term (inner joins on a predicate binding or on an object used as a predicate, FILTER = and sameTerm, a leading VALUES, subquery and group joins, a multi-pattern OPTIONAL), so the canonical form cannot regress them.
Measured on a 1M-triple ledger (warm server, dev-fast), the canonical form first cost +9% on GROUP BY / DISTINCT over predicate bindings on the indexed lane, and +31% with novelty pending, where predicate keys are decoded `Sid`s: each missed the subject dictionary and then built its IRI string to ask the predicate dictionary. - `predicate_subject_id` resolves every predicate on first use (up to 4,096 of them; lazily per predicate above that) and records whether any predicate IRI is a subject at all. Usually none is, so the per-row call is an inlined load and a branch. - A decoded IRI asks the predicate table first: a namespace bit test, then a hash probe on the `Sid`, no string building. A decoded predicate key no longer pays a subject-dictionary lookup per row at all, so GROUP BY / DISTINCT over predicates with novelty pending run about 5x faster than on main (300 ms -> 60 ms on the 1M ledger). An IRI in no predicate's namespace pays one bit test before its subject lookup.
… bound
`?s <p> ?t . OPTIONAL { ?s <q> ?t }` correlates on both subject and
object. The substitution fix decoded the row's encoded object into the
scan as well, a dictionary lookup per row that only narrowed a lookup
already narrowed to the subject; the `query_hot_optional`
object_correlated bench measured it at +3.5%. When the row binds the
subject, an encoded object now stays free in the scan and the
correlation is enforced by `unify_check`, whose `same_term` treats the
encoded and decoded forms of one term as one. The cache key follows the
same per-row decision, so a row keys its object as free exactly when its
scan leaves it free.
Tests add the shape, plus one where the unify must match an object
across forms: with novelty pending the batched joins emit the subject
and the correlated object encoded while the OPTIONAL's scan decodes.
…arms `normalize_for_key` and `binding_to_group_key_normalized` now ask `encoded_iri_canonical` first: an `EncodedSid` returns at once (it is canonical already), and an `EncodedPid` costs the store's per-predicate load, before the NUM_BIG check and the decoded arms of `encoded_equivalent`. Paired measurement on the 1M-triple ledger (main and fix servers warm side by side, 15 alternating repetitions per query): GROUP BY / DISTINCT over subjects and objects within +/-1% of main on both lanes; GROUP BY over predicates +0.7%, DISTINCT over predicates +2.5-2.9% on the indexed lane (about 1 ns per row); GROUP BY / DISTINCT over predicates with novelty pending -79%.
… nodes Equality surfaces resolved a decoded term to its id through the persisted dictionaries alone, and `encoded_equivalent` refused blank nodes while the hash join resolved them. With novelty pending a plain scan decodes every term, but the batched lanes bind a subject minted since the last index by its novelty id and a persisted blank node by its subject id. The two forms of one term then keyed apart: MINUS kept rows it must remove, DISTINCT and GROUP BY split one term in two, COUNT(DISTINCT) overcounted, and sameTerm and a trailing VALUES dropped matches. The OPTIONAL bound-object lane made this reachable from the plainest reverse OPTIONAL. `TermDicts` is now the one place a query resolves a decoded term to its id: the persisted dictionary first, then the novelty dictionary, the order every lane assigns ids in. The canonical IRI id is the subject id when the IRI has one (persisted or novelty), else the persisted predicate id. Blank nodes follow the same rule on every surface. A decoded IRI asks the subject dictionary first again, so a decoded subject key costs the one lookup it cost before predicates had ids here; the novelty dictionary is probed only after a persisted miss, only when it holds entries, and the probe no longer allocates. Every surface goes through it: `normalize_for_key` (DISTINCT, GROUP BY, MINUS, EXISTS), the group-aggregate keys and COUNT(DISTINCT), the hash join's `join_key`, OPTIONAL's unify (`same_term`, its normalization now built once per operator), VALUES compatibility, BIND onto a bound variable, and the sameTerm fast path, which compares canonical ids and hands a miss to the decoding comparison instead of answering false. The four copies of the persisted-then-novelty subject lookup (OPTIONAL, the nested-loop join's batched key, the property join, the annotation enumeration lane) now call it.
…nd the join A dataset of several graphs of one ledger (`FROM <g1> FROM <g2>`) has no graph view, while each graph's scan still binds encoded ids. OPTIONAL then refused an encoded correlation value with an internal error, and the nested-loop join left the slot free with nothing to correlate it: its unify instructions were built from right-side variables that exclude every left variable, so they were always empty, and a value left free matched every row of the slot (each match repeated once per left row). The join also ignored a value that can never fill its slot (a grouped, list or map value as an object) and matched the free slot instead. `CorrelationView` is what a correlated scan decodes through: the active graph's view, or, with several graphs of one ledger active, a view over the ledger's store, through which the ids a ledger shares across its graphs (subject, predicate, string dictionary) and inline literals decode. An arena-backed literal handle (NUM_BIG, vector) names a value only within its own graph and stays free. The nested-loop join now acts on the substitution outcome: a value that cannot fill its slot yields no right rows, and every variable the right batch still binds that the left row carries (a slot substitution left free) must be the same term on both sides (`same_term`). OPTIONAL leaves such a slot free for its unify check instead of raising an error. The dead unify-instruction construction in the join is removed.
…ints
A correlated property-path endpoint resolved `EncodedSid` through the
persisted dictionary only and had no `EncodedPid` arm. A predicate bound
in predicate position (`ex:s1 ?p ?o . ?p ex:parentProp+ ?super`), or a
subject a batched join bound by its novelty id, resolved to nothing, and
the row fell into the full-closure branch, pairing it with every closure
pair. The endpoint now resolves an `EncodedSid` through the novelty-aware
graph view and an `EncodedPid` through the predicate dictionary.
`GRAPH ?g`'s binding extraction gets the same `EncodedPid` arm so it stays
total across binding kinds. No plan is known to reach it encoded: the
`ex:s2 ?g ?o . GRAPH ?g { ... }` case the test pins was already right.
…t must not Every OPTIONAL case now states what the lane must do, so a lane that stops firing, or fires where it must not, fails on the same ledger as its twin: it fires for ref-valued object correlations, declines every window of literal objects, declines the windows it cannot probe in a mixed stream, and is never consulted when the OPTIONAL also correlates its subject. The JSON-LD, UPDATE and Cypher twins assert the lane's stamp rather than any bound lookup. Inside an UPDATE's WHERE with novelty pending the lane declines at the shared probe-lane admission (a transaction's WHERE runs over a one-member dataset), and the test pins that too. New cases: the lane's novelty merge (a pending retract of a base link and pending asserts), an UPDATE template and a JSON-LD transaction filled from a `?c` minted in novelty, and a non-root policy that leaves the lane on when it cannot touch the predicate and keeps it out when it can.
…ect is bound With the subject bound, OPTIONAL left an encoded object to its unify check instead of decoding it into the scan. For a numeric that changed the match: a substituted numeric matches by value across numeric datatypes (`1` and `"1"^^xsd:long`), the inner join's rule, and the unify check compares terms, so the answer depended on whether the subject happened to be bound. Numerics are substituted, so OPTIONAL matches a numeric the way the join does in every index state. Main matched it by value only on the novelty lane, where the value arrives decoded, and as a term on the indexed lane. Strings, dates and IRIs, for which the two matches agree, stay with the unify check and keep its saving. The OPTIONAL subject lane also checks the eager-materialization gate the bound-object lane checks. No caller reaches it eagerly today.
…lazy memo Keyed surfaces over decoded predicates, and the first query after an index publish, paid for the canonical form: - A decoded IRI asked the subject dictionary first, so a decoded predicate key (novelty pending: `GROUP BY ?p`, `DISTINCT ?p`) paid a subject lookup that misses, then a predicate probe, then a novelty probe per row. The predicate table answers first again, now behind a per-namespace mask of its predicates' name lengths, so an IRI outside every predicate's namespace or of a length no predicate there has costs a bit test, not a hash. Which persisted predicates are novelty subjects is found once per operator rather than probed per key. - The predicate-to-subject memo resolved every predicate on first use, a dictionary lookup per predicate the index holds inside the first query after a publish. Each predicate now resolves on its own first use, and the per-key read is one relaxed atomic load. - `EqualityNorm` reads which novelty layers hold entries once, not on every per-row `dicts()`. - OPTIONAL's bound-object lane coalesced up to the 512K-row cap of the hash-join lanes before probing. It now coalesces at most the window the join's own bound-object lane flushes at (100,000 rows); main answered these OPTIONALs row by row and buffered nothing. - A per-row output batch is shrunk to its rows only when it is under an eighth full, not under half.
The BIND operator's clobber check kept a row only when the bound value and the computed one were structurally equal. A pattern binds a subject encoded on an indexed ledger while an expression computes it decoded, so the check dropped a row whose computed IRI is the bound term. It now uses `same_term`, as the inline BIND check does since the canonicalization commit. The JSON-LD shapes the tests pin all reach the inline check; no query shape found reaches the operator's check with a bound target, so this keeps the two paths on one rule.
… does not The performance notes claimed every equality surface keyed an IRI by one canonical form. That was not true for novelty-minted ids, blank nodes, the sameTerm fast path, property-path endpoints or BIND. It is now, and the notes say so, along with the two cases still outside the rule (an IRI no dictionary holds, an arena-backed literal with no graph view) and the OPTIONAL lanes' correlation rules.
…BY and DISTINCT The group-aggregate key builders and DISTINCT built the per-row view of the operator's normalization on every row, twice per row in GROUP BY. They now read it once per batch.
…g it
same_term canonicalized a mixed pair by encoding the decoded side, a
reverse dictionary lookup per pair. The commonest mixed pair, a batched
lane's encoded subject against a scan's decoded one with novelty pending,
now decodes the encoded side through the graph view, which memoizes it per
subject.
On the 1M-triple bench ledger with novelty pending, an OPTIONAL correlated
on subject and object over 192,000 rows (`?s schema:about ?t . ?s
prov:wasDerivedFrom ?d . OPTIONAL { ?s ?x ?d }`) went from 1,448 ms to
1,245 ms (paired runs on a loaded box). Main answers it in 1,093 ms but
binds `?x` on none of the rows; this branch binds it on all of them.
The numeric-object case runs with the index fresh and with novelty pending: main matched the decoded value of the novelty lane by value and the encoded one of the indexed lane as a term.
A big number's or a vector's encoded key is a handle into an arena scoped to one graph and predicate, counted from 0. A dataset of several graphs of one ledger (`FROM <g1> FROM <g2>`) bound those handles in each member scan and compared, joined, grouped and projected them in a context with no graph to decode them in, so g1's 1.5 and g2's 9.5 under one predicate, both handle 0, were one value: a join paired the wrong rows, MINUS removed the wrong ones, GROUP BY merged them, and projection decoded g2's handle through g1's arena and printed 1.5 for 9.5. A scan whose context is one member of a union of graphs (`with_graph_ref` under a parent with several active graphs) now binds those literals decoded, so a graph-scoped handle never leaves its graph. Scans of a single graph are unchanged; the scan's open event records which ones decode (`arena_literals_decoded`). `same_term` refuses, as an internal error, to compare an arena-backed literal with no graph view to decode it, rather than certify equality or inequality by handle; the nested-loop join's check no longer compares such handles structurally; and the keyed normalizers assert, in debug builds, that no arena handle reaches them unviewed. Member decoding makes all of these unreachable.
Amounts in both graphs of a two-graph dataset share arena handles. Projection, a join, an OPTIONAL, MINUS and GROUP BY over them, with the member scans required to decode, and a single-graph scan required not to.
…e's first key With novelty pending, the first predicate key an operator canonicalized probed the novelty dictionary for every persisted predicate, a per-operator cost that grows with the ledger's predicate count. Each predicate is now probed on its own first key, into a per-operator slot table read with one relaxed load after that.
…ctions A GRAPH scope's scans bind big numbers and vectors as handles into its own graph's arenas. Its exit decoded big numbers only, and only when the scope's graph id differed from the outer context's. So: - a vector left the scope as a handle. Outside, it was printed or matched as another graph's vector, or it reached a union of graphs, where comparing it is an internal error; - in a union whose first graph is the scope's own, the ids match although the union decodes in no graph, so big numbers left undecoded too; - a row seeding the scope carried the outer graph's handles in, and the scope decoded them through its own arenas. ArenaCrossing decodes a handle crossing in either direction through the graph that bound it, unless both sides read one single graph. It replaces the two copies of the big-number exit.
One vector and one big number per graph, each handle 0 of its own arena, so every crossing that decodes through the wrong graph changes the answer: - a vector projected out of GRAPH, alone and inside a union, and a default-graph vector joined against a GRAPH scope (with a JSON-LD twin); - a GRAPH-scope vector joined with a union, and the vector and big-number joins with a union whose first graph is the scope's own; - a default-graph vector and big number carried into a GRAPH scope by an OPTIONAL (with a JSON-LD twin).
A same-ledger SERVICE reads the dataset's first graph of that ledger, which is another graph when the SERVICE sits inside a GRAPH scope. Its body then crosses graphs as a GRAPH scope does. A seeding row's vector or big number was decoded through the target's arenas, and the body's own handles were decoded downstream through the scope's. It now uses the same ArenaCrossing.
Inside GRAPH <g3>, a SERVICE on the same ledger reads the dataset's default graph g2. A vector projected out of it keeps g2's value, and a g3 vector carried into it matches nothing in g2.
…aths Instructions per query against the pre-review head 5c7cb1f found the rework adding per-row work in five places, each now out of the hot loop: - The scan wrapped every late-materialized object binding in a filter for graph-union members. It now decides by object type, and only when the scan is a union member, before building a binding. - MINUS rebuilt its equality normalization on every probe row. It now reads it once per batch and answers an encoded subject before normalizing. - The key normalizers built their dictionaries before looking at the binding. An encoded subject is its own key and now returns first. - OPTIONAL's subject probes and the join's batched lane built the term dictionaries for every row, even for an encoded subject, whose id needs none. They now read them only for a decoded subject. - OPTIONAL kept the full-size columns of output batches between an eighth and a half full, where the pre-review head shrank them. Not shrinking cost more instructions downstream on the hash-join lane (+0.7% median, and bimodal across processes), so the threshold is back to half.
… a no-op The batched R2RML GRAPH path seeds the whole parent batch without decoding its arena handles. That is sound only because it runs without a dataset, where the source scope inherits the outer graph id. A debug assertion now pins the crossing as a no-op there.
| /// (a big number's or a vector's) names its value only within this graph, | ||
| /// so a scan here decodes those literals rather than binding the handle: | ||
| /// the rows leave for a context with no single graph to decode them in. | ||
| pub graph_union_member: bool, |
There was a problem hiding this comment.
This overlaps with #2004 (d38608e79), which landed on main after this branch's base. Main already decodes members of a multi-graph union of one ledger as they scan: DatasetOperator::decode_members sets eager_materialization on each member context. And ScopeExit in graph.rs decodes every encoded binding a GRAPH scope hands to a union. After a rebase, context.rs and dataset_operator.rs merge cleanly, so both mechanisms run (graph.rs conflicts).
I'd keep main's member decode and drop graph_union_member (with the arena_literals_decoded stamp) and CorrelationView::Ledger. Decoding only the arena kinds still sends every other encoded value into a context with no graph view, and several surfaces decode through ctx.graph_view():
- the dateTime comparison in
eval/helpers.rs:757returnsNone; - sort's materialization;
- the new property-path
EncodedSidarm, which matters more now that feat(query): union default graph, per ledger and per query #2004 lets paths traverse a union.
The "Wrong results" table and the "the whole file fails on main" check need re-running against current main. Several of the FROM <g1> FROM <g2> rows should already pass there: the internal error, the tripled join, and the colliding arena handles under projection, join, OPTIONAL, MINUS and GROUP BY.
| // The seeding row's arena handles (big numbers, vectors) name values | ||
| // in the outer graph, and the rows leaving carry handles into this | ||
| // graph's arenas: each is decoded through its own graph on the way. | ||
| let crossing = crate::object_binding::ArenaCrossing::between(ctx, &graph_ctx); |
There was a problem hiding this comment.
Main has nothing for this direction, or for the SERVICE crossing, so these are worth keeping. On rebase I'd fold enter into main's ScopeExit (which now decodes everything on exit into a union) rather than carry ArenaCrossing beside it.
| // arena crossing is a no-op both ways. | ||
| let crossing = crate::object_binding::ArenaCrossing::between(ctx, &graph_ctx); | ||
| debug_assert!( | ||
| crossing.is_noop(), |
There was a problem hiding this comment.
On main this branch is now taken when ctx.explicit_dataset().is_none(), which includes the implicit union-default-graph dataset from #2004. So "this path runs without a dataset" no longer holds. It's probably still a no-op, since a union has no graph view to enter through, but the comment and the assertion need re-checking against that context.
| bound into the scan, whichever form the row carries it in: an encoded subject, | ||
| predicate or literal is decoded into its slot, so no row scans the whole | ||
| predicate. With several graphs of one ledger active there is no graph view, and | ||
| values decode through a view of the ledger's store instead; only an |
There was a problem hiding this comment.
This goes stale if main's member decode stays (see the comment on context.rs): values from several graphs of one ledger arrive decoded.
| let Some(o_id) = | ||
| resolve_subject_id(required_batch.get_by_col(row, object_left_col), ctx)? | ||
| else { | ||
| return declined(); |
There was a problem hiding this comment.
One row this can't key declines the whole window: a literal, an unbound value, an EncodedPid, or an IRI no dictionary holds. The operator then answers one row per-row and calls build_batch again from the next row (:2983), which walks the same prefix to the same failing row. Every row before it goes per-row and the prefix is re-walked each time: about j²/2 row visits, in a window of up to 100k rows.
Example: ?s ex:link ?o OPTIONAL { ?c ex:cites ?o } where ex:link is occasionally a string, or ?o comes from an earlier OPTIONAL and is sometimes unbound. Main didn't coalesce this shape, so this is new.
The join handles the same case per row (join.rs ~1567-1625). Suggest answering the rows that can be keyed in the lane and sending only the failing row per-row (or advancing current_required_row only past the rows answered). An IRI no dictionary holds can be answered "no match" inside the lane. Every lane test is pure-ref, so a mixed-window test would pin it.
| /// bindings, `false` for two of one form (other than NUM_BIG), `None` for a | ||
| /// pair of forms that must be canonicalized to compare. | ||
| #[inline] | ||
| fn one_form_answer(a: &Binding, b: &Binding) -> Option<bool> { |
There was a problem hiding this comment.
Vectors need the same exemption as NUM_BIG here. Vector arenas are per predicate and don't dedupe, and Binding::eq ignores p_id for VECTOR_ID. So handle 0 under ex:embA equals handle 0 under ex:embB, and one vector stored on two subjects gets two handles that compare unequal.
Suggest is_arena_encoded here and in both normalizers (normalize_for_key_cow, binding_to_group_key_normalized), plus p_id in Binding's eq/hash for VECTOR_ID. The keyed-surface side predates this PR (#1957), but the OPTIONAL routing in object_left_to_unify makes it newly reachable.
| Binding::EncodedLit { o_kind, .. } => ![ | ||
| ObjKind::NUM_INT.as_u8(), | ||
| ObjKind::NUM_F64.as_u8(), | ||
| ObjKind::NUM_BIG.as_u8(), |
There was a problem hiding this comment.
A VECTOR_ID object goes to same_term here, which compares handles (see the comment on one_form_answer). ?s ex:embA ?e OPTIONAL { ?s ?p ?e } binds ?p = ex:embB when s got handle 0 in both arenas, and equal vectors under different handles miss. The inner join decodes by value and gets both right, so "OPTIONAL agrees with the inner join" doesn't hold for vectors. At minimum, add VECTOR_ID to this exemption.
| Binding::EncodedPid { p_id } => binary_store | ||
| .and_then(|st| st.resolve_predicate_iri(*p_id)) | ||
| .and_then(|iri| db_for_encode.encode_iri(iri)), | ||
| _ => None, |
There was a problem hiding this comment.
A bound value that lands here is treated as unbound, and the row gets the whole closure. That covers a literal, an Iri that won't encode, an EncodedSid whose resolve fails (the .ok() above swallows it), or no graph view. Example: ?s ex:age ?a . ?a ex:next+ ?n pairs every row with every ex:next pair, where the answer should be no rows.
It's pre-existing, but it's the same class as the table row this PR fixes. Suggest that a bound endpoint that can't be resolved yields no rows, and that a decode failure errors.
This is a lot more than #1973's slowdown needs, and that's deliberate. One IRI reaches operators in several internal forms, and each place that compared terms decided equality its own way. We've been fixing that a site at a time (#1443, #1681), #1320 is the same gap as a slowdown, and OPTIONAL, DISTINCT, GROUP BY, MINUS, EXISTS, VALUES and joins still return wrong answers. So rather than patch one more site, every surface calls one canonical comparison, four copies of one lookup become one, and values that only make sense inside one graph never leave it: one place to reason about and tune. Fwiw, it's one of five PRs taking this approach, with #2006, #2007, #2008 and #2009.
On an indexed ledger, and in some shapes with novelty pending, ordinary queries return wrong results today, silently: OPTIONAL matches dropped, DISTINCT duplicates, GROUP BY groups split in two, a MINUS that removes nothing, an EXISTS that finds nothing, a join that repeats every match, property paths paired with their whole closure, and a SPARQL UPDATE whose WHERE then deletes nothing. Across two graphs of one ledger, a correlated OPTIONAL even fails with an internal error, and a big number or a vector can print as another graph's value. The full list is just below.
Fixes #1973
Fixes #1320
Wrong results this fixes
Reproduced on main (
61b836e9a), indexed unless noted. Each case is pinned by a test with a hand-written expectation, and the test file run against main's sources fails on every one.ex:s1 ?p ?o . OPTIONAL { ?p rdfs:label ?l }ex:s1 ?p ?o . OPTIONAL { ?x ex:mentions ?p }?xex:s4 ex:usesPredicate ?q . OPTIONAL { ex:s1 ?q ?v }?v?d a ex:Doc ; ex:title "Doc 1" . ?s ex:derivedFrom ?d . OPTIONAL { ?c ex:subjectOf ?s }, and the CypherOPTIONAL MATCHtwin?cdropped… ?c ex:subjectOf ?s . OPTIONAL { ?c ?p ?s }?pdroppedSELECT DISTINCT ?x { { ex:s1 ?x ?o } UNION { ?x rdfs:label ?l } }GROUP BY ?xex:s1 ?p ?o MINUS { ?p rdfs:label ?l }MINUS { ?s ex:text ?t }ex:s1 ?p ?o FILTER EXISTS { ?p rdfs:label ?l }(and JSON-LDexists/not-exists){ ex:s1 ?p ?o } VALUES ?p { ex:text }DELETE { ?p rdfs:label ?l } WHERE { ex:s1 ?p ?o . OPTIONAL { ?p rdfs:label ?l } }FROM <g1> FROM <g2>of one ledger?c ex:subjectOf ?s . OPTIONAL { ?s ex:derivedFrom ?d }FROM <g1> FROM <g2>of one ledger?s ex:derivedFrom ex:doc1 . ?c ex:subjectOf ?s?x ex:amount ?v?x ex:amount ?v . ?y ex:amount ?v?xwith every?yOPTIONAL { ?y ex:amount ?v }MINUS { ex:y1 ex:amount ?v }GROUP BY ?vSELECT ?s ?e { GRAPH <g2> { ?s ex:emb ?e } }?a ex:emb ?e . GRAPH <g2> { ?b ex:emb ?e }(x1, y1)ex:x1 ex:emb ?e . OPTIONAL { GRAPH <g2> { ?b ex:emb ?e } }, and its big-number twiny1: x1's handle decoded through g2's arenaFROM <g2> FROM <g3> FROM NAMED <g3>,GRAPH <g3> { ?s ex:emb ?e }GRAPH <g3> { ?s ex:emb ?e } ?t ex:emb ?e; and with<g3>the firstFROM, for vectors and for big numbers(z1, y1)GRAPH <g3> { ?s ex:emb ?e SERVICE <fluree:ledger:…> { ?t ex:emb ?e } }(z1, y1)ex:s1 ?p ?o . ?p ex:parentProp+ ?super?s ex:derivedFrom ?d . ?s ex:next+ ?nwith?sminted in novelty{"@id": "?s", …}, ["bind", "?s", "(iri \"…/s2\")"]Why
On an indexed ledger the scan emits terms in encoded form, and one IRI has two encoded forms:
EncodedPidwhere a pattern reaches it as a predicate,EncodedSidwhere it is a subject or object. Decoded producers (VALUES, BIND, scans over pending novelty) carry it asSid, and the batched lanes bind a subject minted since the last index by its novelty id.Binding'sPartialEqcompares those forms structurally and answersfalseacross them, and several surfaces compared or keyed bindings without first bringing them to one form. The result was the silent wrong answers above.The same gap made a single-triple OPTIONAL correlated on its object scan the whole predicate per required row (#1973). That is the ~13 s
DELETE … WHERE { … OPTIONAL { ?c schema:subjectOf ?s } }reported against solo.This fixes a condition #1967 (
00920483c) introduced. #1967 changed the late-materialization gate from "overlay epoch is 0" to "no live novelty", which is the right rule: it restored fast paths a long-running server had lost after its first write. It also moved those servers onto the encoded lane, the state where these comparisons fail. Before #1967 (ffeeec554) the bugs showed only on a freshly loaded ledger; after it they show in a server's steady state too. Some shapes need no index state at all: the batched join lanes bind encoded ids even with novelty pending.What changed
One canonicalization for term equality:
TermDicts. A query resolves a decoded term to its id in one place. That place checks the persisted dictionary first and the novelty dictionary second, the order every lane assigns ids in. The canonical id of an IRI is its subject id when it has one, persisted or novelty, else its persisted predicate id. A blank node follows the same rule; it is stored as a subject. Every surface that keys or compares terms goes through it:normalize_for_key(DISTINCT, GROUP BY, MINUS, EXISTS, subquery keys);COUNT(DISTINCT);join_key;same_term, its normalization now built once per operator; an encoded subject against a decoded one compares by decoding the encoded side through the view's memo);sameTermfast path. It now compares canonical ids, and hands a miss to the decoding comparison instead of answeringfalse.The four copies of the "persisted, then novelty" subject lookup are consolidated into it: OPTIONAL's subject lane, the join's batched key, the property join and the annotation enumeration lane.
One substitution for every correlated scan:
substitute_binding. It decodes an encoded value into any slot. The join already did this; OPTIONAL's per-row lookup now calls the same function instead of its own copy, which handled only decoded values and an encoded subject. With several graphs of one ledger active (FROM <g1> FROM <g2>) there is no graph view, so values decode through a view of the ledger's store (CorrelationView). That view declines an arena-backed literal, but none reaches it: those arrive decoded (below).Arena-backed literals stay inside their graph. A big number's or a vector's encoded key is a handle into an arena scoped to one graph and predicate, counted from 0. A handle is now decoded, through the graph that bound it, wherever it would otherwise reach a context that reads another graph or none:
FROM <g1> FROM <g2>) binds those literals decoded.ArenaCrossing): those its rows carry out, through its own graph, and those the seeding row carries in, through the outer graph. So does a same-ledger SERVICE that reads another graph than its parent. Before, the exit decoded big numbers only, and only when the two graph ids differed. They match when a union's first graph is the scope's own, although the union decodes in no graph. On the way in, nothing was decoded.arena_literals_decoded), and the tests check both.same_termrefuses to compare an arena-backed literal without a graph view to decode it: it returns an internal error, plus a debug assertion, rather than answer either way by handle. The keyed normalizers assert the same in debug builds. Member decoding and the crossings make both unreachable, and with member decoding disabled the assertion fires.No slot is left free to match every row. The nested-loop join now acts on the substitution outcome:
The join's old unify instructions were built from right-side variables, which exclude every left variable, so they were always empty and are removed. OPTIONAL leaves an undecodable value to its own unify check.
The scan cache key follows the substitution. Every value substitution binds is keyed by its identity, in every position.
OPTIONAL correlated on its object answers with a batched OPST probe (
?s … OPTIONAL { ?c <p> ?s }), stampedoptional_object_probe.for_each_object_probe_match), novelty merge included, and the join's admission gates.When the row binds the subject, an encoded IRI, string or date object is left to the unify check, because the subject seek is already narrow. A numeric object is still substituted.
1and"1"^^xsd:long); that is the join's long-standing rule. The unify check compares terms.Property-path endpoints resolve an
EncodedPid, and resolve anEncodedSidthrough the novelty-aware view. Without that, the row fell into the full-closure branch and was paired with every closure pair.GRAPH ?gextraction gets the sameEncodedPidarm for totality.perf: EXISTS semijoin cache probe declines EncodedSid rows to the generic per-row path #1320. The EXISTS-in-an-expression semijoin cache now probes
EncodedSiddirectly. Before, every row on an indexed ledger declined the cache and ran its EXISTS body as a seeded subplan.OPTIONAL with a left-bound variable in object position: quadratic CPU and ~170 KB of memory per required row (OOM) #1973's memory side. OPTIONAL's per-row path returned a batch per required row with columns sized for 1,000 rows. A batch less than half full is now shrunk to its rows.
Cost of the canonical form. The details are folded just below.
Cost of the canonical form, in detail
Decision for review
How OPTIONAL correlates a numeric value. OPTIONAL now uses the substitution the inner join uses. That substitution matches a numeric by value across numeric datatypes:
OPTIONAL { ?s ?p ?n }with?n = 1(xsd:integer) also matches"1"^^xsd:long.Consequences:
sameTerm(1, "1"^^xsd:long)is true on main and on this branch. That is a pre-existing deviation from the spec, unchanged here.As maintainer I favor this rule, for consistency with the inner join, but it's still offered to reviewers: please ratify or push back.
numeric_object_correlated_with_a_bound_subject_matches_by_valuepins it in both index states.Performance
On #1973's own shapes the change is large. On the reported 1M-triple ledger the SELECT with the OPTIONAL goes from 1.166 s on main to 0.012 s in a server's steady state, and the same OPTIONAL over every chunk, on an indexed ledger, from 76.2 s and 11,547 MiB to 0.387 s and 433 MiB. #1973's synthetic goes from 1.79 s and 3,860 MB to 0.058 s and 58 MB. Those ran on a shared, loaded machine, so the tables are folded below with the rest of the local numbers.
Quiet box. EC2 c7i.4xlarge, the fat-LTO bench profile, base and head in interleaved rounds; a change counts as a win or a loss only when the base and head ranges don't overlap and the median delta exceeds the bench's budget (5% at
small). At the final head,83900eff1, all 9 scenarios are stable against main. The largest moves areunbound_filter_operand+1.4% andsingle_triple_probe+0.7%, both slower in all 3 rounds but under the budget;minus_countis −3.0% (faster in all 3), q9 −0.1%, and the rest are within ±1%. Against the head before Claude's review,5c7cb1f99, every scenario is within ±2.5%. The +1.8% to +6.3% an earlier run measured atfdc49a7ca, before the perf pass, is gone.Instructions and cycles. Per-iteration
perf staton the same box (x86) puts instructions at parity with main on 5 of the 7 keyed scenarios (−0.8% to +0.4%), andminus_count3.4% below it (−2.1% cycles).not_exists_after_optionalandunbound_filter_operandrun equal or fewer instructions at +1.9% and +2.4% cycles, which is code placement, not added work.object_correlatedis real work: +1.8% instructions and +1.3% cycles, the batched object lane's overhead per required row (2 allocations per required row, already there at5c7cb1f99), on the very shape this PR takes from 1.166 s to 0.012 s above. That's a known small cost, and trimming it is a possible follow-up.The quiet-box tables, and the earlier run at
fdc49a7caThe final head against main: GitHub's merge of this PR,
0a5464749, whose tree is83900eff1's, againstmainat61b836e9a. Three rounds, with the three binaries (main, this PR and5c7cb1f99) each run once in every position:Per-iteration instructions and cycles:
perf statover two run lengths, the difference divided by the measured iterations, 3 alternations. "pre-review" is the head before Claude's review:The earlier run, at
fdc49a7ca, before the perf pass. All 9 scenarios were stable by the rule, but the head was slower in all 3 rounds on 6 of them:bsbmq9 +6.3% (over budget on its median, but its ranges overlapped),unbound_filter_operand+3.4%,minus_count+3.3%,not_exists_after_optional+3.3%,multi_pattern_hash_join+2.3% andobject_correlated+1.8%.single_triple_probewas +1.4% (slower in 2 of 3), andvalues_starandwhole_graph_aggwere flat. The head before Claude's review had been flat on q9,minus_count,not_exists_after_optionalandmulti_pattern_hash_joinin an earlier run on the same box, so the rework had cost about 2–6% in time on the keyed OPTIONAL, MINUS/EXISTS and GROUP BY paths. At that point q9 was within 0.1% of main in instructions (at9643dc75a), so instruction counts alone couldn't settle the time cost.The perf pass,
f99928dd4, takes the rework's added per-row work off the keyed paths. Counted in instructions per query on the benches' own fixtures (the table below), it's at parity with5c7cb1f99on all seven keyed scenarios: five take fewer instructions than it, the other two are within the measurement spread of it (+0.05% and +0.2%), and several are now below main too (minus_count−5.9%, q9 −1.5%). Allocations per query match5c7cb1f99.Instructions per query on the criterion scenarios. These are counted on the benches' own fixtures and queries: dev-fast build, Apple M4, eight processes per build, medians, process-to-process spread ±0.2–0.8%. Allocations per query match this PR's first head (
5c7cb1f99, before Claude's review).q9minus_countnot_exists_after_optionalmulti_pattern_hash_joinunbound_filter_operandsingle_triple_probeobject_correlatedThe
object_correlatedgap to main, and its 2 extra allocations per required row, were in that first head too.Arena values crossing a GRAPH scope. Decoding at a GRAPH scope's exit now also materializes vectors (big numbers were already decoded there), and a seeding row's vectors and big numbers are decoded on the way in. The cost is confined to arena-backed bindings that cross between two different graphs; a projected one was decoded for output anyway. Not measured.
The local numbers, with their method (a shared, loaded machine)
dev-fast builds of main before #1967 (
ffeeec554), main (61b836e9a) and this branch, on a shared macOS machine (load 8–19, other sessions building). Medians. "Paired" means both builds served side by side with repetitions alternated, and every query's full sorted result compared between the two before timing.The reported shape. 1M triples: 300 documents × 640 chunks, plus 23,100
schema:subjectOflinks. The WHERE yields 3,230 rows. Server over HTTP, 2 rounds × 3 queries:¹ Includes the server's first write after loading, about 0.15 s on every build.
#1973's synthetic. 20,000 concepts, 5,000
skos:broadertriples, 24,500 rows. The server is restarted for each run, and RSS is the server process's peak:The same OPTIONAL over every chunk (192,000 driving rows, 193,356 result rows), restarted server, 2 runs:
The lane's window was chosen against two measured extremes: answering each required batch as it came, and coalescing the whole driving side up to the 512K cap. They were within 6% of each other on time and 30–40 MiB apart on peak RSS. The 100,000-row window bounds the buffering at the join lane's own flush size.
OPTIONAL shapes, paired (1M ledger, 7 rounds):
?p ?o, OPTIONAL?c subjectOf ?s(rows)?s schema:text ?tover every link, COUNTOPTIONAL { ?s ?x ?d }over every chunk, COUNT?c schema:name ?t, one document² Main's answer is wrong here: it binds
?xon 0 of the 192,000 rows, this PR on all 192,000 (every chunk hasprov:wasDerivedFrom ?d). The extra time is the work of matching: the batched joins bind?sand?dencoded while the OPTIONAL's scan decodes, and each pair is compared across forms.Keyed surfaces, paired (1M ledger, 9 rounds). Change vs main without / with
FLUREE_DISABLE_QUERY_FAST_PATHS:Loaded box. This machine's run-to-run noise is about 20%, so the indexed-lane deltas of +1–4% (and +7% on
DISTINCT ?pin one configuration) are within it; across three earlier builds of this branch the same queries ranged from −1% to +7%. The cost they would reflect is per predicate-valued key: one relaxed atomic load to map a predicate binding to its subject id when its IRI is also a subject. With novelty pending, predicate keys no longer pay a subject-dictionary lookup per row, so those queries run about 5× faster.Novelty-predicate resolution on a ledger with 5,001 predicates, novelty pending, paired (resolved for every predicate per operator → per predicate met):
DISTINCT ?pover one subject (26 predicates met) 0.600 → 0.538 ms;GROUP BY ?pover one subject 0.449 → 0.421 ms;GROUP BY ?pover every triple, which meets every predicate either way, 8.22 → 8.37 ms (41 rounds). The per-operator cost removed is about 0.06–0.08 ms per 5,000 predicates, and it grew linearly with the predicate count.First query after a restart (the predicate memo's first use), fresh server per run, 5 runs:
GROUP BY ?p0.057 s on main, 0.059 s here;DISTINCT ?p0.054 s, 0.055 s.Not re-run on this machine at this head: the criterion benches (
query_hot_optional,query_hot_values_star,query_hot_negation_count,query_hot_whole_graph_agg,query_overlay_matrix,query_hot_bsbm). The quiet box ran nine of their scenarios at the final head (above), and all 36 at the head before Claude's review.Before ready
5c7cb1f99reached (f99928dd4), and time stable against main on the quiet box at the final head (83900eff1), both under Performance.Tests
Every case in the test file checks a hand-written expectation: the OPTIONAL and equality cases in three index states (loaded fresh, the server steady state after an index adoption, and an unrelated write pending); the novelty shapes where a batched lane binds a novelty id or a blank node while a scan decodes the same term; several graphs of one ledger; property-path endpoints; where the new OPTIONAL lane must and must not fire; the numeric rule; BIND onto a bound variable; and #1320's seeded-subplan count. Each fix was also shown red without it and then restored. The case list and that table are folded:
The test file, case by case
All in
fluree-db-api/tests/it_query_optional_encoded_bindings.rs(ingrp_query). Every case checks a hand-written expectation.EncodedPid, predicateEncodedSid;EncodedSid, plain and after a batched join;EncodedLit, string and integer;OPTIONAL MATCHplus a count over it.=and a trailing VALUES, with JSON-LD MINUS and selectDistinct twins;FROM <g1> FROM <g2>):GRAPH <g2>(with a JSON-LD twin), and a default-graph vector joined against a GRAPH scope;GRAPH <g3>, and joined with the union;GRAPH <g3>that reads g2: a vector projected out of it, and one carried into it.GRAPH ?gname.optional_object_probestamp on the same ledger:?c; and a non-root policy, which leaves the lane on when it cannot touch the predicate and keeps it out when it can.Shown red without their fix, then restored
Shown red without their fix, then restored (needles grepped after each restore):
y1, and the debug assertion on the three union joinsy1object_probe_columnforced toNonexsd:longmatch missingRun against main's sources, the whole file fails: all 25 tests. The result-level failures are the rows of Wrong results this fixes; the rest are routing assertions for a lane main does not have, and the #1320 subplan counts.
Not changed here (follow-ups)
Binding'sPartialEqstays form-sensitive by design, since it has no store. Every keyed surface now canonicalizes throughTermDicts, but nothing stops a new surface from hashing raw bindings. Its design, sites and effort are folded below this list.SidandIriforms (a VALUES constant and aBIND(IRI(…)), say) still key apart. This is pre-existing and needs a namespace-table canonicalSidfor unknown IRIs.EncodedSid/Sidkeys only. AnEncodedPidrow declines to the per-row path: correct, but not batched.object_correlated: the batched object lane's per-required-row overhead (2 allocations per required row, +1.8% instructions on the quiet box) could be trimmed.The
TermKeyfollow-up: design, sites and effortTermKeynewtype whose only constructor isTermKey::of(&Binding, &EqualityNorm). Then either removeHashfromBinding, or add adisallowed_typeslint on maps and sets keyed byBinding/Vec<Binding>. Every keyed site must then name it, and a raw-form site cannot be written.eval/rdf.rsandeval/compare.rs, and BIND.Overlap with #2006
git merge-tree --write-treeof this branch and #2006 at its headc67dc3516:fluree-db-query/src/join.rsandfluree-db-query/src/optional.rs. Both are fix(query): evaluate grouped SELECT expressions once per group #2006'sGroupedarms in the two per-slot substitution copies this PR replaces with the sharedsubstitute_binding. The resolution keeps this PR's side and moves the arm (below).filter.rs,group_aggregate.rs,subquery.rsandgrp_query.rs. (docs/query/sparql.mdalso auto-merges, against main's changes since fix(query): evaluate grouped SELECT expressions once per group #2006's base; this PR does not edit it.)grouping.rs,lower.rs,operator_tree.rs, and the SPARQLselect.rsandvalidate/projection.rs), edits no file this PR edits.binding_to_group_key_normalized,normalize_for_key,same_term,join_key,unify_check).Semantic seams for whichever PR merges second:
Groupedarm moves intosubstitute_binding, in all three positions.debug_assert!andUnmatchable; in the subject and predicate positions it isUnmatchable.Unmatchablewith no match, so a grouped value no longer matches anything in either. That was the hazard fix(query): evaluate grouped SELECT expressions once per group #2006'sErrcloses in the join.Errbelongs insubstitute_binding'sGroupedarms, where the three positions and both operators get it at once.RowPredicate(FILTER's evaluator shared with HAVING) makes this PR's perf: EXISTS semijoin cache probe declines EncodedSid rows to the generic per-row path #1320EncodedSidsemijoin probe reachable from HAVING. No test here coversHAVING EXISTSover an encoded group key.Solo impact
Query results change on indexed ledgers, and on ledgers with novelty pending; these are corrections:
FROM <g1> FROM <g2>) with a correlated OPTIONAL no longer fails with an internal error, and its joins no longer repeat each match. Its big-number and vector values are compared, grouped and printed by value, where one graph's value could stand in for another's.GRAPH <g>could print another graph's vector, and a value carried in or out could match another graph's value.No API, wire format or index format changes. The store gains a per-predicate memo, filled on each predicate's first use. A transaction's WHERE with novelty pending still runs the OPTIONAL row by row (see the follow-ups), so the reported delete in that state is as fast as on main, not faster.