Skip to content

fix(schema-compiler,query-orchestrator): bound the rollupLambda source query - #11708

Open
ovr wants to merge 1 commit into
masterfrom
fix-rolluplambda-unbounded-union
Open

fix(schema-compiler,query-orchestrator): bound the rollupLambda source query#11708
ovr wants to merge 1 commit into
masterfrom
fix-rolluplambda-unbounded-union

Conversation

@ovr

@ovr ovr commented Aug 31, 2026

Copy link
Copy Markdown
Member

Check List

  • Tests have been run in packages where changes have been made if available
  • Linter has been run for changed code
  • Tests for the changes have been added if not covered yet
  • Docs have been added / updated if required

Issue Reference this PR resolves

#11682

Description of Changes Made

A rollupLambda with union_with_source_data issued a live source query on every request that was lower-bounded by FROM_PARTITION_RANGE and unbounded above, so it aggregated and CSV-downloaded the whole tail of the source table even when the requested range sat entirely inside already-built partitions — ~7.0 s and 1,271,521 rows per request at granularity: minute in the reporter's repro, to return a single number. The source query is now bounded by the same range that already selects partitions (the matchedTimeDimension/filters block moves out of preAggregationDescriptionFor() into PreAggregations.matchedTimeDimensionDateRangeFor(), so nothing is recomputed and the two halves of the union cannot disagree), and PreAggregationPartitionRangeLoader skips the download outright when the requested range ends at or before the last partition's buildRangeEnd. Separately, the maxSourceRowLimit valve turned out to be inert under Tesseract — the default planner — because query_properties_compiler.rs reads rowLimit via parse::<usize>().ok(), so the __MAX_SOURCE_ROW_LIMIT sentinel resolved to None and no LIMIT was emitted at all; the limit is now resolved to a number and echoed on LambdaQuery so the row-count check matches the SQL that actually ran. Requests carrying no date range are deliberately untouched and still expose all fresh data, which the smoke suite asserts. Three pre-existing bugs found nearby are left out of scope and will be filed separately: downloadLambdaTable does not convert its FROM_PARTITION_RANGE substitution to UTC (off-by-offset lower bound on non-UTC timezones), multi-usage descriptions never add the lambda table to their per-usage unions, and the rowCount === maxSourceRowLimit check should be >=.

Generated source SQL for the reporter's model, dateRange: ['2024-02-01', '2024-02-29']:

planner before after
legacy WHERE ts > $1 GROUP BY 1 LIMIT $2 WHERE ts > $1 AND (ts >= $2 AND ts <= $3) GROUP BY 1 LIMIT 200000
native WHERE ts > $1 GROUP BY 1 WHERE ts > $1 AND (ts >= $2 AND ts <= $3) GROUP BY 1 LIMIT 200000

Verified (every new test confirmed red on unmodified source first):

suite result
cubejs-schema-compiler unit 805 passed / 21 failed — baseline on reverted source is 802/21, so no regressions (the 21 are pre-existing: error-reporter, FILTER_PARAMS native, member-references)
cubejs-query-orchestrator unit 120 / 120
cubejs-schema-compiler integration postgres, rollup lambda pass
cubejs-testing smoke-lambda 8 / 8, incl. query with 2 dimensions and query month
eslint on all changed files 0 errors

The new schema-compiler cases were also run under CUBEJS_TESSERACT_SQL_PLANNER=false and pass on both planners.

@ovr
ovr requested review from a team as code owners August 31, 2026 13:06
@github-actions github-actions Bot added the javascript Pull requests that update Javascript code label Aug 31, 2026
…e query

A rollupLambda pre-aggregation with unionWithSourceData issued a live source
query on every request. It was lower-bounded by FROM_PARTITION_RANGE and had no
upper bound, so it aggregated and CSV-downloaded the entire tail of the source
table even when the requested range sat entirely inside already-built
partitions - every row of which the outer query then discarded. In the
reporter's repro a query returning 696 cost ~330-390 ms at granularity day and
~7.0 s at granularity minute, streaming 1,271,521 rows into a Cube Store inline
table per request.

Three defects, not the two named in the issue:

- buildLambdaQuery() built the sub-query from references.timeDimensions, which
  carry only {dimension, granularity}, so the requested range never reached it.
- loadPreAggregations() downloaded the lambda table whenever a lambdaQuery was
  present, without checking whether the requested range even reached past the
  last partition's buildRangeEnd.
- Under Tesseract - the default planner - the source query carried no LIMIT at
  all. query_properties_compiler.rs reads rowLimit as parse::<usize>().ok(), so
  the __MAX_SOURCE_ROW_LIMIT sentinel resolved to None and the maxSourceRowLimit
  safety valve had silently stopped protecting anything. The row limit is now
  resolved to a number before the options cross the bridge, and echoed back on
  LambdaQuery so the orchestrator checks the count against the limit that was
  actually rendered.

Generated source SQL for the reporter's model, dateRange 2024-02-01..2024-02-29:

| planner | before | after |
| --- | --- | --- |
| legacy | `WHERE ts > $1 GROUP BY 1 LIMIT $2` | `WHERE ts > $1 AND (ts >= $2 AND ts <= $3) GROUP BY 1 LIMIT 200000` |
| native | `WHERE ts > $1 GROUP BY 1` | `WHERE ts > $1 AND (ts >= $2 AND ts <= $3) GROUP BY 1 LIMIT 200000` |

The bound is not a new computation: the matchedTimeDimension/filters block moves
out of preAggregationDescriptionFor() into
PreAggregations.matchedTimeDimensionDateRangeFor(), so buildLambdaQuery and the
orchestrator skip both decide from the range that already selects partitions.
lambdaSourceDateRange() wraps it to mirror the multi-usage mergeUsageDateRanges
overwrite, and bounds nothing when a usage range is unknown. The inDateRange
filter stays separate from the afterDate one on purpose: inDateRange's lower
bound is inclusive and would double-count rows sitting exactly at buildRangeEnd.

Requests carrying no date range are untouched and still expose all fresh data,
which the smoke suite asserts.

Fixes #11682

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ovr
ovr force-pushed the fix-rolluplambda-unbounded-union branch from 5cc95c8 to 0f4b3bf Compare August 31, 2026 13:12
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ovr's task in 4m 25s —— View job


Review complete — solid, well-targeted fix; 1 medium regression risk, 1 medium correctness gap, 3 low (4 inline comments posted)

Full review
  • Gather context / read full diff
  • Review cubejs-schema-compiler changes (BaseQuery, PreAggregations)
  • Review cubejs-query-orchestrator changes (PartitionRangeLoader, PreAggregations)
  • Review tests
  • Check stale review threads (none existed)
  • Post review

Overall

The diagnosis is convincing and the shape of the fix is right: reusing the same range that already drives partition selection to bound the source half of the union is exactly the invariant you want, and hoisting it into matchedTimeDimensionDateRangeFor() makes that shared origin explicit rather than duplicated. Keeping afterDate(FROM_PARTITION_RANGE) and the new inDateRange as two separate filters (inclusive vs. exclusive lower bound) is the correct call and the comment explaining why is the kind that saves a future reader. The lambdaSourceDataCovered() skip is safe by construction — the source query can only ever contribute rows strictly after buildRangeEnd, so an upper bound at or below it makes the interval empty. Test coverage is genuinely good: the undefined-range and Z-suffix cases are the two that would have bitten, and the "stays unbounded above without a date range" test pins the behavior you deliberately preserved.

Nothing security-relevant: the new bounds go through the normal filter/param path, no string interpolation of user input. The LIMIT is the one value now inlined into SQL, and it comes from env/config, not from the request.

Findings

# Sev Where Issue
1 Medium BaseQuery.js:1100-1104 preAggregationsOptions.maxSourceRowLimit config becomes inert — this.options.maxSourceRowLimit is not a compiler query option, so it always falls to env, and the loader now prefers the baked value over the orchestrator's configured one
2 Medium PreAggregationPartitionRangeLoader.ts:385-392 Skip check reads a per-description matchedTimeDimensionDateRange while the SQL is bounded by the union of usage ranges; R.uniqBy(tableName) keeps only the first, so a multi-usage query can skip a download whose bound was widened for another usage
3 Low PreAggregationPartitionRangeLoader.ts:426 rowCount === appliedRowLimit is more fragile now that the limit is inlined at compile time; >= fails closed (PR already notes this as out of scope)
4 Low PreAggregations.ts:327 matchedTimeDimensionDateRangeFor() is now called an extra time per lambda query, re-running the un-memoized allBackAliasMembers() — contradicts the "nothing is recomputed" claim in the description
5 Low PreAggregations.ts:322 The early return undefined on !partitionGranularity means a rollupLambda referencing a single non-partitioned rollup keeps the old unbounded source query. That's a real config (checkPartitionGranularityDefined is only enforced for i > 0), so the issue is only fixed for partitioned lambdas. Worth a note in the PR body if intentional.

Smaller notes (no inline comment)

  • this.logger('Skipping lambda source query', …) — surrounding orchestrator log events use Title Case names (Load Request Success, Load Pre Aggregation). Cosmetic, but custom logger handlers tend to switch on these strings.
  • reformatInIsoLocal normalizes to fixed-width DATETIME_LOCAL_MS, so the lexicographic <= in lambdaSourceDataCovered() is sound. Good use of an existing helper rather than pulling in moment comparisons.
  • lambdaSourceDateRange() returning undefined on an unknown usage range ("may need anything, so bound nothing") is the right fail-open default, and it correctly mirrors the mergedDateRange && desc.matchedTimeDimensionDateRange guard in preAggregationDescriptionsForUsageInfos().
  • The new schema-compiler test asserting LIMIT 200000 will fail on any machine with CUBEJS_MAX_SOURCE_ROW_LIMIT set. Harmless in CI; consider asserting against getEnv('maxSourceRowLimit') instead of the literal.
  • The source query's cache key now varies with the requested date range, so distinct ranges no longer share a renewQuery entry. That's inherent to the fix and almost certainly a net win, just worth being aware of for cache-entry churn on high-cardinality ranges.
  • The line 364 access references.timeDimensions[0].dimension remains unguarded, but that's pre-existing and unreachable without partitionGranularity.

Docs

No docs change needed — this is behavior-preserving from a user-facing API standpoint. If finding #1 is resolved by keeping the env-only resolution, maxSourceRowLimit docs should say the config option no longer applies to the lambda source query.

· branch [`fix-rolluplambda-unbounded-union`](https://github.com/cube-js/cube/tree/fix-rolluplambda-unbounded-union)

Comment on lines +1100 to +1104
// Tesseract renders LIMIT as an inline number and can't carry the MAX_SOURCE_ROW_LIMIT
// placeholder (it parses to None, emitting no LIMIT at all), so resolve it here.
const resolvedRowLimit = this.options.maxSourceRowLimit ?? getEnv('maxSourceRowLimit');
const maxSourceRowLimit = typeof resolvedRowLimit === 'number' && resolvedRowLimit > 0
? resolvedRowLimit

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.

Baking the limit here silently drops support for the preAggregationsOptions.maxSourceRowLimit config option.

Two things line up badly:

  • this.options.maxSourceRowLimit is not a schema-compiler query option — nothing in the repo ever puts it there (grep maxSourceRowLimit only finds preAggregationsOptions in OptsHandler.ts:634, LambdaOptions in the orchestrator, and the env getter). So this always falls through to getEnv('maxSourceRowLimit') (default 200000).
  • The runtime value that used to win is the orchestrator's this.options.maxSourceRowLimit, which OptsHandler fills from preAggregationsOptions.maxSourceRowLimit or env. That is what the MAX_SOURCE_ROW_LIMIT placeholder was substituted with in downloadLambdaTable.

So for a user with preAggregationsOptions: { maxSourceRowLimit: 5000 } in cube.js and no CUBEJS_MAX_SOURCE_ROW_LIMIT, after this change the source SQL gets LIMIT 200000, and because PreAggregationPartitionRangeLoader:425 prefers this.lambdaQuery.maxSourceRowLimit ?? this.options.maxSourceRowLimit, the guard also checks against 200000 — the configured 5000 becomes completely inert (previously it was honored on the legacy planner).

Either plumb preAggregationsOptions.maxSourceRowLimit into the compiler query options (so this.options.maxSourceRowLimit is real), or keep the placeholder when it hasn't been resolved from an authoritative source and only bake the number on the native path.

Fix this →

Comment on lines +385 to +392
private lambdaSourceDataCovered(buildRangeEnd?: string): boolean {
const matchedRangeEnd = this.preAggregation.matchedTimeDimensionDateRange?.[1];
if (!matchedRangeEnd || !buildRangeEnd) {
return false;
}
// buildRangeEnd comes back from the DB and may carry a `Z` suffix.
return reformatInIsoLocal(matchedRangeEnd) <= reformatInIsoLocal(buildRangeEnd);
}

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.

The skip decision and the SQL bound can disagree in the multi-usage case, and the disagreement is in the unsafe direction.

lambdaSourceDateRange() bounds the SQL by the union of every usage range for the rollupLambda, but this check reads this.preAggregation.matchedTimeDimensionDateRange, which comes from one description. With several usage infos, preAggregationDescriptionsForUsageInfos() produces one description per usage (each with its own merged range) and then preAggregationsDescription() collapses them with R.uniqBy(desc => desc.tableName) (schema-compiler/src/adapter/PreAggregations.ts:145) — the first one wins.

So if usage A ends inside the built range and usage B extends past it, the surviving description carries A's range, lambdaSourceDataCovered() returns true, and the fresh tail that B's SQL bound was explicitly widened to fetch is never downloaded. Before this change the download always ran.

Given the PR already notes that multi-usage descriptions don't add the lambda table to their per-usage unions, this may be masked today — but the two computations should be fed from the same value rather than relying on that. Consider having the schema compiler stamp the effective source range onto LambdaQuery (next to maxSourceRowLimit) and comparing against that here, so the skip can never be wider than the bound actually rendered into the SQL.

const partitionInvalidateKeyQueries = queryForSqlEvaluation.partitionInvalidateKeyQueries?.(cube, preAggregation);
if (!preAggregation.partitionGranularity) {
return undefined;
}

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.

The PR description says "so nothing is recomputed", but extracting this into a public method actually adds a computation: matchedTimeDimensionDateRangeFor() is now called once per description from preAggregationDescriptionFor() and once more from lambdaSourceDateRange() (via buildLambdaQuery). Each call re-runs this.query.allBackAliasMembers(), which is not memoized (BaseQuery.js:5472backAliasMembers(flattenAllMembers()), which traverses symbols and evaluates member SQL — see the warning comment at BaseQuery.js:5406).

Worth memoizing either allBackAliasMembers() or this method per foundPreAggregation, since the whole point of the change is to make lambda requests cheaper.

if (data.rowCount === this.options.maxSourceRowLimit) {
throw new Error(`The maximum number of source rows ${this.options.maxSourceRowLimit} was reached for ${this.preAggregation.preAggregationId}`);
const appliedRowLimit = this.lambdaQuery.maxSourceRowLimit ?? this.options.maxSourceRowLimit;
if (data.rowCount === appliedRowLimit) {

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.

Minor, and the PR already flags it as out of scope: now that the limit is inlined into the SQL by the compiler rather than substituted at runtime, an === check is more fragile than before — any divergence between the two values (see the config-option issue on BaseQuery.js) turns the guard from "throw" into "silently return truncated data". >= costs nothing here and fails closed.

Suggested change
if (data.rowCount === appliedRowLimit) {
if (data.rowCount >= appliedRowLimit) {

@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 72.34043% with 13 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.03%. Comparing base (92d1595) to head (0f4b3bf).

Files with missing lines Patch % Lines
...ejs-schema-compiler/src/adapter/PreAggregations.ts 67.74% 10 Missing ⚠️
...orchestrator/PreAggregationPartitionRangeLoader.ts 72.72% 3 Missing ⚠️

❗ There is a different number of reports uploaded between BASE (92d1595) and HEAD (0f4b3bf). Click for more details.

HEAD has 1 upload less than BASE
Flag BASE (92d1595) HEAD (0f4b3bf)
cubesql 1 0
Additional details and impacted files
@@             Coverage Diff             @@
##           master   #11708       +/-   ##
===========================================
- Coverage   79.91%   59.03%   -20.88%     
===========================================
  Files         498      237      -261     
  Lines      104694    18975    -85719     
  Branches     3841     3851       +10     
===========================================
- Hits        83661    11202    -72459     
+ Misses      20472     7208    -13264     
- Partials      561      565        +4     
Flag Coverage Δ
cube-backend 59.03% <72.34%> (+0.22%) ⬆️
cubesql ?

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.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant