transform: make literal constraint detection index-directed - #38515
transform: make literal constraint detection index-directed#38515frankmcsherry wants to merge 4 commits into
Conversation
`LiteralConstraints` converts a filter predicate to disjunctive normal form before consulting any index, then reads lookup values off the disjuncts. The expansion is multiplicative in the arity of every disjunction in the predicate, including disjunctions over columns that no index covers, so a query pairing an `IN` list with any other `OR` can expand far past what its answer requires. A guard bails out above a predicate size of 1000 nodes, and because it measures the predicate before expanding rather than the expansion it is about to build, it neither bounds the result nor admits cases it could afford. Past the guard the transform silently declines the index, so `shop_id = X AND sku_code IN (<500 values>)` on an index over `(shop_id, sku_code)` falls back to a full scan. Ask the question per candidate index instead. For a given list of key expressions, one pass over the predicate yields the values those expressions may take, and predicate structure that says nothing about them costs a single visit and contributes nothing. The answer is a disjunction of conjunctive boxes, where a box bounds each key field independently, so `a IN (..) AND b IN (..)` is one box denoting the cross product rather than a disjunct per pair, and the box count is bounded by the number of distinct key tuples the predicate admits rather than by how its disjunctions are arranged. Nothing is rewritten in order to be read, which removes the preparation and its lossy undo along with the heuristic that chose between them. Constraint removal becomes a per-predicate test: a predicate that is exactly a bound on the key can go, which is sound because the lookup values intersect what every predicate implies, so a retained predicate can only narrow the key further. Two behaviors that the normal form used to provide as side effects are kept explicitly. Contradictory disjuncts are pruned by a bottom-up pass that reuses the same bounds, covering contradictions that span two predicates. A literal `null` or `false` now reads as unsatisfiable, which is sound for the `AND`/`OR` trees that reach us because neither `min` nor `max` distinguishes `false` from `null` when asking whether a result is `true`, and a filter drops a `null` row just as it drops a `false` one. Adds sqllogictest coverage for an `IN` list on a column the index does not cover, for several such lists at once, for a covered list long enough to exceed any workable size guard, and for the distinction between a cross product that is inherent to a compound key and one that is incidental.
Two plans improve.
`WHERE a = NULL OR a = 2` reduces to a `null` literal disjoined with a literal
equality. A filter drops a `null` row just as it drops a `false` one, so the
predicate is equivalent to `a = 2` and the equality can be served by a lookup
rather than a full scan with a residual filter. The paired data query is
unchanged, so the `null` row in the table is still not returned, which is what
the case was written to check.
The join equivalence `{#0, #1, #2}` over a single input lowers to the null-safe
pairwise equalities with one mapped expression, rather than the shape the old
normal-form round trip left behind. There is no index on that source at all, so
the previous expectation recorded only the distortion: the expansion ran, found
nothing to use, and the node-count heuristic then preferred the tangled result
over the predicate it started from.
antiguru
left a comment
There was a problem hiding this comment.
The design is right and the deletion is earned. KeyBounds answers the question the transform actually has, the DNF machinery genuinely was answering a different one, and I checked both golden changes by hand: the join-equivalence rewrite is the null-safe pairwise form and agrees with the old one on every null combination, and a = NULL OR a = 2 really is equivalent to a = 2 as a filter, so serving it from a lookup is correct.
One blocking correctness bug, then smaller things.
Blocking: wrong results from and() widening
remove_literal_constraints drops each predicate whose own bounds are exact, on the precondition stated in its NOTE: the lookup values intersect what every predicate implies. KeyBounds::and breaks that when the product of two box lists exceeds MAX_BOXES. It widens one operand to its bounding box and clears exact on the result, but the operands keep their own exact, and removal never consults the conjunction's flag.
With an index on (a, b):
WHERE (a,b) IN ((0,0),(1,1),...,(39,39))
AND (a,b) IN ((0,1),(1,2),...,(39,40))40x40 = 1600 > MAX_BOXES, so the first list collapses to {a: 0..39} x {b: 0..39}, the second survives intact, both predicates report exact and are removed, and the plan looks up 39 key values for a predicate whose true answer is zero rows.
Confirmed against this branch with a unit test on KeyBounds alone, asserting the property removal depends on (the lookup values of a conjunction fall inside the bounds of every exact conjunct):
test key_bounds::tests::exact_conjunct_contains_lookup_values ... FAILED
lookup values not implied by the predicate:
{[0,1], [1,2], [2,3], ... [38,39]} (39 values)
Both assert!(exact) lines preceding it pass. The test is on claude/pr-38515-review-iv2qom (commit 2f38d01) if it is useful.
About 33 pairs per list is enough to trip it, and (a,b) IN (...) AND (b,c) IN (...) on a three-column index is the same shape and more likely to show up in generated SQL. Note the other two consumers are fine: is_unsatisfiable and the lookup values only ever over-approximate, and widening preserves that. It is specifically the removal step. Smallest fix is a widened bit out of KeyBounds::conjunction, gating remove_literal_constraints.
Behavior worth deciding rather than inheriting
Error suppression widens. prune_inner rewrites any unsatisfiable subtree to false, and empty replaces the whole relation, so WHERE a IN (1,2) AND a IN (3,4) AND (1/x > 0) returns empty where the division would have errored. remove_impossible_or_args did the same for a whole OR arg, so it is not new in kind, but it now also reaches contradictions spanning two separate predicates, which the old pass only caught after DNF. Worth a line in the "Behaviors kept explicitly" section.
Literal identity is Row byte identity. RowRef's Ord carries "Warning: These order by the u8 array representation, and NOT by Datum::cmp". munge_numeric normalizes -0 but not scale, so n = 1.0 AND n = 1.00 intersects to the empty set and the relation is declared empty, though 1.0 = 1.00 is true. Pre-existing (the old all_unique() check reached the same verdict) and not this PR's to fix, but the new representation leans on set intersection much harder, so the assumption deserves a line in the module doc.
Cost
The table is single-column IN lists, which is the case the new design is fastest on. Two shapes it does not cover:
recommended_keyis now computed on everyresult.is_none(), including when the Get has no indexes at all. That is a full bounds pass overconstrained, the widest key in play, on the common path. The old code computed it inside theUnusableTooWidearm; gating onindex_matches.iter().any(...)would restore that.normalizeruns on everyproduct, and its field loop is0..aritywithKeyBoxcomparisons that areO(arity). For(a,b) IN (1000 pairs) AND c1 = 1 AND ... AND c48 = 48, each of the 48 folds re-sorts and re-groups 1000 boxes across 50 fields. I did not measure this, so treat it as a shape to benchmark rather than a finding, but the old 1000-node guard is gone and nothing replaced it, so it is worth knowing where the new ceiling sits.
Smaller
lookup_valuesopens withassert!(self.bounds_every_field()), and the notice path reaches it viabounded_fieldscomputed against a different key list. I convinced myself the invariant holds (projection, and both widenings, preserveSome-ness), but an optimizer panic is a rough failure mode for a cross-call invariant. ReturningNonewould be safer.literal_constrained_exprs's doc says it is used for "cheaply rejecting an index whose key mentions an expression the predicate never pins, and recommending a key".match_indexnever consultsconstrained; only the recommendation exists.exactmeans "equivalent as a filter", whichleaf's NOTE says but the field doc onexactdoes not. Worth stating where the definition lives, since it is what licenses treating a literalnullas unsatisfiable.MAX_LOOKUP_VALUESis far more permissive than the old size guard (which bailed around 140 pairs), so I could not find a regression window from adding it.
CI
buildkite/test/doctests is red on 729d3d0. That step is ci/test/lint-doc.sh: bin/doc --document-private-items plus ci-closed-issues-detect, cargo about, helm unittest, bin/pydoc. I checked the closed-issue detector against the new database-issues/issues/1924 reference, and it only fires on comment blocks containing TODO/reenable/in the future, none of which that block has, so rustdoc looks more likely. I did not reproduce it.
I also could not enumerate all 81 statuses through the API, so I do not know whether the sqllogictest steps that own literal_constraints.slt are green. Given the note about the expectations predating the rebase, that is the first thing I would check.
Generated by Claude Code
| // Widen before multiplying, so the product stays inside the budget. Only the wider | ||
| // operand is widened, because widening both would discard structure that | ||
| // `MAX_BOXES` can still afford to keep. | ||
| let (left, right, exact) = if self.boxes.len() * other.boxes.len() > MAX_BOXES { | ||
| if self.boxes.len() >= other.boxes.len() { | ||
| (Self::widen(&self.boxes, arity), other.boxes, false) | ||
| } else { | ||
| (self.boxes, Self::widen(&other.boxes, arity), false) | ||
| } | ||
| } else { | ||
| (self.boxes, other.boxes, self.exact && other.exact) | ||
| }; |
There was a problem hiding this comment.
This widening is where the removal precondition breaks (see the review body for the failing case).
Widening clears exact on the result, but the operands keep theirs, and remove_literal_constraints only ever looks at a predicate's own exact. So the accumulator gets replaced by its bounding box and the predicate that produced it is dropped from the filter without the lookup enforcing it.
Suggest carrying a widened bit out of KeyBounds::conjunction and gating remove_literal_constraints on it. is_unsatisfiable and the lookup values themselves stay sound under widening, so nothing else needs the bit.
Separately: this widens the wider operand. Widening the narrower one is equally within budget (the product is then max(len) <= MAX_BOXES, by the invariant both branches already maintain) and keeps strictly more structure. With 1000 boxes against 2, the current choice discards the 1000-way partition to keep a 2-way one. The comment defends the choice against widening both, which isn't the alternative in question.
Generated by Claude Code
| let (map, predicates, project) = mfp.as_map_filter_project(); | ||
| let kept = predicates | ||
| .into_iter() | ||
| .filter(|p| !KeyBounds::extract(p, key).exact()) |
There was a problem hiding this comment.
The NOTE above this function states the precondition exactly right:
the lookup values are the intersection of what every predicate implies
KeyBounds::and violates it when it widens (see the comment on key_bounds.rs). A per-predicate exact is not enough on its own; it needs pairing with "and no widening happened while folding the conjunction".
Also on the doc: (f1 = 3 AND f2 = 5) OR (f1 = 7 AND f2 = 5) with key f1 is described as not removable "because the residual f2 = 5 is entangled with the f1 constraint". It isn't entangled, the expression is f1 IN (3,7) AND f2 = 5, and the code being deleted here did remove it. In practice undistribute_and_or factors this shape upstream before the transform sees it, so I don't think anything regresses, but the doc explains a capability loss as a necessary limitation.
Generated by Claude Code
`bin/doc` runs rustdoc with `-D warnings`, which rejects an intra-doc link from a public module to a private one even under `--document-private-items`, since the link would break without that flag. Refer to the `key_bounds` submodule in prose instead. Also drop a reference to a closed issue from a comment on a new test. `bin/ci-closed-issues-detect --changed-lines-only` flags those, and the case the reference belonged to already cites it a few lines up.
Removing a predicate that exactly bounds the index key is sound only because the lookup values are the *intersection* of what every predicate implies, so they fall inside the removed predicate's own bounds. `KeyBounds::and` breaks that when a box product exceeds its budget: it widens one operand to the box containing it, which yields a superset of the intersection. The operands keep their own `exact` flags, and removal consulted only those, so both predicates of a two-list conjunction could be dropped against lookup values that neither of them admits. With an index on `(a, b)` and two disjoint 40-element pair lists, the plan looked up 39 keys with no residual filter and returned rows for a predicate whose answer is empty. Track widening as its own bit rather than folding it into `exact`. `exact` has to stay per-predicate for removal to work at all, since a conjunction containing one opaque predicate is not exact yet its other conjuncts are still removable. Widening is a property of the conjunction, so removal now refuses outright when the bound it is working against widened anywhere. Keeping the filter is always correct, as the lookup then merely over-approximates. The other two consumers were never affected: lookup values and `is_unsatisfiable` only ever over-approximate, which widening preserves. Adds a sqllogictest asserting rows rather than a plan, since over-approximating the lookup is allowed and returning a row is not, and unit tests on `KeyBounds` for the widened and non-widened conjunctions. Also skips the no-op passes in `normalize`. Merging on a field that every box agrees on cannot combine anything, because two boxes sharing a group would then agree everywhere and have been deduplicated already. Wide keys are mostly pinned to single values, so this is nearly all of them: for a 1000-value tuple list conjoined with 48 equalities, `LiteralConstraints` drops from 245ms to 49ms, against 23ms before this branch, where no index was selected at all. Gates the "index too wide" recommendation on some index actually being too wide. It runs a bounds pass over the widest key in play, and a Get with no indexes should not pay for it. Replaces an assertion in `lookup_values` with a `None`, since a panic is a poor failure mode for an invariant that spans calls.
|
Confirmed the blocking bug, fixed it, and worked through the rest. Thank you — this was a real wrong-results bug, not just a bad plan. The bugReproduced as a data query before touching anything. With an index on The plan had no residual filter at all, so 39 widened lookup values went straight to the output. Your diagnosis is exactly right: Fixed with the
The gate is deliberately conservative: any widening anywhere refuses all removal, even though removal of the un-widened operand stays sound (the product is still contained in it). Buying that back means tracking containment per predicate through the fold, which did not seem worth the subtlety for a case that is already degrading gracefully. Regression tests at both levels. The sqllogictest asserts rows rather than a plan, since over-approximating the lookup is allowed and returning a row is not. The unit tests assert normalize costYou were right to flag it, and it was worse than you guessed. Your exact shape does not plan — a 1000-element tuple list hits a recursion limit in the SQL desugarer, before the optimizer — so I measured what does,
A 10x regression at 200 pairs, now gone. The fix is that merging on a field every box agrees on cannot combine anything: two boxes sharing a group would then agree on every field and have been deduplicated already. A wide key is mostly pinned to single values, so that is nearly every field — 48 of 50 here. End-to-end the shape is now 404ms against main's 425ms, while producing an index plan where main selects none. Single-column lists got slightly faster too. I have not found the ceiling, only moved it. The remaining cost is Also fixed
CI
On the closed-issue detector you were right and I was wrong. I had removed the All five |
|
(Triggered a Nightly: https://buildkite.com/materialize/nightly/builds/18196) Edit: Ehh, tons of Nightly failures, but so far all look unrelated at first glance. I'm triaging them now. Edit 2: All Nightly failures are unrelated. I've opened various issues, and extended some ci-regexp. |
ggevay
left a comment
There was a problem hiding this comment.
Thank you! LGTM, just minor comments.
| //! NOTE: Literal values are compared as `Row`s, and `RowRef`'s `Ord` orders by the packed | ||
| //! byte representation rather than by `Datum::cmp`. Two literals that compare equal in SQL | ||
| //! can therefore land in different set elements: `munge_numeric` normalizes `-0` but not | ||
| //! scale, so `n = 1.0 AND n = 1.00` intersects to the empty set even though `1.0 = 1.00`. | ||
| //! The verdict is the same one the surrounding transform has always reached, but the set | ||
| //! arithmetic here leans on it much harder, so treat byte identity as the definition of | ||
| //! literal equality for these purposes. |
There was a problem hiding this comment.
This example is wrong: push_datum reduces numerics before writing them (row.rs:2078-2083), so 1.0 and 1.00 are byte-identical, and on v26.39 n = 1.0 AND n = 1.00 plans to lookup value=(1) and returns both rows. Floats are the live case: Float64 packs raw bits (row.rs:1986-1989), so f = 0.0 AND f = '-0'::float8 intersects to empty and returns 0 rows where 2 are correct (SQL-452, fix in #37482).
| //! NOTE: Literal values are compared as `Row`s, and `RowRef`'s `Ord` orders by the packed | |
| //! byte representation rather than by `Datum::cmp`. Two literals that compare equal in SQL | |
| //! can therefore land in different set elements: `munge_numeric` normalizes `-0` but not | |
| //! scale, so `n = 1.0 AND n = 1.00` intersects to the empty set even though `1.0 = 1.00`. | |
| //! The verdict is the same one the surrounding transform has always reached, but the set | |
| //! arithmetic here leans on it much harder, so treat byte identity as the definition of | |
| //! literal equality for these purposes. | |
| //! NOTE: Literal values are compared as `Row`s, and `RowRef`'s `Ord` orders by the packed | |
| //! byte representation rather than by `Datum::cmp`. Packing canonicalizes numerics, but writes | |
| //! a float's raw bits, so `f = 0.0 AND f = '-0'::float8` intersects to the empty set even | |
| //! though the two literals are equal in SQL (https://linear.app/materializeinc/issue/SQL-452). | |
| //! Byte identity is therefore the definition of literal equality for everything in this module. |
|
|
||
| /// Largest number of key values we will ask an index to look up. Above this a full scan | ||
| /// plus filter is the better plan, and the constant collection would itself be a burden. | ||
| const MAX_LOOKUP_VALUES: usize = 100_000; |
There was a problem hiding this comment.
How was the 100_000 determined? I guess the "Above this a full scan plus filter is the better plan" is not universally true, right? Also, could you make it a system var, if it's not too hard?
(The old code could do ~330.)
| // Disjuncts that contradict themselves are dead weight in the filter, and | ||
| // pruning them needs no index. Done before detection so that detection sees the | ||
| // simplified predicate. | ||
| let (pruned, empty) = Self::prune_unsatisfiable(&mut probe_mfp, &constrained); |
There was a problem hiding this comment.
This pass keys on every literal-pinned expression, so its arity is the number of distinct equalities in the predicate (50 in the (a,b) IN (...) AND c1 = 1 ... AND c48 = 48 shape from your table), and the work per predicate fold scales with that arity: leaf tries every key field with a clone on each miss (scalar.rs:384), and intersect clones every bounded field's set for every box (key_bounds.rs:332).
Only an expression pinned to two or more distinct literals can ever empty a field (a field with one value intersects to itself, and the false/null/impossible leaves are bottom regardless of key), so the pruning key can be restricted to those, which makes the pass scale with the number of contradiction candidates instead. constrained stays as is for the recommendation.
The comment at 249-251 ("a Get with no indexes at all must not pay for it") is contradicted by this line, since it is the same-arity pass.
| let bounds = LiteralConstraints::key_bounds(mfp, &usable_subset); | ||
| let Some(literal_values) = bounds.lookup_values() else { | ||
| return; | ||
| }; |
There was a problem hiding this comment.
Cap what goes into the notice independently of MAX_LOOKUP_VALUES: fmt_message renders every value and the string is persisted into mz_optimizer_notices for index and MV dataflows. A dozen values plus a count is enough for a hint.
| /// | ||
| /// `predicates` is an implicit conjunction, as an MFP's predicate list is. | ||
| /// | ||
| /// This is what turns `a IN (1, 2) AND a IN (2, 3, 4)` into `a = 2`, and it applies whether |
There was a problem hiding this comment.
prune_unsatisfiable does not turn a IN (1, 2) AND a IN (2, 3, 4) into a = 2: neither list nor the conjunction is unsatisfiable, so changed stays false, and the single lookup value comes from detect + remove. An example this function rewrites is (a = 1 AND a = 2) OR b = 3 -> false OR b = 3.
FYI, on a table with no index main simplifies the IN-list pair to Filter (a = 2) (via the old DNF) and this branch leaves both lists; fine, but the doc should not claim otherwise.
| for b in &self.boxes { | ||
| let sets = b.iter().map(|f| f.as_ref()).collect::<Option<Vec<_>>>()?; | ||
| for combination in sets.into_iter().multi_cartesian_product() { | ||
| values.insert(Row::pack(combination.iter().map(|r| r.unpack_first()))); | ||
| if values.len() > MAX_LOOKUP_VALUES { | ||
| return None; | ||
| } | ||
| } |
There was a problem hiding this comment.
nit: the cap tests the deduped set, not the iterations; overlapping boxes can enumerate far more than MAX_LOOKUP_VALUES combinations before returning. Counting iterations is the same line.
| /// dropping it loses nothing. `(f1 = 3 AND f2 = 5) OR (f1 = 7 AND f2 = 5)` with a key | ||
| /// of just `f1` is not removable, because the residual `f2 = 5` is entangled with the | ||
| /// `f1` constraint. `f1 IN (3, 7) AND f2 = 5` is: the first predicate goes, the second | ||
| /// stays. |
There was a problem hiding this comment.
nit: the example is not entangled (f2 = 5 is the same in both disjuncts; the old code removed it) and the mechanism is different: and clears exact on any non-key conjunct (key_bounds.rs:221), so the genuinely entangled (f1 = 3 AND f2 = 5) OR (f1 = 7 AND f2 = 555) is declined for the same reason. Upstream undistribute_and_or usually factors the first shape, which is why no golden moved; worth saying rather than explaining a capability gap as a necessity.
| /// Whether the derivation widened anywhere, which makes the bound a strict | ||
| /// over-approximation of what the predicate implies. | ||
| /// | ||
| /// Removal reasons about *containment*, not just exactness: dropping a predicate that is | ||
| /// exactly a bound on the key is sound only because the lookup values are the | ||
| /// intersection of what every predicate implies, so they fall inside the dropped | ||
| /// predicate's own bounds. Widening produces a superset of that intersection instead, so | ||
| /// a lookup value need no longer satisfy a dropped predicate, and the rows it finds would | ||
| /// go unfiltered. Callers that remove must refuse when this is set. |
There was a problem hiding this comment.
nit: the containment argument is stated here, in the field doc (88-95), the module NOTE (29-33) and the remove_literal_constraints NOTE (337-343). Let this one own it and point the others here. Same for the "structure the index does not cover costs one visit" fact (kb 14-15, 26-27, lc 15-17, 185-188) and the "over-approximation is safe" fact (kb 29-33, 421-422, lc 342-343).
And the boxes.len() <= MAX_BOXES invariant that keeps product bounded is load-bearing but unstated; one line on the field.
| if boxes.is_empty() { | ||
| // "Never satisfied" needs no widening, and widening it would be wrong. | ||
| return Vec::new(); | ||
| } |
There was a problem hiding this comment.
nit: unreachable. and widens only when the product exceeds MAX_BOXES (impossible with a zero factor) and disjunction only when the count does. Either drop the branch or say it is defensive.
| # The same shape under the budget, where the intersection is exact and stays empty. | ||
|
|
||
| query II rowsort | ||
| SELECT * FROM pairs WHERE (a,b) IN ((0,0),(5,5)) AND (a,b) IN ((0,1),(5,6)) |
There was a problem hiding this comment.
Tests worth adding: the MAX_LOOKUP_VALUES cap (unit), disjunction-level widening (unit, pair_list(2000)), (a = NULL AND b = 'l1') OR a = 2 (main full-scans with null AND left in the filter, this branch should look up (2)), and a pruned-only case on a table with no index.
LiteralConstraintsconverts a filter predicate to disjunctive normal form before it consults any index, then reads lookup values off the resulting disjuncts. The expansion is multiplicative in the arity of every disjunction in the predicate, including disjunctions over columns that no index covers. A guard bails out above a predicate size of 1000 nodes, and because it measures the predicate before expanding rather than the expansion it is about to build, it neither bounds the result nor admits cases it could afford. Past the guard the transform silently declines the index.So this loses the index entirely:
with a notice recommending an index on
(shop_id, has_dual_price_record), which is an artifact of the bail-out rather than advice. Under the guard it works but pays for structure it does not use: an unrelated two-wayORelsewhere in the predicate halves theINlist length the query is allowed to have.This makes the detection index-directed. For a given list of key expressions, one pass over the predicate yields the values those expressions may take. Predicate structure that says nothing about them costs a single visit and contributes nothing, so the cost no longer depends on how the disjunctions in the rest of the predicate are arranged.
Reading guide
The diff is large but it is mostly deletion, and it splits cleanly in two.
Start with
src/transform/src/literal_constraints/key_bounds.rs(new, self-contained). It never touches aMirRelationExprand never looks at an index. It answers one question aboutMirScalarExpr: given this predicate, what values can these expressions take?Read it in this order:
KeyBoxis one conjunctive bound with an entry per key field, andKeyBounds::boxesis a disjunction of those. The representation is over key values, not predicate syntax, which is the whole point:a IN (1,2) AND b IN (3,4)is one box denoting four key values where a DNF would write four disjuncts, while(a,b) IN ((1,3),(2,4))needs two, since one box would admit(1,4).extractandleaf— the interpreter, five cases.topvsunit. These differ only inexact, and the difference carries the correctness of constraint removal.topmeans "I could not read this predicate" and must stay in the filter.unitmeans "there is nothing here to read" and is the identity ofand.and/disjunction/product/intersect— the combinators.disjunctionis deliberately n-ary: folding it pairwise normalizes the accumulator once per argument, which is quadratic inIN-list width.normalize— merges boxes differing in a single field. This is a precision mechanism, not only a cost one. Without it a 500-elementINlist is 500 boxes, blowsMAX_BOXES, and gets widened, which losesexactand so loses constraint removal.bounds_every_field,lookup_values,bounded_fields,is_unsatisfiable,exact.prune_unsatisfiableat the bottom, which is a separate concern (see below).Then
src/transform/src/literal_constraints.rs. Six functions are deleted outright, all DNF machinery:distribute_and_over_or,unary_and,list_of_predicates_to_and_of_predicates,canonicalize_predicates,remove_impossible_or_args,predicates_size. So are theundo_preparationclosure and theorig_mfpclone it compared against. Those existed because the old design rewrote the predicate in order to read it: once the MFP is distorted you owe an undo, the undo is lossy, and you need a heuristic to pick between the two results. Nothing is rewritten now, so all of it goes.Three functions survive.
inline_literal_constraintsis untouched and still needed, now run on a clone.detect_literal_constraintskeeps its signature and job with a new body.remove_literal_constraintsgoes from about sixty lines to four: drop each predicate whose own bounds areexact.The call graph in
actionis four questions, each mapped to a decision:Treating "every literal-pinned expression" as a key asks the same question at maximum width, which is why one traversal proves whole-predicate contradictions, drives the pruning, and computes the notice's recommended key.
Soundness
Three properties are structural rather than enforced by hand.
Index-directedness.
key_boundsonly ever sees the expressions it is handed. There is no path by which it can be charged for a disjunction the index does not cover.Direction. Every rule only widens the admissible key set, so
boxesis always a superset of the truth. Over-approximating is therefore always safe for lookups, because the residual filter still runs and the constant collection has distinct rows so the semi-join cannot duplicate. It also makesis_unsatisfiablea valid emptiness proof: an empty superset means an empty set.Removal gated on one bit.
exactis the only thing licensing a rewrite. Dropping an exact predicate is sound because the lookup values intersect what every predicate implies, including retained ones, so a retained predicate can only narrow the key further, never widen it past what a dropped one allowed.Behaviors kept explicitly
The normal form provided two things as side effects, and both fired whether or not an index existed, since
remove_impossible_or_argsalone used to trigger the undo. I checked each against tables with no index at all to isolate them from index selection.Unaffected, because
MirScalarExpr::reduceandundistribute_and_oralready do them outside this transform: absorption ((a=1 AND b) OR a=1toa=1), factoring ((a=1 AND b) OR (a=1 AND c)toa=1 AND (b OR c)), andAND-argument dedup.Kept explicitly: pruning contradictory disjuncts, which
prune_unsatisfiablenow does in a bottom-up pass reusing the same bounds. It threads each node's bounds back up so no subtree is analyzed twice, and it takes the predicate list as one implicit conjunction so it also catches contradictions spanning two predicates, such asc IN (1,2)alongsidec IN (3,4). It subsumes a separate emptiness pass, so it costs about 5%.A literal
nullorfalsenow reads as unsatisfiable. Sound for theAND/ORtrees that reach us becausemax(a,b) = trueiff either argument is, andmin(a,b) = trueiff both are, so neither distinguishesfalsefromnullwhen asking whether a result istrue.reducehas already pushedNOTto the leaves, where the analysis treats it as opaque. A literal error is deliberately excluded, since that row errors rather than being filtered.Effect
For the shape above, on a two-column index: a 500-value lookup with both key constraints out of the filter, where before the index was declined. The cross product of two covered
INlists is unchanged, since those lookup values are inherent to a compound key. Ten unrelated two-wayORs, a DNF factor of 1024, produce the same two lookup values as before and a cleaner residual.Optimizer time for
foo = X AND foo2 IN (N values)plus three unrelated conjuncts, debug build,LiteralConstraintsonly:Linear in N now. At N of 1000 and above the old code bailed on the guard, so its time looks reasonable while the plan is wrong. The larger effect is next door:
CanonicalizeMfpdrops from 21ms to 0.9ms at N of 1000 and from 93ms to 2.4ms at N of 3000, because it no longer inherits a mangled MFP.Commits
Split so that functionality and expectation churn stay separable.
transform: make literal constraint detection index-directed— the change, plus new sqllogictest coverage for anINlist on an uncovered column, several such lists at once, a covered list past any workable size guard, and the distinction between an inherent and an incidental cross product.transform: update expectations for index-directed literal constraints— two golden rewrites, both improvements.WHERE a = NULL OR a = 2now serves the equality from a lookup instead of a full scan, and its paired data query is unchanged so thenullrow is still not returned. The join equivalence{#0,#1,#2}over a single input lowers to null-safe pairwise equalities with one mapped expression; there is no index on that source at all, so the old expectation recorded only the distortion.Open questions
MAX_LOOKUP_VALUESis a cap that does not exist today, and nothing in the suite reaches it. It is a guess at where a full scan beats a constant collection and probably wants to be a system variable..sltexpectations were generated against an oldermainand the branch was rebased onto currentmainafterward, so unrelated plan-rendering drift would show up as sqllogictest diffs. Leaving that to CI, with corrections as follow-up commits.