fix(tesseract): never serve a calendar time shift partly from a rollup - #11962
waralexrom wants to merge 11 commits into
Conversation
…ation A calendar `time_shift` is not arithmetic on the time column — it is the join condition. The plan joins the fact table on the shifted key and labels rows by the reporting period. Replacing the fact scan with a rollup dropped the calendar cube from the FROM, so the shift was applied nowhere and a prior-year measure silently repeated the current period. A calendar query is now answered one of two ways, never a mix: - everything the shift reads is materialized — served entirely from pre-aggregation tables, which a `rollup_join` between the fact rollup and a rollup of the calendar makes possible; - it is not — the whole query falls back to the source database. `shift_for_substituted_column` therefore refuses any calendar-cube time dimension, `can_carry_time_shifts` gains the one exception above, and `CalendarTimeShiftSqlNode` asks the same question before rendering so the gate and the renderer cannot disagree. `extract_date_range` no longer offsets a pre-aggregation's range by an interval that only names a calendar mapping. Fixes a second, unrelated defect with the same shape: a granularity defined with `sql` is read from a stored column, so it cannot be assembled from a finer rollup nor from a dimension kept at several granularities at once. Both are refused; previously they rendered the cube's own table into a rollup scan, producing `missing FROM-clause entry`, or returned another granularity's column. The test harness never materialized a `rollup_join` — every existing test of one is plan-only — so each referenced rollup is now built from its own definition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Store The claim that such a query needs no source table rested on reading the generated SQL. Running it proves it, and turned up a requirement the SQL alone does not show: CubeStore joins rollups only on an indexed column, so both sides of the `rollup_join` must declare an index on their join key. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rollups The example was missing the indexes Cube Store requires to join rollups, so a model copied from it would fall back to the source database with no hint why. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…m JS Exercises the path a deployment actually takes — YAML model, CubeEvaluator, the native planner, Postgres — rather than the planner alone. Writing it turned up two model requirements the Rust fixtures had only by accident: - a `time_shift` must be declared on the dimension the query groups by; declared only on the primary key, the interval is applied to the key and the calendar's mapping is skipped entirely; - `rollup_join` accepts a singular `time_dimension`, not the plural form the mock schema happens to parse. The calendar's first retail year is 53 weeks, so "one year back" is 371 days for some rows and 364 for others. No interval reproduces that, which is what makes the assertions discriminating rather than merely green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The seed mapped a prior year to a constant 364 days, which is still arithmetic: it discriminates against the two bugs that happened to exist and would accept any implementation that guessed the same constant. A 4-5-4 calendar does not work that way. The first retail year is now 53 weeks, so "one year back" is 371 days for one band of rows and 364 for another. The CubeStore snapshots cover both, and one rollup answering 4090 -> 9 in one year and 8094 -> 4090 in the next cannot be produced by any interval. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`calendar_shifts_are_stored` counted a stored time dimension whatever its granularity, but such a column holds a period while the shift joins on an exact value. A rollup keeping the mapped column at, say, month grain was admitted and then rendered the untruncated column name, which does not exist. Only an untruncated column now counts: a plain dimension or a time dimension at the calendar's own row grain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
|
Claude finished @waralexrom's task in 7m 40s —— View job Review complete — 1 docs bug, 1 unexercised test, 2 low. Core fix looks right.Full review
VerdictThe shape of the fix is correct: a calendar Findings
All four posted inline. Not posted inline, summary only: Things I checked that are fine
Review threadsListing returned 0 of my own threads ( Not run
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #11962 +/- ##
==========================================
+ Coverage 80.52% 84.89% +4.37%
==========================================
Files 508 261 -247
Lines 107359 87535 -19824
Branches 4043 0 -4043
==========================================
- Hits 86450 74314 -12136
+ Misses 20337 13221 -7116
+ Partials 572 0 -572
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:
|
…r mapping" This reverts commit 99e1fe1. The guard did not fix what it claimed. `calendar_shifts_are_stored` reads the member list the matched pre-aggregation declares itself, so for a `rollup_join` the mapped column is found there as a plain dimension whatever the referenced rollups physically store. Re-running the shape that prompted the change shows it still fails with `column "..._prev_year_date" does not exist`. Its test did not cover it either: the fixture never declared the mapped column on the join, so it fell back for the same reason as the sibling fixture that omits the column, and deleting the guard left the test green. Filtering stored time dimensions to `day` also assumed a calendar has one row per day, which nothing in the model states, and cost acceleration for a rollup legitimately keeping the column at its own row grain. The real gap stands: a `rollup_join` declaring the mapped column while a referenced rollup stores it truncated renders a column name that does not exist. Closing it needs the referenced rollups' stored grain, which `PreAggregationSource::Join` does not carry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The example could not serve a shift as written. It omitted the mapped column from the `rollup_join`'s own members, which is the list that decides whether the shift can be served, and used the plural `time_dimensions` that `rollup_join` does not accept. It now matches the model the tests exercise. Also records why a calendar dimension is refused even when its shift resolved to plain arithmetic: the interval is recorded against the calendar's primary key rather than the queried dimension, so nothing offsets the stored column and the rows come back unshifted rather than merely unaccelerated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 9m 10s —— View job Review complete — 1 high, 1 medium, 2 low; 4 inline comments postedFull review
VerdictThe core insight is right and the modelling is careful: a calendar Two defects worth addressing before merge, both in Findings
What I did not verifyNo warm cargo target here and a cold Smaller notes, not posted inline
Review threadsListing returned 4 unresolved threads, all from earlier rounds of this review. All 4 resolved, each with a reply saying why — three were addressed by commits |
| .all(|member| { | ||
| !time_shifts.has_shift_under(member) | ||
| || time_shifts.shift_for_substituted_column(member).is_some() | ||
| || calendar_shifts_are_stored |
There was a problem hiding this comment.
The exception is a per-stage boolean OR'd into a per-member predicate, so it excuses members the renderer never rewrites.
shift_for_substituted_column is asked per member and answers "this member's stored column can carry its own shift". calendar_shifts_are_stored answers a different question — "the stage's calendar shifts read only stored columns" — and once true the .all() closure accepts every read grouping member with a shift under it, without asking whether that member is one the shift rewrites.
CalendarTimeShiftSqlNode rewrites only a dimension whose own full_name() is a key of shifts, i.e. the calendar's time-shift primary key (calendar_time_shift.rs:70). A stored member that merely depends on the shifted calendar dimension — a view re-export of retail_calendar.retail_date, or a fact-cube dimension declared sql: "{retail_calendar.retail_date}" and listed in the rollup — satisfies has_shift_under but is rendered by the reference branch as its stored, unshifted column. Before this change shift_for_substituted_column returned None for exactly those (reference, or non-time) and the pre-aggregation was refused; now they pass whenever some other member in the stage happens to make the boolean true.
I could not build the crate here to produce the failing snapshot, so treat the specific shape as unverified — but the asymmetry between "the stage's shifts are storable" and "this member carries its shift" is visible in the code. Scoping the exception to the member — its shift is a calendar shift whose sql dependencies are stored, i.e. it is the member the renderer rewrites — closes it and keeps the gate and the renderer asking the same question, which is what the surrounding comments promise.
The range a usage carries becomes `matchedTimeDimensionDateRange`, which `PreAggregationPartitionRangeLoader.partitionRanges` intersects with the build range to pick the partitions it loads. A calendar maps the period through its own table, so the rendered filter asks for rows the reporting band does not contain and a partitioned rollup would come back empty. Offsetting the range by the declared interval was wrong in the other direction; neither band describes those rows, so the honest answer is to decline pruning. Also extracts the predicate the gate and the renderer share into `CalendarDimensionTimeShift::renders_from_stored`, so the promise that they cannot disagree is structural rather than a comment, and trims the explanatory comments that had grown past three lines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 9m 50s —— View job Review complete — 1 high, 1 medium (re-affirmed), 3 low. The rollup-only rule is right; the gate that enforces it doesn't check for the join it depends on.Full review
Posted inlineHigh — Low — Low — Not inlinePR description is stale. "Only an untruncated stored column counts as holding the mapping" describes 99e1fe1, reverted in 351bd6d. The revert message states the gap plainly — a Docs, low. The What holds upThe binary rule is the right call: no rollup scan can reproduce a table mapping, and a mixed plan could not run on Cube Store anyway. The 53-week fixture year earns its keep: Review threadsThe listing returned 4 of my own threads. Resolved 2 — the duplicated |
…just the column Storing the mapped column was taken as enough, but the mapping is applied where the calendar's primary key is rendered — the condition joining two rollups. A plain rollup that happened to store the column passed the gate, was served from a single scan with no `ON` anywhere, and returned the current period as the prior one: the very symptom this branch fixes, re-admitted for one model shape. The gate now requires the primary key to be a member of the pre-aggregation's join. Two narrower corrections alongside it: `extract_date_range` asked about a calendar shift inside a loop that returns on the first date-range filter, so a second time dimension ordered ahead of the shifted one still pruned partitions; and the render-side guard keyed on the shifted member being substituted, while the shape it exists to catch — a mapping rendered into a rollup scan — only needs the query to read from a pre-aggregation at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 5m 47s —— View job Review: sound fix; 3 inline findings (1 medium, 2 low), 1 prior thread still openFull review
VerdictThe shape of the fix is right. A calendar The 53-week first retail year in both fixtures is what makes the tests worth their length — Findings
Docs read accurately against the code: the Not raised inline: the Review threadsThe listing returned 5 of my own threads. Resolved 4 as addressed by the current diff: the hoisted calendar-shift check in Not run: |
| return Ok(false); | ||
| } | ||
| let stored = pre_aggregation | ||
| .dimensions | ||
| .iter() | ||
| .chain(pre_aggregation.segments.iter()) | ||
| .map(|member| member.full_name()) | ||
| .chain( | ||
| pre_aggregation | ||
| .time_dimensions | ||
| .iter() | ||
| .map(|member| PreAggregation::stored_time_dimension_column(member).0), | ||
| ) |
There was a problem hiding this comment.
The stored set counts a time dimension by its base member name whatever grain it is truncated to, and for a rollup_join it reads the join's own declared member list rather than what the referenced rollups physically store. Both halves of that were the subject of 99e1fe1 and its revert 351bd6d, and after the revert the gate admits a shape it cannot render:
- name: calendar_rollup_coarse
time_dimensions:
- dimension: prev_year_date # the column the shift maps through
granularity: month
- dimension: date_val
granularity: day
- name: demand_with_calendar
type: rollup_join
dimensions: [retail_calendar.prev_year_date]
rollups: [demand.demand_rollup, retail_calendar.calendar_rollup_coarse]prev_year_date is on the join's dimensions, so stored contains it and the shift is admitted; the calendar rollup only has ..._prev_year_date_month, and the query fails with column "retail_calendar__prev_year_date" does not exist. Your own revert message says the same and calls the gap live.
Two asks, neither of which is the full fix:
- The PR description still claims "Only an untruncated stored column counts as holding the mapping", which is no longer true. That sentence should go, and the
rollup_joingap should be listed alongside the multi-granularity one under Known limitation — the revert commit message is the only place it is currently recorded. - A failure that hard-errors on a legitimate-looking model deserves a test, even an
#[ignore]d one, so the next change to this gate sees it. The harness can now reproduce it: thePreAggregationSource::Joinarm added intest_context.rs:766builds each referenced rollup from its own definition, which is exactly what the pre-revert fixture could not do.
| // Without that join nothing rewrites and the stage reads unshifted. | ||
| let joins_on = |primary_key: &String| match pre_aggregation.source.as_ref() { | ||
| PreAggregationSource::Join(join) => join.items.iter().any(|item| { | ||
| item.from_members | ||
| .iter() | ||
| .chain(item.to_members.iter()) | ||
| .any(|member| member.symbol.full_name() == *primary_key) | ||
| }), | ||
| _ => false, | ||
| }; |
There was a problem hiding this comment.
joins_on matches the primary key by symbol.full_name() and ignores PreAggregationJoinMember::granularity, which is set a few files over precisely to record that a key kept as a rollup's time dimension is truncated (pre_aggregations_compiler.rs:649-658).
check_join_members_comparable does not cover the gap: it only requires the two sides be truncated the same way. Keep the key at month on both rollups and it passes, then the shift replaces the calendar side of the ON with the untruncated mapped column, so the condition compares demand__demand_date_month to retail_calendar__prev_year_date and the shifted stage returns nothing. The mapping joins on an exact value, so this wants
.any(|member| member.symbol.full_name() == *primary_key && member.granularity.is_none())| /// The member a stored time-dimension column stands for, and the column's | ||
| /// name. The one place this naming is decided. | ||
| pub fn stored_time_dimension_column(dimension: &Rc<MemberSymbol>) -> (String, String) { |
There was a problem hiding this comment.
"The one place this naming is decided" is not true: resolve_join_member builds the same {alias}_{granularity} column independently at pre_aggregations_compiler.rs:649-658. Either call this helper there or drop the sentence — a comment asserting a single source of truth that does not exist is what lets the two drift.
…ot serve Three shapes measured rather than assumed: - reporting by a `sql`-overridden fiscal week, which an earlier reading had called impossible. The join key and the reporting label are different columns and need not share a grain: the calendar's rollup keeps the primary key at `day`, so the shifted join still matches an exact value, while the week column beside it carries the label; - grouping by a calendar dimension that is not a time dimension at all, which is an ordinary stored dimension like any other; - a plain rollup holding the mapped column but no join to apply it, which must fall back rather than read the period unshifted. Documents the week variant and states that the join has to name the calendar's primary key, which is the member a shift replaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @waralexrom's task in 7m 50s —— View job Review complete — no new inline comments; 4 prior threads re-affirmed (2 medium correctness, 2 low)Full review
VerdictThe shape of the fix is right. Making The non-calendar half is also correct: Test coverage is unusually good — 19 planner tests with both positive controls and must-fall-back mirrors, the 53-week fixture that no interval can reproduce, a live Cube Store run, and a JS test through Review threadsThe listing returned 4 unresolved threads, all authored by
Two findings I reached independently this round — the truncated stored column at One note on the first of those, since the PR body and the code disagree: the description's third bullet says "Only an untruncated stored column counts as holding the mapping: a stored time dimension carries a period, while the shift joins on an exact value." Commit Not raised inline
|
Summary
A calendar
time_shiftis not arithmetic on the time column — it is a mapping held in thecalendar's own table, which Cube applies by joining the fact table on the shifted key. A
rollup stores its rows joined on the unshifted key, so replacing the fact scan with one
dropped the calendar cube from the
FROMand the shift was applied nowhere: a prior-yearmeasure silently returned the current period's value, and disabling the pre-aggregation was
the only way to get correct numbers.
The answer is now binary. Either everything the shift reads is materialized and the query is
served entirely from pre-aggregation tables, or the whole query falls back to the source
database — never a mix of a rollup and a live calendar in one query, which could not run on
Cube Store anyway.
Changes
shift_for_substituted_columnrefuses any calendar-cube time dimension: no offset of astored column reproduces a table mapping.
can_carry_time_shiftsgains one exception — every member the shift'ssqlreads isstored by the pre-aggregation — which a
rollup_joinbetween the fact rollup and a rollupof the calendar satisfies.
CalendarTimeShiftSqlNodeasks the same question beforerendering, so the gate and the renderer cannot disagree.
carries a period, while the shift joins on an exact value.
extract_date_rangeno longer offsets a pre-aggregation's range by an interval that merelynames a calendar mapping.
sqlis read from a stored column, so it can be served neither by a finer rollup nor by adimension kept at several granularities at once. Both previously rendered the cube's own
table into a rollup scan (
missing FROM-clause entry) or returned another granularity'scolumn.
which omitted the indexes Cube Store requires to join rollups.
Testing
cargo test -p cubesqlplannerwith--features integration-cubestore: 1431 passed, 0failed; clippy clean.
live Cube Store. This is the first
rollup_joinin the repository exercised on Cube Store;every other one is plan-level or runs on Postgres, which is why the harness had to learn to
build each referenced rollup from its own definition.
model,
CubeEvaluator, the native planner, Postgres.some rows and 364 for others. No interval reproduces that, so the snapshots discriminate:
one rollup answers
4090 -> 9in one year and8094 -> 4090in the next.Known limitation, not addressed here
A rollup declaring one time dimension at several granularities exposes a single stored column
for all of them — the reference map is keyed by member alone — so the last declaration wins
and a query for another grain can silently read the wrong column. This predates the change
and is not calendar-specific; the guard added here covers only the
sql-granularity half.Fixing it properly means making the reference map granularity-aware.
🤖 Generated with Claude Code