Skip to content

fix(tesseract): resolve rollup-join keys kept as a rollup's time dimension - #11747

Merged
waralexrom merged 4 commits into
masterfrom
tesseract-rollup-join-on-clause-aliases
Sep 4, 2026
Merged

fix(tesseract): resolve rollup-join keys kept as a rollup's time dimension#11747
waralexrom merged 4 commits into
masterfrom
tesseract-rollup-join-on-clause-aliases

Conversation

@waralexrom

Copy link
Copy Markdown
Member

Summary

A rollup_join whose join key is declared as a leg rollup's time_dimension — rather than as a plain dimension — never resolved, at any chain length. Both the hop lookup and the ON-clause rendering assumed the key lives in a plainly named column, and a time dimension does not: a rollup stores it truncated to its granularity, under a granularity-suffixed name.

The chain-depth limitation reported in #11362 does not exist. Four- and five-cube chains match on master on the same terms a three-cube chain does; the reported failure came from an interior rollup not declaring the key on its own side of the new hop. The four-cube test added here passes without any of the changes below, and is there to keep it that way.

Changes

  • Resolve each join member through the leg rollup that actually carries it, and keep the resolved column on the join item (PreAggregationJoinMember { symbol, column, granularity }). The ON clause now reads the column the rollups materialize, and the join-members pass no longer overwrites the query's own time dimension in all_dimensions_refererences.
  • The join therefore compares truncated values, so every key of a hop must be stored the same way across both sides. A key truncated to day can be compared neither to one truncated to month nor to one kept raw as a plain dimension; both are rejected with an error naming the members and how each is stored. The two sides carry no pairing between their members, so the check is on the hop as a whole rather than side by side.
  • Prefer a rollup declaring the key plainly over one keeping it as a time dimension, so a hop that already resolved keeps resolving to the same rollup instead of becoming ambiguous and failing with Multiple rollups found.
  • Tesseract's rejection now names the failing hop, the unmatched members and which rollup_join to fix, as the JS message already did. A bare No rollups found that can be used for rollup join is why this was filed as a depth bug.
  • The JS matcher accepts the wider reading too, because Tesseract calls back into it after planning to build the legacy preAggregationForQuery descriptor, and a throw there kills the whole native call even though the SQL is already correct. It is gated on canUseNativeSqlPlannerPreAggregation: the legacy planner renders the ON clause from the bare alias, so widening it unconditionally would turn its clean rejection into SQL that fails at execution.

Testing

  • rollup_join_keys.rs — six planner tests over four new fixtures: the four-cube chain, the hop an interior rollup has no key for, the time-dimension key, the two rejected storage mismatches, and the ambiguity guard. Each was red-checked by reverting the corresponding change.
  • pre-aggregations.test.ts (unit) — the chain matching and assembling, the rejection naming the hop, and the granularity mismatch.
  • pre-aggregations.test.ts (integration, Postgres) — rollupJoin pre-aggregation on a time dimension join key materializes both leg rollups and asserts the returned rows. Reverting the rendering fix and rebuilding the native addon makes it fail against the database with column "td_dates__day" does not exist, which is the actual defect rather than a shape read off the SQL.
  • Full suites green: 1328 Rust planner tests; schema-compiler pre-aggregations unit; the Postgres pre-aggregation integration file on both planners (48 passed under Tesseract, 52 under the legacy planner).

Note

The join happens on the stored buckets, not on the raw values it is modeled on, so it fits keys already aligned to that granularity — a date dimension joined to facts by day. The reference page is deliberately untouched here; the docs change already open against it is the place for that.

Refs #11362

waralexrom and others added 2 commits September 2, 2026 18:23
…nsion

A rollupJoin resolves one hop at a time, looking for a leg rollup that carries
that hop's key on each side. The lookup only ever read the rollups' `dimensions`,
so a key declared as a rollup's `time_dimension` was invisible and the join was
rejected outright, at any chain length.

Widening the lookup alone is not enough: a time dimension is stored truncated to
the rollup's granularity, under a granularity-suffixed column, and the ON clause
was rendered from the bare member alias. Resolve each join member through the leg
rollup that actually carries it and keep the resolved column on the join item, so
the ON clause reads what the rollups materialize and the query's own time
dimension is no longer overwritten by the join-members pass.

The join then compares truncated values, so every key of a hop must be stored the
same way on both sides — a key truncated to `day` can be compared neither to one
truncated to `month` nor to one kept raw as a plain dimension. Both are rejected
with an error that says so.

A rollup declaring the key plainly is preferred over one keeping it as a time
dimension, so a hop that already resolved keeps resolving to the same rollup
instead of turning ambiguous.

Tesseract's rejection now names the failing hop, the unmatched members and which
rollupJoin to fix, as the JS message already did — a bare "No rollups found" is
why this was reported as a chain-depth limitation. Chain length is not a limit:
the four-cube test covers it, and it passes without any of the changes here.

Refs #11362

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Leave the reference page to the docs change already open against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@waralexrom
waralexrom requested a review from a team as a code owner September 2, 2026 16:25
@github-actions github-actions Bot added rust Pull requests that update Rust code javascript Pull requests that update Javascript code labels Sep 2, 2026
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 3m 10s —— View job


Review complete — solid fix, well-tested; 2 medium, 3 low. 5 inline comments posted.

Full review
  • Gather context / read diff
  • Review Rust planner changes
  • Review JS PreAggregations.ts changes
  • Review tests & fixtures
  • Post findings

General

The diagnosis is right and the fix is in the right place. PreAggregationJoinMember { symbol, column, granularity } is the correct shape: the ON clause previously rendered from member.alias() while the rollup materialized alias_granularity, so the join was structurally unable to work for a time-dimension key regardless of chain depth. Carrying the resolved column on the join item, rather than re-deriving it at render time, is the change that makes all_dimensions_refererences correct.

The debunking of #11362's chain-depth claim is convincing and the four-cube fixture is the right way to keep it debunked. The error-message work is genuinely valuable — "No rollups found" with no hop named is exactly why this got misfiled as a depth limit. Red-checking each test by reverting the corresponding change, and validating the integration test against a real Postgres rather than against SQL shape, is the level of rigor this kind of planner change needs.

Findings

# Severity Where Issue
1 Medium pre_aggregations_compiler.rs:543-550 check_join_members_comparable collapses all four member lists into one granularity set, so a composite hop joining on both a plain key and a time-dimension key is rejected with a message that misdescribes the schema
2 Medium pre_aggregations_compiler.rs:605-622 The plain-first preference is applied per side before comparability is checked; a greedy per-side pick can fail a hop that a different (available) pairing would have resolved
3 Medium pre_aggregation.rs:136-145 The join pass still overwrites the time_dimensions pass (diverges if the rollupJoin's own granularity differs from the hop's), and the flat member→column map can't represent a member stored differently by two hops' leg rollups
4 Low pre_aggregations_compiler.rs:579-593 find_map lets declaration order pick the granularity when a rollup declares the same base dimension twice; granularity compared by name, so same-named custom granularities with different definitions compare equal
5 Low PreAggregations.ts:1108-1116 JSDoc largely restates the two filters; only the canUseNativeSqlPlannerPreAggregation rationale is load-bearing

Findings 1, 2 and 4 are all limitations rather than regressions — each of these hops failed outright before the change, since the time-dimension key was invisible to the lookup. The concern is that the new errors read as verdicts about the schema when they're really about the resolution strategy.

Security / performance

Nothing to raise. No new input surface; the added work is filtering over pre_aggrs_for_join, which is bounded by the rollups: list, and resolve_join_member is called at most twice per member during planning.

Testing

Coverage is good and the cases chosen are the ones that matter. Two gaps, both following from the findings above: a composite hop with two keys stored differently (finding 1), and the raw-vs-truncated fixture extended with a second from rollup that would match (finding 2). The JS unit tests cover the gate; there's no test that the legacy planner still rejects the widened case, which is the behavior the gate exists to preserve.

Not verified

I did not build or run the Rust or JS suites in this environment; the reported results (1328 planner tests, the Postgres integration file on both planners) are taken as stated.

• branch `tesseract-rollup-join-on-clause-aliases`

Comment on lines +543 to +550
// Every key of the hop has to be stored the same way, across both sides at once.
// The two sides carry no pairing between their members — comparing them side by
// side would let a raw column on one side line up with a truncated one on the other.
let members = || from_members.iter().chain(to_members.iter());
let stored_the_same_way = members().map(|m| &m.granularity).sorted().dedup().count() <= 1;
if stored_the_same_way {
return Ok(());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Composite hops that mix a plain key with a time-dimension key are rejected even when each pair is comparable.

Collapsing all four member lists into one granularity set means the check is "every key of the hop, on both sides, is stored identically" — but a hop can legitimately join on more than one member. For a join like ${CUBE}.customer_id = ${other.id} AND ${CUBE}.created_at = ${other.d}, from_members resolves to [customer_id → None, created_at → Some("day")] and to_members to [id → None, d → Some("day")]. The set is {None, Some("day")}, count 2, so this is rejected with "mixes join keys its rollups store differently" even though customer_id/id pair correctly as raw and created_at/d pair correctly at day.

Not a regression (before this PR the time-dimension key wasn't visible at all, so the hop failed anyway), but the new error message actively misdescribes the schema. If pairing really can't be recovered from ResolvedJoinItem, it'd be worth at least softening the message, or checking from_members' granularity set against to_members' as multisets rather than requiring a single global value. Fix this →

Comment on lines 136 to 145
if let PreAggregationSource::Join(join) = self.source().as_ref() {
for item in join.items.iter() {
for member in item.from_members.iter().chain(item.to_members.iter()) {
let alias = member.alias();
res.insert(member.full_name(), QualifiedColumnName::new(None, alias));
res.insert(
member.symbol.full_name(),
QualifiedColumnName::new(None, member.column.clone()),
);
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two things about this map that the change makes more reachable:

  1. The join pass still runs after the time_dimensions pass and still overwrites it. It's now consistent in the common case (both derive alias + "_" + granularity from the same base symbol), but if the rollupJoin declares td_dates.d at month while the hop resolves it at day, the join pass wins and the query's own time dimension renders d_day. check_join_members_comparable only compares the two sides of a hop; it never compares against the rollupJoin's own declared granularity.

  2. This is one flat member.full_name() → column map for the whole pre-aggregation, but the column is now per-leg-rollup. In a multi-hop join where the same member participates in two hops and the two chosen leg rollups store it differently (one plainly, one truncated), the last hop iterated wins and the other hop's ON clause renders a column that side doesn't have. Previously every entry was the bare alias, so this couldn't diverge. A cheap guard would be to error on an inconsistent re-insert here rather than let it resolve to whichever hop came last.

Comment thread packages/cubejs-schema-compiler/src/adapter/PreAggregations.ts
@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.64%. Comparing base (6c75c60) to head (4806ba9).
⚠️ Report is 12 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff             @@
##           master   #11747       +/-   ##
===========================================
+ Coverage   59.08%   84.64%   +25.56%     
===========================================
  Files         237      261       +24     
  Lines       19022    86220    +67198     
  Branches     3865        0     -3865     
===========================================
+ Hits        11239    72982    +61743     
- Misses       7222    13238     +6016     
+ Partials      561        0      -561     
Flag Coverage Δ
cube-backend ?
cubesql 84.64% <ø> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…d refuse what it can't tell apart

A rollup can store the same dimension at several granularities, each in its own
column. Taking the first match let declaration order decide which column a join
reads, so an ambiguous key is now rejected by name.

The comparability error named only the members, though each side's rollup was
chosen on its own and that choice is never revisited; it now names the rollup on
each side and how each stores the key, which is what tells the author which of
the two to change.

Members are read from one column per pre-aggregation, so a member shared by two
hops that resolves to different columns would leave one ON clause naming a column
its side doesn't have. Rejected up front.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 2m 4s —— View job


Re-review of 4aa41b6 — 3 of 5 prior findings addressed; 1 new medium. 1 inline comment posted.

Full review
  • Read the new commit's diff
  • Check which prior findings are addressed
  • Resolve stale threads / post new findings

What the new commit fixes

Prior finding Status
4 — find_map let declaration order pick the granularity Fixed. resolve_join_member now collects all matching time dimensions and rejects len() > 1 by name, with a fixture (..._two_granularities.yaml)
5 — JSDoc restated the filters Fixed. Trimmed to the load-bearing canUseNativeSqlPlannerPreAggregation reason, 3 lines
3(2) — flat member→column map, last hop wins Fixed. check_join_members_resolve_consistently rejects a member resolving to two columns across hops, before the plan is built
2 — greedy per-side pick isn't backtracked Addressed as asked — the comment at pre_aggregations_compiler.rs:528 now states it plainly. Resolved the thread; the limitation stands, documented
1 — composite hop mixing a plain and a truncated key Still open. The message is better (it names each rollup and how it stores each key) but the rule is unchanged, so a hop joining customer_id = id AND created_at = d is still rejected. Thread left unresolved
3(1) — join pass overwrites the time_dimensions pass Still open. check_join_members_resolve_consistently compares hops against each other, never against the rollupJoin's own declared granularity, so a rollupJoin declaring d at month over legs storing day still renders d_day. Thread left unresolved

The error-message rework is the strongest part of this commit. Naming both rollups and how each stores the key turns "these keys disagree" into "change one of these two rollups", which is the actual next action.

New finding

# Severity Where Issue
6 Medium pre_aggregations_compiler.rs:685 The ambiguity error propagates out of the whole candidate scan, so one ambiguous rollup in rollups: fails the hop even when another candidate resolves cleanly. Deferring the error until widened is empty keeps both properties

Testing

The new ..._two_granularities.yaml fixture and its test are right for the ambiguity rejection. Two gaps remain, both matching open findings: a two-candidate side where one is ambiguous (finding 6), and a composite hop mixing a plain and a truncated key (finding 1).

Not verified

I did not build or run the Rust or JS suites in this environment.

• branch `tesseract-rollup-join-on-clause-aliases`

A rollup storing the join key at several granularities is no candidate for the
hop, but failing the whole scan on it hides the rollups that can serve it — the
list is walked in declaration order, so a usable rollup listed after it was never
reached. Keep the reason instead and report it only when nothing else stands in
either, where it is the actual cause rather than a generic rejection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 1m 21s —— View job


Re-review of 4806ba9 — the prior medium is fixed as suggested; no new blocking findings. 2 prior threads still open by design.

Full review
  • List existing review threads
  • Read the new commit's diff
  • Check prior open findings against current code
  • Resolve stale threads / post findings

What the new commit fixes

Finding 6 — the ambiguity error propagating out of the whole candidate scan — is fixed, and fixed the way that keeps both properties: an ambiguous rollup drops out of widened, its reason is stashed in ambiguous, and it is surfaced only when found_pre_aggr ends up empty (pre_aggregations_compiler.rs:707). So a rollup listed before a usable one no longer fails the hop, and when ambiguity really is the cause the message still says so instead of the generic "no rollups found".

rollup_join_time_dimension_key_ambiguous_candidate.yaml is the right fixture for it: td_dates_wide_rollup (day at both day and month) is listed first in rollups:, so the test fails on the previous commit and passes on this one. Asserting on td_dates__td_dates_rollup present and td_dates__td_dates_wide_rollup absent pins the choice rather than just the absence of an error. Thread resolved.

Prior threads left open

Finding Why it's still open
1 — a composite hop mixing a plain key with a truncated one is rejected (check_join_members_comparable) Rule unchanged; the message now names both rollups and how each stores each key, which is a real improvement, but customer_id = id AND created_at = d is still refused. Documented, not fixed
3(1) — the join pass in pre_aggregation.rs:136-145 runs after and overwrites the time_dimensions pass check_join_members_resolve_consistently compares hops against each other only; a rollupJoin declaring d at month over legs storing day still renders d_day for the query's own time dimension

Both are limitations rather than regressions (each of these shapes failed outright before the PR), and both are stated in code comments now. Nothing here I'd hold the PR on.

One small thing, not worth an inline thread

Which reason survives in ambiguous depends on member iteration order: the inner loop breaks on the first member that fails, so a rollup that lacks member A but stores B ambiguously records nothing, while the reverse order records the ambiguity. And a rollup can be reported as ambiguous when fixing its ambiguity wouldn't make it a candidate anyway (it's also missing another key). The message is still true about that rollup, just not necessarily the actionable cause — acceptable for a best-effort hint, which is what the comment claims it is.

Testing

The distinguishing case for finding 6 is now covered. The two remaining gaps line up with the two open threads: a composite hop mixing a plain and a truncated key, and a rollupJoin whose own declared granularity differs from the hop's.

Not verified

cargo check/cargo test and the JS suites were not run — the build commands aren't permitted in this environment, so the reported results (1328 planner tests, the Postgres integration file on both planners) are taken as stated.

• branch `tesseract-rollup-join-on-clause-aliases`

@waralexrom
waralexrom merged commit 1d08fc0 into master Sep 4, 2026
230 of 238 checks passed
@waralexrom
waralexrom deleted the tesseract-rollup-join-on-clause-aliases branch September 4, 2026 15:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

javascript Pull requests that update Javascript code rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants