Skip to content

fix(tesseract): keep time_shift when a pre-aggregation serves the query - #11599

Merged
waralexrom merged 8 commits into
masterfrom
tesseract-time-shift-view-preagg-date-range
Aug 24, 2026
Merged

fix(tesseract): keep time_shift when a pre-aggregation serves the query#11599
waralexrom merged 8 commits into
masterfrom
tesseract-time-shift-view-preagg-date-range

Conversation

@waralexrom

@waralexrom waralexrom commented Aug 19, 2026

Copy link
Copy Markdown
Member

Summary

A multi_stage measure with time_shift silently returned wrong values whenever a pre-aggregation served the query. Time shifts are keyed by the fully resolved cube member, but the lookups that decide the shifted leaf's date range and rendering probed with an unresolved name, so the shift was dropped. Fixes CORE-767 / #11536.

Two distinct shapes were affected:

  • Through a view — the shifted leaf kept the unwidened pre-aggregation date range, so it scanned a partition set that could not contain its rows and every row came back NULL. The identical query against the cube was correct.
  • On a derived time dimension (a time dimension wrapping another cube's time dimension, with the rollup materializing it) — the shift was lost entirely, including from the rendered SQL, so the shifted measure repeated the current period instead of the previous one. The same query without a pre-aggregation was correct.

Changes

  • Add a single normalized lookup on TimeShiftState and route the shift lookups through it, so the key-resolution rule lives in one place instead of being re-derived per call site. It probes both ways a key is built: the chain-resolved dimension, and the owned member a declared dimension wraps.
  • Apply the shift to the rollup column when the pre-aggregation substitutes a dimension: its SQL is never expanded, so the recursion that normally carries the shift to the owned member never happens. Gated on dimensions known to be substituted — an evaluated dimension must still wait for the recursion, or the interval would be added twice.
  • Treat a shift with no interval as "no shift" rather than unwrapping it, matching the other consumers of the same lookup.
  • member_name() is deliberately left alone: its other callers compare against query-level names, where view qualification is consistent.

Only Tesseract is fixed. The legacy planner has the same defect and is untouched.

Testing

  • Both fixes were landed test-first and each test was confirmed red for the stated reason before the fix — for the view case by dumping the matched usages (both matched the rollup, both with the unwidened range), for the derived case by observing the executed rows.
  • Cube-level and view-level tests now sit side by side. The derived-dimension test asserts the widened range, that every rendered interval sits directly on the rollup column (so a doubled shift cannot pass), and the executed values against a seed holding a period before the queried range.
  • cargo test -p cubesqlplanner: 1225 passed.
  • With Postgres + CubeStore (--features integration-cubestore): 1216 passed. The 9 switch_rolling failures are pre-existing on this branch's base — verified against a baseline run with the fixes reverted — and come from the older released cubestored binary used locally.

One limitation worth recording: the view fix is pinned only by assertions on the plan. The widened range decides which rollup partitions get loaded, and the test harness builds each rollup as one whole table, so no seed can make the executed rows discriminate. Filed as CORE-805.

waralexrom and others added 3 commits August 18, 2026 17:17
A multi_stage measure with time_shift returned NULL for every row when
queried through a view while a pre-aggregation was matched. The shifted
leaf scanned a partition set that could not contain its rows.

Time shifts are keyed by the fully resolved cube member: QueryProperties
builds them from all_time_members(), which peels the TimeDimension
wrapper and follows the reference chain. extract_date_range probed that
map with BaseFilter::member_name(), which resolves neither, so a
view-qualified filter never found its shift and the range was left
un-widened.

Add TimeShiftState::get_for_symbol, which normalizes the probe the same
way the keys are built, and route the lookup sites through it. Preferred
over a fallback second lookup so the key-normalization rule lives in one
place instead of being re-derived per call site; member_name() is left
alone because its other callers compare against query-level names, where
view qualification is consistent. TimeShiftSqlNode keeps its own probe:
it is guarded on a non-reference symbol, and resolving there would apply
the shift twice.

Covered by a view-level test sitting next to the existing cube-level one,
asserting the shifted and unshifted usages carry different date ranges.

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

A time_shift declared on a time dimension that wraps another cube's time
dimension was lost entirely once a pre-aggregation served the query: the
shifted leaf read the same rows as the unshifted one, so the shifted
measure silently repeated the current period instead of the previous one.
The same query without a pre-aggregation was correct.

Dimension-specific shifts are keyed by the owned member the declared
dimension wraps, because that is where the interval lands when the
member's SQL is expanded. Two things then went wrong when the rollup
materialized the derived dimension instead:

- extract_date_range probed only the chain-resolved name, so the range
  was never widened. get_for_symbol now probes the owned child too,
  covering both ways a key is built.
- The rollup column is substituted for the dimension, so its SQL is never
  expanded and the recursion that normally carries the shift down to the
  owned member never happens. TimeShiftSqlNode now applies the shift to
  the column itself, but only for dimensions it knows are substituted —
  an evaluated dimension must still wait for the recursion, or the
  interval would be added twice.

Covered by a test asserting the widened range, a single shift on the
rollup column, and — on a seed holding a period before the queried range
— the executed values.

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

Fall through instead of unwrapping a shift with no interval, so
TimeShiftSqlNode treats it as "no shift" like the other two consumers of
the same lookup rather than panicking.

Assert the single-application invariant in the derived-dimension test by
requiring every rendered interval to sit directly on the rollup column,
instead of matching one exact textual form of a doubled shift.

Record what the view test's executed rows do and do not cover: the widened
range only selects rollup partitions, which the harness does not emulate —
it loads each rollup whole — so only the assertions on the usages guard the
widening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the rust Pull requests that update Rust code label Aug 19, 2026
@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 5m 19s —— View job


Review complete — looks correct and well-tested; 0 blocking, 1 medium, 3 low (posted as 3 inline comments)

Full review
  • Gather context (read diff, base at 528d755)
  • Review TimeShiftState::get_for_symbol
  • Review time_shift.rs / factory.rs rendering path
  • Review optimizer.rs + physical base_filter.rs call sites
  • Review tests & fixtures
  • Post review

Verdict

The diagnosis holds up under reading. Both lookups (extract_date_range in optimizer.rs:498, filter-params in physical_plan/filter/base_filter.rs:28) probed dimensions_shifts with a name that cannot match the keys built in query_properties.rs:821 (ts.dimension.full_name(), the resolved cube member), and centralising that in get_for_symbol is the right shape. Two things I checked specifically and that come out clean:

  • Node ordering. TimeShiftSqlNode is constructed outermost in dimension_processor (wrapping CalendarTimeShiftTimeDimensionNodeRenderReferencesSqlNode), so the substituted-dimension fallback yields "col" + interval rather than shifting an expression that then gets replaced. Correct by construction.
  • No double shift. The ungated first probe is exact-name, and only the substituted-gated probe resolves through the chain. An evaluated derived dimension therefore still gets its interval from the recursion into the owned member, once. The shifts_on_column == shifts assertion in the new test pins this, which is the right thing to assert.
  • Granularity keys. TimeDimensionSymbol::full_name() is granularity-suffixed (time_dimension_symbol.rs:79), so the chain-end probe can miss when the chain lands on a TimeDimension; the find_owned_by_cube_child fallback covers it. The two probes genuinely complement each other.

member_name() being left alone is the right call and the reasoning in the PR body matches what its other callers do.

Findings

Sev Where What
Medium time_shift_state.rs:28-35 get_for_symbol probes only the chain end and the deepest owned leaf, and swallows find_owned_by_cube_child's error with .ok()?. A derived time dim with >1 dependency (COALESCE({CUBE.a}, {CUBE.b})) gets no shift from the lookup while the renderer still shifts a/b — the same silently-unwidened-range bug, one shape over. Also skips shifts keyed on an intermediate derived level.
Low time_shift.rs:55-66 interval: None went from unwrap() panic to a silent unshifted render. Better than panicking, but a named shift escaping extract_time_shifts (the else arm at time_shift_state.rs:87 inserts it verbatim) now yields silently wrong numbers. Consider an explicit error or a why-unreachable comment.
Low time_shift.rs:55-66 The exact-name/resolving-name asymmetry between the two probes is the no-double-shift argument, but it's only recoverable from the PR description. One line of comment would carry it.
Low factory.rs:289-297 TimeShiftSqlNode clones the whole RenderReferences map to call contains_key on it. A HashSet<String> (or Rc<…>) says "these names are columns" more directly.

Nothing on security (no new input handling, no user-controlled strings reaching SQL that weren't already there) and nothing on docs (internal planner fix, no user-facing surface).

Tests

Good shape: cube-level and view-level cases sit side by side, the derived-dimension test asserts the widened range and that every interval lands on the rollup column, and the seed deliberately holds a period before the queried range. Two small notes, neither worth blocking:

  • sql.matches("interval '1 month'") is dialect-literal. It's the assertion that gives the test teeth, so I'd keep it, but it will break silently-usefully if the Postgres template ever changes its interval rendering.
  • The stated CORE-805 limitation is accurate — with the harness building each rollup as one whole table, the widened range cannot change the executed rows, so the view case really is pinned by plan assertions only. Worth keeping that ticket visible; a partitioned-rollup fixture is what would close it.

What I could not verify

cargo is not in this job's allowlist, so I could not run cargo test -p cubesqlplanner or cargo clippy myself. The reported 1225-pass / 1216-pass integration run and the pre-existing switch_rolling failures are taken from the PR body, unverified here. Re-run locally (or grant Bash(cargo test:*)) if independent confirmation matters.

• branch [`tesseract-time-shift-view-preagg-date-range`](https://github.com/cube-js/cube/tree/tesseract-time-shift-view-preagg-date-range)

Comment thread rust/cube/cubesqlplanner/cubesqlplanner/src/physical_plan/sql_nodes/time_shift.rs Outdated
Comment on lines 289 to 297
let input = if !self.time_shifts.is_empty() {
TimeShiftSqlNode::new(self.time_shifts.clone(), input)
TimeShiftSqlNode::new(
self.time_shifts.clone(),
self.pre_aggregation_dimensions_references.clone(),
input,
)
} else {
input
};

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.

TimeShiftSqlNode only ever calls contains_key on this map, but it takes a full RenderReferences clone (a HashMap<String, RenderReferencesType>) — the third clone of the same map in this function (lines 204, 269, 292). Cheap in absolute terms, but it also couples the shift node to a rendering-substitution type it doesn't render from. A HashSet<String> of substituted names (or Rc<RenderReferences>) would express "these names are columns, not expressions" more directly and drop the copy.

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.59%. Comparing base (528d755) to head (4c2c63b).
⚠️ Report is 49 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff            @@
##           master   #11599    +/-   ##
========================================
  Coverage   79.58%   79.59%            
========================================
  Files         480      485     +5     
  Lines       99544   100011   +467     
  Branches     3636     3675    +39     
========================================
+ Hits        79224    79599   +375     
- Misses      19801    19865    +64     
- Partials      519      547    +28     
Flag Coverage Δ
cube-backend 59.34% <ø> (+0.06%) ⬆️
cubesql 84.10% <ø> (+0.04%) ⬆️

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.

waralexrom and others added 2 commits August 19, 2026 17:49
A shift entry reaching the renderer without an interval was rendered
unshifted, turning a state the map calls shifted into silently wrong
numbers. Return an error instead.

Also record why the first probe is by exact name: a dimension that gets
evaluated picks its shift up when the recursion reaches the owned member
it wraps, so matching it at the outer level too would add the interval
twice. Only a substituted dimension, never expanded, resolves through the
chain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A time dimension built from several members, only some of which the shift
covers, has no valid offset of its stored column: moving the column would
carry along the rows the shift must leave in place. The rollup was matched
anyway and the shift was dropped, so the shifted measure silently repeated
the unshifted one.

Reject such a pre-aggregation during matching. The unrewritten leaf then
triggers the existing rollback of the whole multi-stage rewrite and the
query falls back to base SQL, which computes the shift correctly.

The gate is tied to the shift lookup rather than re-deriving reachability:
reject exactly when a shift is involved but cannot be attributed to the
stored column. Re-deriving the rule would add a second place obliged to
stay in step with the lookup.

The test's expected values were captured from the same query with
pre-aggregations disabled, before the gate existed. They differ from what
offsetting the stored column would produce, which is what rules that
approach out.

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

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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


Re-review of the 2 new commits — both prior blocking-ish concerns addressed; 1 new medium (posted inline), 2 low notes

Full review
  • Fetch base, diff 2f86135..a9078f4
  • Review fail loudly on a time shift with no interval (f5744b6)
  • Review skip a rollup that cannot carry the query's time shift (a9078f4)
  • Re-check prior unresolved threads against current code
  • Post review

What changed since my last pass

f5744b6 — no-interval shift now errors. Some(shift) with interval == None returns CubeError::internal naming the dimension instead of rendering unshifted. That's what I asked for: this node is the last place that could notice, and the previous silent fallthrough turned an escaped named shift into wrong numbers. The added comment above the two probes also records the exact-name/resolving asymmetry that the no-double-shift argument rests on. Both halves of my time_shift.rs thread are closed — resolved it.

a9078f4 — unattributable shift now skips the rollup. has_shift_under walks the whole dependency tree probing full_name() at each level, so it answers "is a shift involved anywhere below this column", while get_for_symbol answers "can I attribute one to this column". Where they disagree, the pre-aggregation is skipped and the query falls back to base SQL. This is a better answer than the "walk every level in get_for_symbol" I suggested: it fixes the failure mode rather than widening the lookup, and it covers both shapes I raised —

  • multi-dependency derived dimension (COALESCE({CUBE.a}, {CUBE.b}), shift on one branch) — find_owned_by_cube_child errs → skip;
  • shift keyed on an intermediate derived level — has_shift_under sees it via recursion, get_for_symbol can't attribute it → skip.

Both now degrade to "correct but slower" instead of "silently wrong". Resolved that thread too. The reasoning comment on can_carry_time_shifts is unusually good — it states why the two predicates are allowed to disagree, which is the non-obvious part.

The new test asserts pre_aggrs.is_empty() with the offending name in the failure message, plus a base-SQL value snapshot. Right assertion for a "must not match" test.

New finding

Sev Where What
Medium optimizer.rs:483-503 can_carry_time_shifts scans only time_dimensions, but make_pre_aggregation_source substitutes dimensions as columns too (optimizer.rs:406-412). A time-type dimension under a rollup's dimensions: that depends on a shifted member bypasses the guard, so the shifted leaf offsets one column while reading another computed from the unshifted value — same inconsistency the guard exists to prevent. Posted inline with a fixture and a two-line patch.

Low notes (no inline comment)

  • has_shift_under has no memo or visited set. It re-walks shared sub-dependencies, so a diamond-shaped member graph is exponential in depth. Depths are tiny in practice and it runs once per pre-aggregation candidate, so this is a note, not a request — but if member graphs ever get deep it's the kind of thing that shows up as a mystery planning stall.
  • Shift interval vs. stored granularity. The derived test confirms the interval lands on "pa_customers__return_day_month" — the granularity-truncated column. That commutes for a whole-month shift, but a sub-granularity interval (say 7 day against a month rollup) would give month_start + 7 days from the rollup where base SQL gives date_trunc('month', day + 7 days), so the stage join keys wouldn't line up. As far as I can tell this predates the PR (a plain shifted time dimension read from a rollup already offset the granularity column), so I'm recording it rather than attributing it — worth confirming that rollup matching rejects a shift that isn't a multiple of the stored granularity.

Re-affirmed prior thread

factory.rs:297TimeShiftSqlNode still takes a full RenderReferences clone to call contains_key. Unchanged, so the thread stays open; not reposting.

What I could not verify

cargo isn't in this job's allowlist, so cargo test -p cubesqlplanner / clippy were not run here. The PR body's counts are taken as reported. One thing worth a local check: can_carry_time_shifts sits inside the per-candidate loop, so it also runs for the whole-query rewrite path where time_shifts is TimeShiftState::default() — that early-returns true, so no existing rollup should lose its match, but a full suite run is the real confirmation.

• branch [`tesseract-time-shift-view-preagg-date-range`](https://github.com/cube-js/cube/tree/tesseract-time-shift-view-preagg-date-range)

… shift

The gate scanned only a pre-aggregation's time dimensions, but dimensions
and segments are substituted by column just the same. A dimension built
from a partially shifted member slipped through, and the shifted leaf then
read it computed from unshifted values: both leaves rendered identically,
so the shifted measure repeated the unshifted one.

Check every member the pre-aggregation stores. The type is not what
matters — any stored column computed from a shifted member is wrong when
read unshifted — so dimensions and segments are checked whatever they hold.

Tests cover all three ways such a member reaches a rollup: as its time
dimension, under dimensions, and through a segment. Each was confirmed to
fail with its own part of the gate removed. Their snapshots pin what makes
the stored column unusable: the row the shift leaves in place lands on the
same key in both stages, which no offset of a single column reproduces.

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

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

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


Re-review of 628e116 — prior medium addressed (and widened past what I asked); 1 new low-medium posted inline, 1 prior thread re-affirmed

Full review
  • Diff a9078f4..628e116
  • Review the widened can_carry_time_shifts
  • Check the remaining member kinds (measures) against the same reasoning
  • Review new fixture, seed change, and the two tests
  • Resolve stale threads / dedupe against prior comments
  • Post review

What changed

628e116 extends can_carry_time_shifts from time_dimensions to time_dimensions ∪ dimensions ∪ segments. That's what my optimizer.rs:508 thread asked for, plus segments — which I'd missed and which is right for the same reason: make_pre_aggregation_source chains all three into the schema (optimizer.rs:406-412), and factory.rs:199-206 short-circuits segment wrapping precisely because a stored segment is already a column. A segment built over a partially shifted dimension is a boolean computed from unshifted values; no offset recovers it. Resolved that thread.

The fixture additions are the right shape. Adding batch_at as an unshifted time dimension is the key move: it makes the rollup's time dimension clean so the only thing that can reject the candidate is the stored dimensions:/segments: entry. Without that, the test would have been passing for the old reason and proved nothing. Both new tests assert pre_aggrs.is_empty() with the offending names in the message, and the base-SQL snapshots show the discriminating behaviour — row 3 (happened_at IS NULL) staying in February while every other row moves, which is exactly what no column offset can reproduce.

New finding

Sev Where What
Low-Medium optimizer.rs:501-506 The comment now claims "every member the pre-aggregation stores is checked", but measures is still outside the chain. A measure whose SQL references the shifted time dimension (sql: "CASE WHEN {CUBE.happened_at} >= … THEN val END") re-enters the dimension branch of RootSqlNode when expanded, so base SQL shifts it while the rollup returns a column aggregated from unshifted values. has_shift_under/get_for_symbol already answer correctly for a measure symbol; only the iteration misses it. Posted inline with the one-line patch and two caveats (it over-rejects relative to the dimension case, and pre_aggregation.measures is the unfiltered list). Excluding measures is a defensible scope call — but then the comment should say so.

Notes carried forward (not re-posted)

  • factory.rs:297TimeShiftSqlNode still clones the whole RenderReferences map to call contains_key. Thread left open, unchanged.
  • has_shift_under has no visited set. Now called up to 3× more members per candidate, so the re-walk of shared sub-dependencies got proportionally wider. Still tiny in practice (depths are small, once per candidate), still a note rather than a request — but the constant factor moved in the wrong direction, so if member graphs ever get deep this is where it shows.
  • Shift interval vs. stored granularity. Unchanged from my last pass and predates this PR: a sub-granularity interval against a coarser rollup gives month_start + 7 days where base SQL gives date_trunc('month', day + 7 days). Worth confirming separately that matching rejects a shift that isn't a multiple of the stored granularity.

What I could not verify

cargo is not in this job's allowlist, so cargo test -p cubesqlplanner and clippy were not run here. Two things I'd specifically want from a local run:

  • That the widened guard doesn't cost existing rollup matches. dimensions and segments are far more commonly populated than a rollup's time_dimension, so this commit has more blast radius than the previous one — any rollup carrying a derived dimension whose graph touches a shifted member now falls back to base SQL. has_shift_under returning false for the overwhelming majority should keep that at zero, but the suite is the confirmation.
  • That both new tests are red without the commit. The batch_at addition strongly suggests they were written that way, but I can't observe it.

• branch tesseract-time-shift-view-preagg-date-range

waralexrom and others added 2 commits August 24, 2026 12:00
Three ways the gate reached the wrong verdict, each confirmed by comparing
the rollup plan against the same query without one.

A measure was not examined at all. One whose SQL reads the shifted
dimension is stored aggregated from unshifted values, and no offset
recovers it, because a shift changes which rows feed an aggregate rather
than the value itself. Measures are therefore rejected outright whenever a
shift reaches them, never merely attributed — attribution succeeds for a
measure with a single dependency and would have admitted exactly the
broken case. Only the measures the match consumes are examined.

A stored reference to the shifted dimension was admitted although the
renderer resolves a reference through to what it points at instead of
offsetting the column, leaving it unshifted. The gate asked whether a
shift could be attributed while the renderer asked whether it would apply
one; the two are now the same question, asked through
`shift_for_substituted_column`, so they cannot disagree again.

A member the query never reads could reject the whole rollup. Nothing
renders such a column, so it cannot make the stored data wrong; the gate
now looks only at the members the node actually reads, matching how
measures were already treated.

Tests cover all five ways a shift reaches a rollup — time dimension,
dimension, segment, measure, reference — plus the case that must stay
matched. Each was confirmed to flip with its own part of the gate removed,
and the kept-rollup values were checked against a base-SQL run rather than
against the rollup that produced them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A FILTER_PARAMS column bound to a time dimension derived from another
cube's is offset in the shifted stage and left bare in the unshifted one.
Both forms are asserted, so losing the offset and applying a spurious one
are equally caught. Without the shift lookup resolving through the
derivation, the shifted stage filtered the source rows by unshifted bounds
while grouping by shifted values.

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

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

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


Re-review of e7e3970+4c2c63b — prior medium resolved; 0 blocking, 3 low posted inline

Full review
  • Diff 628e116..4c2c63b
  • Review the per-member gate (e7e3970)
  • Review the new filter-column test (4c2c63b)
  • Re-check prior open threads / resolve stale ones
  • Post review

What changed

e7e3970 — the gate moved and got sharper in both directions.

Three things happen at once and they fit together well:

  • shift_for_substituted_column replaces the raw get_for_symbol at both the gate and the render node. It adds the two preconditions the renderer already enforced locally (!is_reference() && is_time()) into the lookup itself. That's the right factoring — the comment's claim that "the two cannot come to different conclusions" is now structurally true rather than a convention, and it fixes a real asymmetry: a stored reference to the shifted dimension is rendered through to its target rather than offset, so the gate previously admitted a rollup whose column would never be shifted. The new ..._on_stored_reference_dimension test pins it.
  • Measures are now in the gate, via matched_measures — and the reasoning is better than what I suggested last pass. Gating on !has_shift_under(measure) alone, with no attribution attempt, is correct for the stated reason: a measure column holds an aggregate and a shift changes which rows feed it, so no offset exists even in principle. And narrowing to the matched set instead of pre_aggregation.measures avoids the over-rejection I'd flagged as the cost of the simple version. Both caveats from my last comment are answered. Resolved that thread.
  • read_member_names narrows the grouping check to members the query actually reads. This is the part I looked hardest at, since it's the only change that adds rollup matches. It holds up:
    • Skipping an unread stored dimension is sound — it only sets the rollup's grain, and re-aggregating across a grain the shift doesn't touch is exact.
    • The granularity-mismatch hole I went looking for isn't there: resolve_base_symbol strips the TimeDimension wrapper (typed_filter.rs:23-29) before full_name(), so a rollup at day and a query at month normalise to the same key. Both sides go through the same helper, so they can't drift.
    • Pushed-down filter members are in the read set (find_all_member_evaluators over dimensions_filters/time_dimensions_filters/segments), which is what 4c2c63b's test pins from the other end.

The gate's new position is safe. Moving it from try_rewrite_query into try_rewrite_simple_query means try_rewrite_schema_and_filter (optimizer.rs:208) is ungated — but that path is unreachable under a shift, because AggregateMultipliedSubquery with an evaluation_context is rejected outright at optimizer.rs:321. And the gate returns before make_pre_aggregation_source, so a rejected candidate leaks no usage_counter.

4c2c63b is a good test to have: it pins the FILTER_PARAMS-pushed column from the opposite direction (the shifted stage must offset it, the unshifted stage must not), which is the one place where getting the shift right on the column and wrong on the pushed-down predicate would still produce plausible-looking numbers.

Findings

All three are low; none affect behaviour.

Sev Where What
Low optimizer.rs:516 Inserting read_member_names between the rationale comment and can_carry_time_shifts re-parented all three paragraphs onto the wrong function — the one they explain now has no comment at all, 30 lines down. read_member_names' own one-liner is also glued to the measures paragraph with no blank line. Plus a dropped word: Only the measures matching consumed are examined.
Low optimizer.rs:186 read_member_names is an eager argument, so it runs before can_carry_time_shifts reaches its is_empty() early return — full symbol resolution and a String per member, discarded on every no-shift match (including the whole-query path at line 104, which always passes TimeShiftState::default()). Suggestion inline.
Low integration_multi_dep_time_dim_shift_pre_agg.yaml:19-23 happened_at_ref was inserted between effective_at's comment and effective_at, so "built from two owned time dimensions" now sits directly above a single-dependency plain reference. Suggestion inline.

One more note, no comment posted: read_member_names omits filter.measures_filter. Harmless as far as I can tell — a HAVING measure reaches the gate through matched_measures instead, and a dimension read only through a measure filter isn't a shape I can construct — but the omission is silent, and the three lists it does chain make it look exhaustive.

Prior threads

  • Resolved optimizer.rs:562 (measures excluded from the gate) — addressed by e7e3970.
  • Still open, not reposted: factory.rs:297, TimeShiftSqlNode taking a full RenderReferences clone to call contains_key. Unchanged.
  • Carried forward: has_shift_under still has no visited set (now also called over matched measures, so a little wider); and the sub-granularity-interval-vs-stored-granularity question from my second pass, which predates this PR and is worth confirming separately.

Tests

The three new integration tests are well chosen — two rejections and, importantly, one acceptance (..._keeps_rollup_when_unshiftable_member_unused) that pins the new permissiveness with assert_eq!(pre_aggrs.len(), 2). Worth noting for the record that this test's value snapshot cannot discriminate: the shift is on happened_at, nothing in the query reads it, so total_prev_month == total on every row whether or not the rollup is used. The count assertion is the whole test. That's the correct assertion for the claim, just don't read the snapshot as independent confirmation.

The ..._on_measure_reading_shifted_dimension snapshot does discriminate nicely — row 1 NULL, row 2 NULL/2000.00 — values a column aggregated from unshifted rows can't reproduce.

What I could not verify

cargo isn't in this job's allowlist, so cargo test -p cubesqlplanner and clippy were not run here; the PR body's counts are taken as reported. The one thing I'd want from a local run is that ..._keeps_rollup_when_unshiftable_member_unused is red without e7e3970 — it's the only new test asserting a match, so it's the only one that could pass for the wrong reason.

• branch [`tesseract-time-shift-view-preagg-date-range`](https://github.com/cube-js/cube/tree/tesseract-time-shift-view-preagg-date-range)

// matching consumed are examined, since the rest are never read.
// Resolved names of every member the query reads, so a stored member no
// one reads cannot decide anything.
fn read_member_names(schema: &LogicalSchema, filter: &LogicalFilter) -> HashSet<String> {

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.

Inserting read_member_names here re-parented the whole rationale block. All three paragraphs — "A stored member is shifted by offsetting its column…", the grouping-members paragraph, and the measures paragraph — now read as the doc comment for read_member_names, which is a name-collection helper they say nothing about. can_carry_time_shifts, the function they actually explain, is now 30 lines below with no comment of its own. The one-line doc that does belong to read_member_names ("Resolved names of every member the query reads…") is glued to the end of the measures paragraph with no blank line, so it reads as a continuation of it.

Moving read_member_names below can_carry_time_shifts (or the comment block down onto it) restores the pairing. Also, Only the measures matching consumed are examined looks like a dropped word — reads as if matching is the noun; Only the measures matching actually consumed them or Only the measures the match consumed are examined is presumably the intent.

Fix this →

if !Self::can_carry_time_shifts(
pre_aggregation,
&matched_measures,
&Self::read_member_names(&query.schema(), &query.filter()),

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.

read_member_names is an eagerly-evaluated argument, so it runs before can_carry_time_shifts gets to its time_shifts.is_empty() early return. Every successful pre-aggregation match therefore walks the full schema plus every filter's member evaluators, calls resolve_base_symbol().resolve_reference_chain() on each, and allocates a String per member into a fresh HashSet — then discards all of it, because time_shifts is empty for essentially every non-multi-stage query and for the whole-query rewrite path at line 104 (&TimeShiftState::default()).

Not hot enough to be a real problem (once per match, not per candidate), but it's free to avoid:

Suggested change
&Self::read_member_names(&query.schema(), &query.filter()),
if !time_shifts.is_empty()
&& !Self::can_carry_time_shifts(
pre_aggregation,
&matched_measures,
&Self::read_member_names(&query.schema(), &query.filter()),
time_shifts,
)
{

(the is_empty() check inside can_carry_time_shifts can stay as the invariant for direct callers).

Comment on lines +19 to +23
# Time dimension built from two owned time dimensions. The shift
# below covers only one of them, so the stored rollup column
# cannot represent the shifted values.
# Plain reference to the shifted dimension. Rendered through to what
# it points at, so a stored copy of it is never offset.

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.

happened_at_ref was inserted between effective_at's comment and effective_at, so the two comments are now stacked and the first one describes the dimension two entries below it. A reader hitting line 19 reads "built from two owned time dimensions" immediately above a single-dependency plain reference.

Suggested change
# Time dimension built from two owned time dimensions. The shift
# below covers only one of them, so the stored rollup column
# cannot represent the shifted values.
# Plain reference to the shifted dimension. Rendered through to what
# it points at, so a stored copy of it is never offset.
# Plain reference to the shifted dimension. Rendered through to what
# it points at, so a stored copy of it is never offset.
- name: happened_at_ref
type: time
sql: "{CUBE.happened_at}"
# Time dimension built from two owned time dimensions. The shift
# below covers only one of them, so the stored rollup column
# cannot represent the shifted values.

@waralexrom
waralexrom merged commit 23255e2 into master Aug 24, 2026
172 of 173 checks passed
@waralexrom
waralexrom deleted the tesseract-time-shift-view-preagg-date-range branch August 24, 2026 12:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants