Skip to content

optimizer: add prefix-unique join order scoring behind a flag#37523

Draft
frankmcsherry wants to merge 3 commits into
MaterializeInc:mainfrom
frankmcsherry:join-order-reverse-edges
Draft

optimizer: add prefix-unique join order scoring behind a flag#37523
frankmcsherry wants to merge 3 commits into
MaterializeInc:mainfrom
frankmcsherry:join-order-reverse-edges

Conversation

@frankmcsherry

Copy link
Copy Markdown
Contributor

Motivation

Join ordering scores each candidate order extension only by target-side uniqueness: unique_key says the incoming relation is unique on its bound columns, so the join cannot grow the carried prefix. The dual signal is missing. When the placed prefix is unique on the equated expressions, each incoming row matches at most one prefix row, so the extended result is bounded by the incoming relation's size. Foreign-key equivalences traversed backwards (unique side placed, fk side incoming) are exactly this shape, the bound composes down FK chains, and delta join paths starting from the "many" end of a chain cannot avoid such edges. Today they score as unbounded mysteries: in a chain a.b_fk = b.b_pk AND b.c_fk = c.c_pk with everything indexed, the path from c reads %2:c » %1:b[c_fk]KA » %0:a[b_fk]KA, indistinguishable from a genuine many-to-many hazard.

Change

The Orderer now tracks unique keys of the placed prefix, folding in each placement with the standard join key rule: forward-unique keeps the old keys, reverse-unique adopts the incoming relation's keys, and otherwise pairwise unions (which is what lets the bound compose along chains). Candidates gain a prefix_unique characteristic ranked immediately below unique_key, in a new JoinInputCharacteristicsV3 following the V1/V2 versioning precedent. V3 is selected by a new default-off, cluster-scoped flag enable_join_reverse_edge_scoring, and builds on V2 (enable_join_prioritize_arranged) ordering. EXPLAIN prints the new field as P and accepts enable join reverse edge scoring as a WITH option.

A cute corollary: a cross join against a prefix with the empty unique key (at most one row, e.g. a scalar aggregate) counts as bounded, so singleton cross joins stop being feared.

Known conservatism, documented in the code: characteristics are computed at push time, so a queued candidate does not benefit from prefix keys established after its last push. Sound (never overclaims), just conservative.

Verification

  • With the flag off, plans are unchanged: 2349/2349 sqllogictest queries pass unmodified across transform/, explain/, joins.slt, chbench.slt, and both TPCH files, and rewriting the new corpus reproduces every baseline byte-identical.
  • New test: test/sqllogictest/transform/join_reverse_edges.slt, FK chain / star / snowflake shapes with captured plans flag-off and flag-on.
  • Unit tests for mz-expr, mz-sql-parser, and mz-sql (including the optimizer-features vars invariant) pass.

Evidence from existing corpora (flag on)

Three-way attribution over 110 EXPLAIN queries in chbench, TPCH (index + MV), joins, and ldbc_bi, comparing V1 (production default), V2, and V3, with the P marker stripped before comparison so cosmetic changes don't count: 22 identical, 36 marker-only, 4 changes inherited from V2's arranged-first ranking, 44 reranked by the new signal (plus 4 mixed). Sampled judgments: binary sub-joins now start from the bounded side (no cost, both sides of a differential join are arranged anyway). The tradeoff deserving review: on delta paths, P above A means a bounded-unarranged key can beat an arranged-unbounded key, buying a cardinality bound at the price of an arrangement, the same philosophy as unique_key > arranged in V2 extended one level.

Open questions

  • Should V3 extend V1 instead of V2, keeping this flag independent of enable_join_prioritize_arranged?
  • Is prefix_unique > arranged the right rank, given the arrangement-vs-bound tradeoff above?
  • CREATE CLUSTER ... FEATURES is deliberately not wired for the new flag (EXPLAIN WITH and the system var are). Should it match enable_join_prioritize_arranged?

🤖 Generated with Claude Code

frankmcsherry and others added 3 commits July 8, 2026 17:18
FK chain, star, and snowflake shapes, unindexed and indexed, with
selective-filter variants. The captured plans document how join
ordering currently scores equivalence edges traversed backwards
(unique side placed, foreign-key side incoming): bounded by the
incoming relation's size, but scored without any uniqueness credit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Join ordering scores a candidate only by target-side uniqueness: an
edge into a relation whose bound columns cover one of its unique keys
cannot grow the result. The dual signal was missing. When the placed
prefix is unique on the equated expressions, each incoming row matches
at most one prefix row, so the result is bounded by the incoming
relation's size. Foreign-key equivalences traversed backwards (unique
side placed, foreign-key side incoming) are exactly this shape, and
delta join paths starting from the "many" end of a chain cannot avoid
them.

The Orderer now tracks unique keys of the placed prefix, folding each
placement in with the standard join key rule (forward-unique keeps the
old keys, reverse-unique adopts the incoming relation's keys, otherwise
pairwise unions, which is what lets the bound compose along chains).
Candidates gain a `prefix_unique` characteristic, ranked just below
`unique_key`, in a new `JoinInputCharacteristicsV3` selected by the
default-off, cluster-scoped flag `enable_join_reverse_edge_scoring`.
EXPLAIN renders the new field as `P`, and accepts the flag as a WITH
option for per-statement replanning.

With the flag off, plans are unchanged: rewriting the new slt corpus
reproduces every baseline byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The differential and delta planners each called optimize_orders with
identical inputs, computing the same N greedy orders twice per join.
Compute them once in action and pass them in, which retires the
TODO(mgree) noting exactly this. Error handling is preserved: the
first-run path panics on order-construction failure as the differential
planner did, and the second-run upgrade path remains Ok-gated.

Also replace the order tie-break's max_by_key(|o| o.clone()) with
max_by(cmp), same selection without cloning every candidate order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant