fix(tesseract): resolve rollup-join keys declared as time dimensions, and name the failing hop - #11469
Conversation
… and name the failing hop find_pre_aggregation_for_join matched a hop's join members against a rollup's `dimensions` only, while build_join_source derives the target join hints from dimensions + time_dimensions + segments. A rollup_join whose chain is joined on a time dimension therefore never resolved, at any chain length. Compiled time dimensions are granularity-wrapped, so the comparison goes through base_symbol(). The rejection also carried no diagnosis — a bare "No rollups found that can be used for rollup join" cannot distinguish a planner bug from a rollup that is missing a join key. It now matches the JS planner's message: the failing hop, the unmatched members, and which rollup_join to fix. That missing diagnosis is why #11362 read as a chain-depth limit. There is no such limit. A rollup_join resolves hop by hop, and each hop needs a leg rollup carrying that hop's key on each side — so an interior cube's rollup must declare the upstream hop's key too, even though no query selects it. 4- and 5-cube chains match on the same terms a 3-cube chain does; both are covered by new tests, which also assert the join assembles rather than only matches. Refs #11362
|
Claude finished @igorlukanin's task in 3m 44s —— View job Review complete — the fix is right in principle; 1 high, 1 medium, 2 low, 1 general note.Full review
SummaryThe diagnosis is convincing and the write-up is unusually good. My concerns are all downstream of the match, not about the match itself.
Details are in the four inline comments. General note — divergence from the JS planner
Things I checked and found fine
Not verifiedI couldn't run |
| fn pre_aggregation_covers_join_member( | ||
| pre_aggr: &CompiledPreAggregation, | ||
| member: &Rc<MemberSymbol>, | ||
| ) -> bool { | ||
| let matches_directly = pre_aggr | ||
| .dimensions | ||
| .iter() | ||
| .chain(pre_aggr.segments.iter()) | ||
| .any(|pa_m| member == pa_m); | ||
|
|
||
| matches_directly | ||
| || pre_aggr.time_dimensions.iter().any(|pa_m| { | ||
| pa_m.as_time_dimension() | ||
| .map(|td| td.base_symbol() == member) | ||
| .unwrap_or(false) | ||
| || member == pa_m | ||
| }) | ||
| } |
There was a problem hiding this comment.
The hop now resolves, but I don't think the SQL it produces can reference the right column.
A rollup that declares day as a time_dimension materializes it granularity-suffixed — the pre-agg table column is td_facts__day_day (see build_pre_agg_query_yaml in test_context.rs, and the suffix logic in PreAggregation::all_dimensions_refererences, logical_plan/pre_aggregation.rs:113-129). The join key resolved from {CUBE.day} = {td_facts.day} is the bare dimension symbol, whose alias() is td_facts__day.
Two consequences follow from all_dimensions_refererences:
- The join-members loop (
pre_aggregation.rs:136-142) insertstd_facts.day -> td_facts__dayafter the time-dimensions loop insertedtd_facts.day -> td_facts__day_dayinto the sameHashMap— so the later, unsuffixed entry wins, and even the query's own selected time dimension would then render against a column that isn't in the rollup table. - The
on_sqlfor the hop renders through the same reference map, so the ON clause would referencetd_facts__daytoo.
The new Rust test asserts only the logical structure (join.items[0].to == td_facts_rollup), so neither of these would show up. That's the one gap against the bar this PR sets for itself elsewhere — the JS chain tests deliberately assert the SQL assembles rather than just that matching succeeded. Could the time-dimension fixture get the same treatment (a build_sql assertion, or an integration-postgres run that actually executes the join)? If the SQL does come out right, that test is cheap insurance; if it doesn't, the alias mapping needs to resolve a time-dimension-backed join key to its suffixed column.
There was a problem hiding this comment.
Confirmed, including the overwrite. Executed on both planners — the leg rollup only ever materializes td_facts__day_day, and the join renders ON "td_dates__day" = "td_facts__day". The projection shows the map corruption directly: Tesseract emits date_trunc('day', "td_facts__day") "td_facts__day_day", so the query's own time dimension gets mis-mapped exactly as you said, because the join-members loop in all_dimensions_refererences runs after the time-dimensions loop and wins.
Details and the before/after in the JS thread. Kicking the ticket back rather than growing this PR into an alias-resolution change across both planners.
|
|
||
| matches_directly | ||
| || pre_aggr.time_dimensions.iter().any(|pa_m| { | ||
| pa_m.as_time_dimension() |
There was a problem hiding this comment.
Granularity is discarded, and the two sides of a hop are matched independently.
base_symbol() == member accepts a rollup whose stored values are truncated to any granularity. A rollup declaring time_dimension: created_at, granularity: month will satisfy a join key on created_at even though the column holds month-truncated timestamps, and the two legs of a hop are resolved by two separate find_pre_aggregation_for_join calls — nothing checks they agree. A month-grained leg joined to a day-grained leg matches, and the join silently produces the wrong rows.
The fixture can't distinguish this: both legs are granularity: day and the source data is already day-aligned, so a wrong-granularity match would still produce the same rows.
Even the matching-granularity case is a semantic choice worth stating: joining on truncated buckets is only equivalent to joining on the raw key when the key is already bucket-aligned. If that's the intended contract, the doc comment is the place to say so; if not, the two sides need their granularities compared before the hop is accepted.
There was a problem hiding this comment.
Valid, and it's the part that needs a product decision rather than a patch. Joining on truncated buckets is only equivalent to joining on the raw key when the key is already bucket-aligned, and nothing compares the two legs' granularities — so a month leg joined to a day leg matches and returns wrong rows. Recorded on CORE-724 as an open question for reassessment along with the alias gap; whether mismatched granularities should be rejected or documented as the contract isn't mine to settle inside a bug fix.
| if found_pre_aggr.is_empty() { | ||
| return Err(CubeError::user(format!( | ||
| "No rollups found that can be used for rollup join" | ||
| "No rollups found that can be used for a rollup join from \"{}\" (fromMembers: {}) to \"{}\" (toMembers: {}). Check the \"{}\" pre-aggregation definition — every rollup must declare the dimensions its own joins are on, including keys the query doesn't select", | ||
| join_item.original_from, | ||
| Self::format_join_members(&join_item.from_members), | ||
| join_item.original_to, | ||
| Self::format_join_members(&join_item.to_members), | ||
| rollup_join_name.name, | ||
| ))); | ||
| } |
There was a problem hiding this comment.
The message is a real improvement over the bare string, but it still can't tell the author which side of the hop failed. find_pre_aggregation_for_join is called twice per hop — once for from_members, once for to_members — and both calls emit the identical text listing both sides. In the new rollup_join_missing_key fixture it's the to side (mk_customers) that lacks the key, yet the message reads the same as it would if mk_orders were the broken one.
Since members is already passed separately from join_item, a one-word side label threaded from make_pre_aggregation_join_item ("no rollup covers the from side …") would make the message point at exactly one rollup to fix. The JS planner has the same ambiguity, so this isn't a regression — just the remaining half of the diagnosis this PR is adding.
There was a problem hiding this comment.
Agreed, and cheap — a side label threaded from make_pre_aggregation_join_item would point at one rollup instead of two. Leaving it with the rest of the reassessment rather than pushing a message-only change onto a PR that's being reworked.
…sions on the JS planner too The JS matcher had its own copy of the same defect: preAggObjForJoin checked references.dimensions, which does not include timeDimensions, and cubesHintsFromPreAggregation derived join hints from measures + dimensions only, so a cube reachable only through a rollup's time dimension was invisible. The two planners are independent here, and each needed its own fix — verified by reverting one at a time: with only the Rust fix the legacy planner still failed, and with only the JS fix the Tesseract planner still did. The new test runs both. Also dropped the segments branch from the Rust predicate. MemberSymbol equality includes the enum discriminant, so a join key — always a dimension — can never compare equal to a Segment; removing it reddens no test because it was unreachable.
|
Claude finished @igorlukanin's task in 3m 4s —— View job Re-review of the JS-planner commit — 1 high, 1 medium new; 3 prior threads still stand, 1 resolved.Full review
What changed since the last review
New findings
Details inline. On #5 I was able to trace the JS path statically end to end (ON clause bypasses Prior threads re-affirmed (not re-posted)
Things I checked and found fine
Not verifiedI could not execute anything this round — the monorepo has no installed dependencies in this environment and |
| const fromPreAggObj = preAggObjsToJoin | ||
| .filter(p => joinMembers.every(m => !!p.references.dimensions.find(d => m === d))); | ||
| .filter(p => joinMembers.every(m => !!p.references.dimensions.find(d => m === d) || | ||
| !!p.references.timeDimensions.find(td => m === td.dimension))); |
There was a problem hiding this comment.
The JS side inherits the same alias gap the Rust side has — and here I can trace it end to end.
The hop now matches, but the rollupJoin ON clause is rendered with a sqlResolveFn that returns member.aliasName() for a plain dimension (rollupPreAggregation, line 1592-1599):
const member = this.query.cubeEvaluator.isMeasure(path)
? this.query.newMeasure(path)
: this.query.newDimension(path);
return member.aliasName();For td_dates.day / td_facts.day that yields td_dates__day and td_facts__day. But a leg rollup that declares the key as a timeDimension materializes it granularity-suffixed — timeDimensionsRenderedReference (line 1725) builds the column as timeDimension.unescapedAliasName(rollupGranularity), i.e. td_dates__day_day / td_facts__day_day. Neither td_dates__day nor td_facts__day is a column of either leg table, so the emitted ON should reference columns that don't exist.
The outer projection is fine — it goes through renderedReference, which is the suffixed map. It's specifically the ON clause that bypasses it by resolving straight to aliasName().
The new rollupJoin whose join key is a time dimension test (pre-aggregations.test.ts:1563-1584) asserts only preAggregationsDescription and preAggregationForQuery.preAggregationName — matching, not rendering — so it wouldn't surface this. That's the same bar the 4-/5-cube chain tests in this PR deliberately set for themselves ("assert the SQL assembles, not merely that matching succeeded"); this fixture should get it too. A query.buildSqlAndParams() assertion on the ON text would settle it in either direction.
If it does render unsuffixed, the fix is for the rollupJoin sqlResolveFn to resolve a key backed by a leg rollup's timeDimension through that rollup's time-dimension alias (granularity suffix included) rather than the bare dimension alias.
I could not run this — the monorepo has no installed dependencies in this environment and install wasn't permitted — so this is read from the code path, not observed. Given the Rust twin has the same shape (see the unresolved thread on pre_aggregations_compiler.rs, where all_dimensions_refererences demonstrably overwrites td_facts.day -> td_facts__day_day with td_facts__day because the join-members loop runs last), it's worth confirming before merge.
There was a problem hiding this comment.
You're right, and it renders unsuffixed — I ran it on both planners.
The leg rollup materializes exactly two columns:
CREATE TABLE td_facts_td_facts_rollup AS SELECT
date_trunc('day', ("td_facts".day::timestamptz AT TIME ZONE 'America/Los_Angeles')) "td_facts__day_day",
sum("td_facts".amount) "td_facts__total_amount"There is no td_facts__day. Yet the ON clause is:
-- JS planner
LEFT JOIN td_facts_td_facts_rollup AS "td_facts__td_facts_rollup"
ON "td_dates__day" = "td_facts__day"
-- Tesseract
LEFT JOIN td_facts_td_facts_rollup AS "td_facts__td_facts_rollup"
ON "td_dates__day" = "td_facts__day"Both sides reference columns neither leg table has. And the Rust overwrite you predicted in the sibling thread is visible in the projection too — Tesseract emits date_trunc('day', "td_facts__day") "td_facts__day_day", re-truncating a column that doesn't exist instead of reading the stored one.
So the net effect of this half of the PR is worse than what it replaced: master rejected the schema with an actionable No rollups found, and with the widened predicate it is accepted and emits unexecutable SQL. Confirmed by reverting the predicate alone — the rejection comes back.
Not fixing it inside this PR. Making it correct means resolving a join key backed by a leg rollup's timeDimension through that rollup's suffixed alias in both planners (the JS sqlResolveFn here, and the map ordering in all_dimensions_refererences on the Rust side), which is a different change from widening a lookup. Kicked back to CORE-724 for reassessment with your traces recorded.
| preAggObj.references.dimensions | ||
| preAggObj.references.dimensions, | ||
| // A cube may be reached only through the rollup's time dimension. | ||
| preAggObj.references.timeDimensions.map(td => td.dimension), |
There was a problem hiding this comment.
This one isn't covered by the "cannot regress a working schema" argument — that argument was made for the matching predicate, and it holds there. This hint widening has a different blast radius.
cubesHintsFromPreAggregation feeds existingJoins (line 1457-1461), which is subtracted from targetJoins to produce nonExistingJoins — and if that comes out empty, rollupJoin throws outright:
if (!nonExistingJoins.length) {
throw new UserError(`Nothing to join in rollup join. ...`);
}Adding time-dimension cubes to the hints makes each leg's join tree larger, so existingJoins grows and nonExistingJoins can only shrink. For a leg rollup whose timeDimension carries a join path (td_dates.td_facts.day), a hop that was previously treated as still-to-be-joined is now treated as already inside that leg. I think that's the more correct reading — the hop genuinely is inside the leg rollup — but it is a behaviour change on schemas that were previously matching, and the failure mode is a hard throw rather than a silent fallback.
Worth either a test pinning a leg rollup with a join-path time dimension (does it still produce a non-empty nonExistingJoins?), or a line in the PR description acknowledging that this half of the change can move a previously-matching schema, unlike the predicate half.
There was a problem hiding this comment.
Fair distinction — the "cannot regress a working schema" argument was about the matching predicate and doesn't cover the hint widening, which can only shrink nonExistingJoins and fails hard rather than falling back. Untested either way right now. Folded into the CORE-724 reassessment; the leg-rollup-with-join-path-time-dimension case needs pinning before any version of this lands.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #11469 +/- ##
==========================================
- Coverage 79.45% 79.45% -0.01%
==========================================
Files 480 480
Lines 98810 98812 +2
Branches 3636 3636
==========================================
Hits 78511 78511
- Misses 19778 19780 +2
Partials 521 521
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:
|
Warning
Superseded — do not merge. Review proved this emits an unexecutable join: both planners
render
ON "td_dates__day" = "td_facts__day"while the leg rollups only ever materializetd_facts__day_day. Master rejected such a schema with an actionableNo rollups found; thisbranch accepts it and produces SQL that fails at execution — a worse failure mode than the one
it replaced. Back to CORE-724 for re-planning as an ON-clause alias-resolution fix.
Details, the executed before/after, and the open granularity question are in the planning doc.
The docs PR #11470 is independent and unaffected.
What this was trying to fix
The ticket reported that a
rollup_joinover a 4-cube chain fails where the 3-cube one matches, andinferred a chain-depth limit in Tesseract. There is no depth limit — 4- and 5-cube chains match on
master on the same terms a 3-cube chain does. The inherited regression test failed for two reasons of
its own: its interior rollup declared only
[dim_x], notdim_w(the key on its own side of the newhop), and it asserted the rollupJoin's own id appears in
preAggregationsDescription(), which neverhappens for a matched rollupJoin — you get the leg rollups, with the join named on
preAggregationForQuery.A real defect was found next door: a join key declared as a rollup's time dimension was invisible to
the lookup that matches a hop to its leg rollups, so such a chain never resolved at any length. Both
planners had their own copy — Tesseract's
find_pre_aggregation_for_joinmatcheddimensionsonly,and the JS
preAggObjForJoincheckedreferences.dimensions, which excludestimeDimensions.Why the fix is wrong
Widening the match predicate lets the hop resolve, but nothing teaches the ON-clause renderer that
the key lives in a granularity-suffixed column. The JS
sqlResolveFninPreAggregations.rollupPreAggregationreturns the baremember.aliasName(), and on the Rust side thejoin-members loop in
PreAggregation::all_dimensions_refererencesruns after the time-dimensionsloop and overwrites
td_facts.day -> td_facts__day_daywithtd_facts__day— which also corrupts thequery's own time dimension, rendered as
date_trunc('day', "td_facts__day") "td_facts__day_day".No test caught it because the time-dimension test — the only one covering the actual fix — asserts
matching (
preAggregationsDescription(),preAggregationName) and not rendering. The 4-/5-cube chaintests in this PR do assert the SQL assembles; that one didn't meet the same bar.
What is still worth keeping
rollup_jointo fix — a bareNo rollups found that can be used for rollup joinis why this wasfiled as a depth bug. Sound on its own merits, independent of the matching change.
succeeded.
Refs #11362