transform: complete partly-literal index keys using join equivalences - #38724
transform: complete partly-literal index keys using join equivalences#38724djahandarie wants to merge 3 commits into
Conversation
`match_index` and `undo_preparation` were nested inside `detect_literal_constraints` and `action` respectively. Lift them to associated fns of `LiteralConstraints` so that a second call site in the same impl can reuse them. No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An arrangement can only be probed with its complete key. When literal
equalities on a join input cover only some fields of an index key,
`LiteralConstraints` declines the index and the plan degrades to a full
scan, even though the enclosing join's equivalences bind the remaining
fields. A query like
SELECT ... FROM t
WHERE t.a = (SELECT a FROM m WHERE m.k = 1) AND t.b IN (7, 8)
with an index on `t(a, b)` scans `t` and builds a fresh arrangement on
`a`, while passing `a` as a literal would give two point lookups.
Complete the key at the join instead. `LiteralConstraints` gains a
pre-order `Join` hook that widens the indexed input's projection so the
literal-covered key fields are visible, then supplies their values in
one of two ways:
* a single literal per field enters the join as an equivalence
`{col, lit}`, which the `JoinImplementation` orderer now treats as
binding that field (a separate `literal_bound` set, kept apart from
`bound` because the latter doubles as the key of arrangements the
join builds);
* an `IN` list of two or more values becomes a constant collection
crossed into the one other input that binds the rest of the key,
so that a single input supplies the whole key.
The literal constraints are removed from the input's MFP in both cases,
so the `Get` case does not report the index as too wide for that read.
The rewrite pays for itself only if `JoinImplementation` can lift the
input's MFP and reuse the index, which requires every key it needs on
that input to be an index key. It therefore fires only when every input
that equates with the indexed one binds all the uncovered key fields,
and for a collection only when that input is unique. Without the guard,
a third input reaching the indexed one by a non-index key would leave
the MFP unlifted: two fresh arrangements over the unfiltered table and a
replicated binder.
Gated by `enable_partial_literal_index_lookups`, default off in
production and on in CI. Several `mz_introspection` views hit the
single-literal shape via `worker_id = 0`; their goldens in
`catalog_server_explain.slt` now show the per-worker indexes used as
join arrangements instead of full scans.
Tests: `test/sqllogictest/transform/join_literal_constraints.slt` covers
both tiers with results checked against the flag-off plans, NULLs in
every position, a duplicate-value `IN` list, a non-unique binder key, a
3-input delta join in both tiers (one fires, one is declined by the
guard), a flag-off plan, and a maintained index over a rewritten join
kept correct under an incremental insert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
268c6ac to
8fb0fa5
Compare
…d index When the literal constraints on a join input fully cover one index and only partly cover a wider one whose remaining fields the join binds, `LiteralConstraints` leaves the fully covered index to the `Get` case: its lookup returns the literal slice, which the join then arranges afresh. The wider key now takes precedence when it strictly extends every fully covered index and contains a unique key of the relation. The choice is between arranging the narrow lookup's result and arranging the binder crossed with the literal values, and no statistics exist to estimate either side. A unique key caps the wide probe's output at one row per (binder row, literal tuple) and rules out the narrow index being a point lookup, so it is the one case where the wider key is preferable without cardinality information. Rollup relations keyed by an entity and a time grain, with a literal on the grain and the entity bound by a join, are the common instance. Tests: a keyed materialized view with both indexes fires, with a filtered and with an indexed binder; the same query on a keyless table declines. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
I pushed a third commit, 89138e8, that changes when the join-level key completion is allowed to compete with an index the literals already cover fully. An example of what it fixes: Take a rollup The commit lets the wider key take precedence, but only when it strictly extends every fully covered index, so no literal is left for the Ideally we would weigh |narrow lookup result| against |binder| x |literal values| with statistics, but none exist to estimate either (indexes and materialized views get |
[Note: recommend reviewing the code via this view of the second commit, as the first commit is just moving some code blocks around.]
Motivation
An arrangement can only be probed with its complete key. When literal equalities on a join input cover only some fields of an index key,
LiteralConstraintsdeclines the index and the plan degrades to a full scan, even though the enclosing join's equivalences bind the remaining fields. With an index ont(a, b):scans
tand builds a fresh arrangement ona, and emits a "too wide" notice whose hint (index(b)alone) is usually bad advice. Passingaas a literal instead gives two point lookups. The optimizer has everything it needs to do that itself: the join already performs runtime-keyed probes, andLiteralConstraintsalready knows how to turn literals into a constant collection. The two passes just never combined.Several
mz_introspectionviews hit the same shape throughworker_id = 0on(id, worker_id)indexes.Description
Two commits. The first is a pure move:
match_indexandundo_preparationbecome associated fns ofLiteralConstraintsso a second call site can reuse them. It has no behavior change and can be reviewed on its own.The second adds a pre-order
Joinhook toLiteralConstraints::action(complete_join_index_key). It widens the indexed input's projection so the literal-covered key fields are visible to the join, then supplies their values one of two ways:{col, lit}. TheJoinImplementationorderer treats such a field as bound via a newliteral_boundset. This is kept apart frombound, which doubles as the key of arrangements the join builds; merging them would widen those keys and trade existing indexes for new arrangements.INlist of two or more values becomes a constant collection crossed into the one other input that binds the rest of the key, so a single input supplies the whole key. It cannot be a sibling join input: the orderer can only place an input once one of its equivalence classes is active, and a sibling constant only connects to the indexed input itself, so no order ever binds the whole key before placing it.The literal constraints are removed from the input's MFP in both cases, so the
Getcase does not report the index as too wide for that read. This is per read: another read of the same index elsewhere in the plan that is still a full scan keeps its notice.The rewrite pays off only if
JoinImplementationcan lift the input's MFP and reuse the index, which requires every key it needs on that input to be an index key. Without a guard, a third input reaching the indexed one by a non-index key would leave the MFP unlifted: two fresh arrangements over the now-unfiltered table plus a replicated binder. The rewrite therefore fires only when every input that equates with the indexed one binds all the uncovered key fields, and for a collection only when that input is unique. TheNOTE:oncomplete_join_index_keystates the residual cost when the order still declines the index.Gated by
enable_partial_literal_index_lookups, default off in production and on in CI per the project convention for new optimizer paths. Known limits are left asTODO:s: only one input per join is completed, and the number ofINvalues is not weighed against the binder's cardinality.Verification
New
test/sqllogictest/transform/join_literal_constraints.sltcovers both tiers with results asserted against the flag-off plans: NULLs in every key and join column, a duplicate-valueIN (7, 7), an empty scalar subquery, a non-unique join key on the binder side (the crossed-in values must not multiply rows), a 3-input delta join in both tiers (the single-literal one fires and drops an arrangement, theIN-list one is declined by the guard and keeps its notice), an explicit flag-off plan, and a maintained index over a rewritten join kept correct under an incremental insert.catalog_server_explain.sltis rewritten: eight introspection views go from full scan to using their per-worker index as a join arrangement, with noCrossJoinorConstantintroduced, since they are all the single-literal case.No release note: off by default.
🤖 Generated with Claude Code