fix(tesseract): fold nested multi-fact join legs into one scan - #11643
fix(tesseract): fold nested multi-fact join legs into one scan#11643waralexrom wants to merge 4 commits into
Conversation
Measures were grouped by exact join tree equality, so a measure whose tree is contained in another one's got a group - and a full re-scan of the shared join - of its own. A funnel over several cubes paid for one scan of the base per measure. Such a group is now folded into the one that walks the same cube graph further, provided every measure it carries computes the same value there. The extra joins are LEFT, so they only replicate rows, and a distinct aggregation is immune to that; a plain count or a sum is not and keeps its own group. The wider tree is rebuilt from the union of both groups' join hints before that question is asked: a tree only records multiplication for the cubes its own hints named, so a cube reached through it in transit would otherwise report as unmultiplied and let an inflating measure through. Only the query's own grouping merges. Regrouping a measure subset does not: a pre-aggregation is matched against one leg at a time, and collapsing legs would cost the query its rollups. Ungrouped queries are excluded as well, since they expose the replicated rows directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three holes in the merge, all of them cases where a group was folded in that should have stayed on its own tree. A member expression names no member to anchor it to a cube, so what it reads is decided by the join it lands on - `COUNT(*)` over a wider tree counts the fanned-out rows. The multiplication collector reports such a measure as unmultiplied by construction, so the fan-out check waved it through; a leaf that is not a plain measure now declines the merge outright. A dimension-only member expression over several cubes makes the collector refuse a tree that multiplies any of them. The tree passed here is a merge candidate rather than the one the query renders, so that refusal was turning a plannable query into an error. It now declines the merge instead. Folding groups also costs the query its rollups: a pre-aggregation is matched against one group at a time, and a merged query needs one spanning all of them. The merge now stands down whenever any cube it reads defines a pre-aggregation at all - coarse, but a rollup outweighs the scan saved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 5m 36s —— View job Review: sound optimization, well-guarded — 1 medium correctness, 1 medium perf, 3 low. No blockers.Full reviewTodo
What I verifiedI traced the safety argument rather than taking the PR description at face value, and it holds:
Findings
Security: nothing relevant — no new input paths, no user-controlled SQL, no auth surface. The added SQL text is entirely planner-generated from already-validated members. PraiseThe doc comments are the strongest part of this PR — Notes on scope decisions I agree with
|
|
|
||
| /// How many times the query reads the cube at the root of the join. One read | ||
| /// per group is exactly the duplication that folding nested groups removes. | ||
| fn base_scan_count(sql: &str) -> usize { |
There was a problem hiding this comment.
Counting AS "sites" occurrences is a reasonable proxy but couples these assertions to alias rendering — a change to how the base cube is aliased silently turns all six assert_eq!(base_scan_count(...), n) into vacuous 0 == n failures (loud, so acceptable) or, worse, into passes if the alias appears elsewhere. If there is an existing helper that counts logical scans / join groups in the plan, prefer it; otherwise consider asserting on num_groups() from MultiFactJoinGroups for the structural part and keeping the SQL check only where the rendered shape is the point.
Two coverage gaps worth a test each, since they are the guards most likely to be broken by future edits:
- a merging query that also carries a filter or time dimension on the deeper cube (confirms base hints/filters don't change the merge decision or the values),
- the
pre_aggregation_querybranch inquery_properties.rs(ungroupedis covered bytest_nested_trees_ungrouped_keeps_separate_scans,pre_aggregation_queryis not).
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #11643 +/- ##
===========================================
+ Coverage 59.36% 84.42% +25.05%
===========================================
Files 229 260 +31
Lines 18241 84473 +66232
Branches 3679 0 -3679
===========================================
+ Hits 10829 71313 +60484
- Misses 6861 13160 +6299
+ Partials 551 0 -551
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…cheaply Resolving the union of two groups' join hints asks the join graph a hypothetical question. A hint set it refuses was propagating out and failing a query that plans fine without the merge; it now declines the candidate, the way an unmergeable measure already did. The pre-aggregation guard also ran before anything checked whether a merge was possible at all, so every ordinary single-group query paid a bridge call per cube in its join to compute an answer nothing read. The nested-pair test is pure comparison, so it goes first. Covers the two guards that had no test of their own: a merge alongside a filter on either cube, and the pre-aggregation build path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 1m 38s —— View job Re-review of
|
| # | Severity | Location | Status |
|---|---|---|---|
| 3 | Low | measure_kinds/mod.rs:323 |
Open — min/max are idempotent under replication and render identically, but is_distinct() excludes them. Conservative in the safe direction; fine as a follow-up. |
| 5 | Low (test) | nested_join_trees.rs:32 |
Partially addressed — coverage gaps closed; base_scan_count still counts AS "sites", coupling seven assertions to alias rendering. |
Findings 1, 2 and 4 resolved on their threads.
Verification I could not run
cargo check / cargo test are not in this job's allowlist, so I reviewed the new code by reading it. The changes are small and locally type-obvious (an if let ... else { continue } inside the labeled loop; a &[GroupBuild] → bool helper whose immutable borrow ends before merge_nested_groups takes &mut), but CI is the authority on that. The PR states 1287 tests green on real Postgres at b2ddcb2; the three new tests here are additive and the two new snapshots are the only new expected output.
Verdict
The two things I'd have wanted changed before merge are changed, and the fix for finding 2 is better than what I proposed. The safety argument I traced at b2ddcb2 is untouched by this commit — same LEFT-join premise, same per-measure render-form resolution, same key != other.key rebuild assertion. Ship-ready from my side; the two low items are follow-up material.
A minimum or a maximum does not move when a value it has already seen arrives again, so replication by the wider join tree leaves it where it was — the same grounds a distinct count merges on. Both were being turned down. The question belongs on the aggregation type, next to the two predicates that were already there and neither of which answers it: `is_additive` covers `sum`, which counts every row it is given, and leaves out `countDistinct`; `is_distinct` leaves out `min` and `max`. `is_duplicate_insensitive` names the property the merge actually depends on. Nothing changes for how a multiplied `min`/`max` is classified — that is still decided from the measure's own tree, where the measure stays regular; this only lets it move into a tree that fans it out without changing its value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 2m 20s —— View job Re-review of
|
| # | Severity | Status |
|---|---|---|
| 1 | Medium | Fixed in 902fa44 — probe no longer propagates its error |
| 2 | Medium (perf) | Fixed in 902fa44 — has_nested_pair gates the bridge calls |
| 3 | Low | Fixed in 9b9d000 — min/max now merge; thread resolved |
| 4 | Low | Fixed in 902fa44 — doc corrected to edge-set containment |
| 5 | Low (test) | Coverage gaps closed in 902fa44; the base_scan_count-counts-AS "sites" nit remains, deliberately left as a nit |
Follow-up worth considering (not for this PR)
regular_in_multiplied still turns down a multiplied Min/Max, sending it to a multiplied subquery with its own keys CTE. By the same argument this commit just made, that subquery is unnecessary — MIN/MAX over a fanned-out join equals the value over the un-fanned one, with no form switch. Widening regular_in_multiplied the same way would remove a whole CTE from a common class of query, but it would move many existing snapshots and deserves its own PR. The new doc comment already flags the asymmetry, which is the right amount of signal to leave behind.
Verification I could not run
cargo check / cargo test are outside this job's allowlist, so the new code was reviewed by reading. It is small and locally type-obvious (one new matches! predicate, one call-site swap, three additive tests, three new snapshot files), and CI is the authority. The two fixture YAMLs gained the same three measures, so no existing test in either file changes shape.
Verdict
Ship-ready. The commit closes the last substantive item, the property it relies on is now named and unit-pinned rather than implied, and the tests include a negative control that would actually fail if the predicate were wrong. Only the alias-coupled base_scan_count nit remains open, and that is follow-up material at most.
Summary
A query whose measures sit at different depths of one join chain planned a separate leg
per measure, and every leg re-scanned the shared part of the join from scratch. A funnel
modelled as
sites → carts → checkoutspaid for one full scan of the base per measure,so adding measures multiplied the cost — two measures were already timing out on Trino.
Such legs are now folded into one wherever moving a measure into the wider join tree
provably cannot change what it computes. Note that this is not the star-shaped multi-fact
case (
ordersandreturnsundercustomers), which stays split as before — it is thecase where legs differ only by how far down one chain they walk, and never needed to be
separate at all.
Changes
MultiFactJoinGroups::try_new_merging_nestedfolds a group into one whose join treecontains its own. Safe because the extra joins are
LEFT— they only ever replicate arow, never drop one — and only for measures immune to that replication: a distinct
aggregation qualifies, a plain
countorsumdoes not and keeps its own leg.question is asked.
multiplicationFactoris only populated for the cubes a tree's ownhints named (
JoinGraph.tsmaps it overcubesToJoin), so a cube reached in transitreports as unmultiplied and would otherwise let an inflating measure through. The
rebuild produces the same joins — asserted by comparing the
JoinKey.MeasureKind::survives_row_multiplication, deliberately narrower than the existingregular_in_multiplied: a key-based count is also safe under multiplication, but onlyafter switching to the distinct
MultipliedCountform, and the render form is decidedfrom the measure's own tree elsewhere.
(
COUNT(*)names no member, so what it counts is whatever rows the join produces),ungrouped queries (raw rows expose the replication), pre-aggregation queries, and
regrouping through
for_measures.A rollup is matched against one leg at a time, so a merged query needs one spanning all
of them; a rollup outweighs the scan saved. This guard is coarse — see below.
Testing
New fixture with a three-cube chain and a seed built so right and wrong answers differ:
two carts of one site share a
msid(distinct ≠ count), one cart has two checkouts(fan-out), two carts have none (
LEFT JOINmust keep them).one: a distinct measure returns the same numbers selected alone and selected next to a
deeper measure, while
count/sumkeep their own leg and their own values.grouping itself — nested merges, siblings do not, plain
countdoes not.Follow-up
The pre-aggregation guard answers "could a rollup exist" rather than "did one match", so
the optimisation silently switches off for models with rollups on the fact cubes. Removing
it means two-pass planning in
TopLevelPlanner— plan unmerged, try pre-aggregations,replan merged on a miss — which needs
QueryPropertiesto carry a merge flag. Left out ofthis PR deliberately.
The merge also only takes effect through
is_simple_query(), so a query that additionallycarries a multiplied or multi-stage measure keeps its regular legs unmerged.