Skip to content

fix(query-orchestrator): Bound cached pre-aggregation partition plans - #11896

Open
ovr wants to merge 2 commits into
masterfrom
reproduce-postgres-11860
Open

ovr wants to merge 2 commits into
masterfrom
reproduce-postgres-11860

Conversation

@ovr

@ovr ovr commented Sep 16, 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 (not required; no public API or configuration changes)

Issue Reference this PR resolves

Refs #11860

Description of Changes Made

Moving build ranges retained a partition plan and time-series grid for every refresh; retain only the latest plan per pre-aggregation identity within each base-query compiler cache and remove the separate time-series cache.
Replacement preserves arrays used by in-flight requests and the previous plan on failure, while cache hits still enforce maxPartitions.
Regression tests cover range changes, identity isolation and bounded retention with the real CompilerCache, adding schema-compiler as a dev dependency.

Validation: 224 orchestrator tests, 139 time/timezone tests, 160 expansion comparisons, TypeScript, oxlint and git diff --check; live PostgreSQL/CubeStore checks included 108 successful REST loads and 72 result comparisons, and the 40-refresh profile retained 10 plans with heap at 71.10 MiB versus 72.72 MiB after warmup.

@ovr
ovr requested a review from a team as a code owner September 16, 2026 12:23
@github-actions github-actions Bot added the javascript Pull requests that update Javascript code label Sep 16, 2026
@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ovr's task in 5m 33s —— View job


The fix addresses #11860's leak correctly. 1 medium, 1 low — both on the shape of the change, not the mechanism.

Full review

The fix does cover the reported symptom

Issue #11860 is unbounded growth of QueryCache.storage.expandPartitions.partitions[<buildRange>] — one full partition-description list retained per refresh-key renewal, never evicted because the CompilerCache LRU uses updateAgeOnGet: true and the scheduler reads the entry every run. Replacing that with one mutable slot per pre-aggregation identity, overwritten in place on a range change, bounds retention at one plan per identity. The 300-renewal test at PreAggregations.test.ts:1614 is the right regression shape for it.

Two things I checked that are fine:

  • Dropping the timeSeries cache costs nothing. It was only ever populated when compilerCacheFn was supplied, and the other loader construction site — PreAggregations.ts:568, the query/build path through loadPreAggregations()partitionRanges() — never passes one. So the only caller that benefited was partitionPreAggregations(), where the new plan cache subsumes it.
  • In-place Object.assign after the map is sound. Concurrent callers racing on one identity each return their own array; the loser's write just costs the next call a miss. The descriptions array is shared and then mutated by consumers (p.expandedPartition = true, PreAggregations.ts:709) but that was equally true of the old cached list.

Findings

Sev Where What
Medium PreAggregations.test.ts:10 Cross-package dist import + the new devDependency close a cycle with cubejs-schema-compiler; a clean yarn tsc can fail TS2307 because no project reference back is possible (TS6202). Also forces as any reach-ins into schema-compiler private state at lines 1569/1609/1622-1624.
Low PreAggregationPartitionRangeLoader.ts:447 Cache identity is a hand-picked 7-field subset while partitionPreAggregationDescription spreads all of this.preAggregation; correct today, silently stale when a field is added.

Worth calling out as a bonus: the old key ['partitions', JSON.stringify(buildRange)] carried no pre-aggregation identity at all, while every entry of preAggregationDescriptionList shares one compilerCacheFn scope. Any two pre-aggregations with the same build range collided. This diff fixes that too.

Not verified

node_modules is absent in this checkout, so I could not run yarn tsc, yarn test in cubejs-query-orchestrator, or yarn lint. The TS2307 finding is from reading the tsconfig reference graph, not from a failing build — please confirm with yarn clean && yarn tsc on a clean tree.

Review threads

The listing returned 0 threads of my own on this PR; none resolved, none re-affirmed.

· branch `reproduce-postgres-11860`

Comment thread packages/cubejs-query-orchestrator/test/unit/PreAggregations.test.ts Outdated
return this.compilerCacheFn(['partitions', JSON.stringify(buildRange)], () => partitionRanges.map(range => this.partitionPreAggregationDescription(range, buildRange)));
const buildRange = await this.effectiveDateRange();
const { preAggregationId, tableName, dataSource, timezone, partitionGranularity, timestampFormat, timestampPrecision } = this.preAggregation;
const identity = { preAggregationId, tableName, dataSource, timezone, partitionGranularity, timestampFormat, timestampPrecision };

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 identity is a hand-picked seven-field subset, but the cached value depends on all of this.preAggregation.

partitionPreAggregationDescription (line 206) spreads ...this.preAggregation into every description and additionally branches on partitionInvalidateKeyQueries/invalidateKeyQueries (line 212), updateWindowSeconds (line 221), loadSql, sql, indexesSql and previewSql. None of those are in the key. The scope that key lives in is not per-pre-aggregation either: PreAggregations.expandPartitionsInPreAggregations hands the same queryBody.compilerCacheFn to a loader for every entry of preAggregationDescriptionList (PreAggregations.ts:701), all running concurrently under Promise.all. Two descriptions in that list that agree on the seven fields but differ in, say, updateWindowSeconds or invalidateKeyQueries would serve each other's plans, with the second loader getting the first's sealAt and clipping.

I could not construct that collision from the current code — distinct entries in a list carry distinct preAggregationIds — so this is about the shape rather than a live failure: the subset is correct only as long as nobody adds a field that partitionPreAggregationDescription reads, and nothing makes that coupling visible at either site. Two options that do not carry the maintenance debt:

  • Key on what actually identifies the instance — preAggregationId + tableName + timezone — and drop the four fields that are already derived from it, with a line saying the rest of preAggregation is assumed constant for a given id within one compiler cache. Shorter key, same guarantee, and the assumption is written down.
  • Or key on a hash of the whole preAggregation minus matchedTimeDimensionDateRange, which is exact and stays exact, at the cost of one hash per call.

Worth noting the old key had no identity component at all (['partitions', JSON.stringify(buildRange)]), so any two pre-aggregations in one list sharing a build range collided outright — this diff fixes that as a side effect, which is a good catch.

@ovr
ovr requested a review from a team as a code owner September 16, 2026 13:41
@claude

claude Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ovr's task in 6m 50s —— View job


The fix matches the leak described in #11860 and I found nothing that breaks it — 1 low (inline), 1 prior thread re-affirmed, 1 resolved.

Review details

Verdict

The root cause in #11860 is correctly identified and the fix is the minimal one: replacing the range-keyed ['partitions', …] slot with a single ['partitionPlan', identity] slot bounds retention to one plan per pre-aggregation identity per CompilerCache entry, and dropping the ['timeSeries', …] slot removes the second unbounded map. Object.assign(cachedPlan, …) after a successful expansion is the right shape — a throw in partitionRangesForDateRange or partitionPreAggregationDescription leaves the previous plan intact, and readers holding the old array are unaffected.

What I verified

No regression from removing the timeSeries cache. compilerCacheFn is only ever non-trivial from RefreshScheduler.ts:170; on every other path PreAggregationPartitionRangeLoader.ts:90 installs the pass-through, so partitionRanges() was already uncached there. In the refresh path the scheduler expands first and then builds each partition with expandedPartition = true, so PreAggregationPartitionRangeLoader.ts:273 is not reached with a live compilerCacheFn. Nothing else reads the old keys.

maxPartitions semantics are preserved. On master the check sat outside the timeSeries cache call, so it ran on every invocation; checkMaxPartitions(cachedPlan.descriptions.length) on a hit is equivalent, because descriptions map 1:1 onto ranges.

The cached arrays are not mutated downstream. This is the risk the change introduces — on master a moving build range produced a fresh array every refresh, which masked any in-place mutation; now the same array survives across refreshes. Every consumer of groupedPartitionPreAggregations is read-only: RefreshScheduler.ts:453 and :529 use concat, :457 and :525 index and filter, and :531's cascadedPartitions.push targets a fresh concat result. PreAggregations.ts:711 sets p.expandedPartition = true on the description objects (idempotent, and already the behaviour on master). The only preAggregation.<field> = writes in the package are QueryOrchestrator.ts:372-373, and those hit the model definition, not a partition description.

QueryCache.cache truthiness is safe. QueryCache.ts:22 gates on !keyHolder[lastKey]; the seed { rangeKey: null, descriptions: [] } is truthy, so the slot is created once and the rangeKey === null mismatch drives the first expansion.

Timezone isolation holds for the real caller. RefreshScheduler.ts:519 passes { ...queryingOptions, timezone }, so each timezone already gets its own CompilerCache entry; timezone in the identity makes the partition-plan-cache.test.ts two-timezone-one-entry case correct as well.

CI ordering is fine. yarn tsc (.github/workflows/push.yml:133) runs before yarn lerna run unit (:137), so the new schema-compiler unit test resolves @cubejs-backend/query-orchestrator's dist built from this branch. query-orchestrator is already a devDependency at packages/cubejs-schema-compiler/package.json:63, and test/integration/postgres/pre-aggregations.test.ts already imports it, so the direction is established.

Findings

# Severity Where What
1 low test/unit/PreAggregations.test.ts:1588 const describe = jest.spyOn(...) shadows Jest's describe; plus the as any reach-ins that CLAUDE.md's subclass rule covers — posted inline

Re-affirmed 1 prior thread that still applies: the hand-picked seven-field identity at PreAggregationPartitionRangeLoader.ts:447 versus a description built from all of this.preAggregation (thread). The code at that line is unchanged and I again could not construct a live collision — distinct list entries carry distinct preAggregationIds — so it stays a shape concern, not a defect.

Resolved 1 of my own stale threads: the @cubejs-backend/schema-compiler devDependency / dist import cycle and the (query as any).storage reach-ins. Commit 5818834 took the option that thread suggested — the devDependency is gone from packages/cubejs-query-orchestrator/package.json and the CompilerCache coverage moved into packages/cubejs-schema-compiler/test/unit/partition-plan-cache.test.ts, which asserts bounded retention through a cache-miss counter rather than schema-compiler's private storage.

Not run

No tests, tsc or lint from me — node_modules is absent in this checkout and the sandbox denied yarn/npx, so the PR's own validation numbers are unverified. The findings above are from reading the code.

· [`reproduce-postgres-11860`](https://github.com/cube-js/cube/tree/reproduce-postgres-11860)

bounds.mockResolvedValue(['2024-01-01T00:00:00.000', '2024-01-03T00:00:00.000']);
await expect(loader.partitionPreAggregations()).rejects.toThrow('requested to build 3 partitions');
bounds.mockResolvedValue(rangeB);
const describe = jest.spyOn(loader as any, 'partitionPreAggregationDescription').mockImplementationOnce(() => { throw new Error('expansion failed'); });

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.

const describe = jest.spyOn(...) shadows Jest's global describe for the rest of the test body.

Nothing in the current body calls describe, so it passes — but the binding is live from line 1588 to the end of the arrow function, and anyone adding a nested block or moving a describe(...) into this scope gets TypeError: describe is not a function (or worse, a silent spy invocation) rather than a test. spy, describeSpy or expansion costs nothing here.

The related point: partitionPreAggregationDescription is private, which is why the call needs as any. CLAUDE.md asks for a widened subclass instead, and that works here once the method is protected:

class TestLoader extends PreAggregationPartitionRangeLoader {
  public partitionPreAggregationDescription(range: QueryDateRange, buildRange: QueryDateRange) {
    return super.partitionPreAggregationDescription(range, buildRange);
  }
  public partitionRanges(ignoreMatchedDateRange?: boolean) { return super.partitionRanges(ignoreMatchedDateRange); }
}

That also covers (loader as any).partitionRanges(...) at lines 1630, 1647 and 1648, and keeps both call sites type-checked against future signature changes — the partitionRanges signature moved in this very diff.

@codecov

codecov Bot commented Sep 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 61.77%. Comparing base (1acb83d) to head (747fe89).
⚠️ Report is 74 commits behind head on master.

❗ There is a different number of reports uploaded between BASE (1acb83d) and HEAD (747fe89). Click for more details.

HEAD has 1 upload less than BASE
Flag BASE (1acb83d) HEAD (747fe89)
cubesql 1 0
Additional details and impacted files
@@             Coverage Diff             @@
##           master   #11896       +/-   ##
===========================================
- Coverage   84.88%   61.77%   -23.12%     
===========================================
  Files         261      247       -14     
  Lines       87542    19861    -67681     
  Branches        0     4051     +4051     
===========================================
- Hits        74314    12270    -62044     
+ Misses      13228     7023     -6205     
- Partials        0      568      +568     
Flag Coverage Δ
cube-backend 61.77% <100.00%> (?)
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.

@ovr
ovr requested a review from a team as a code owner September 23, 2026 14:08
@claude

claude Bot commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ovr's task in 7m 40s —— View job


Approach is sound and it does address #11860 — 1 medium, 3 low posted inline; 2 prior threads re-affirmed; tests not executed (no dependency install available).

Full review

Todo

Verdict

The mechanism is right. Retaining one plan per pre-aggregation identity, keyed on the reference identity of the SqlResult.preAggregations array, does cut the unbounded growth described in #11860: with sqlCache on, CompilerApi.getSql returns the same array object across refresh runs, so the refresh worker lands on one live plan per (pre-agg, timezone) instead of one per refresh-key renewal. The sqlCache guard is correct — OptsHandler.ts:418 defaults it to true, and when it is off no array is reference-stable, so skipping the cache entirely is the right call rather than accumulating misses.

Things I checked and found fine:

  • checkMaxPartitions is still enforced on cache hits, with the current request's maxPartitions, not the one that built the plan.
  • The Object.assign replace-after-success ordering means a failed expansion leaves the previous plan intact, and in-flight holders of the old array are unaffected.
  • The timeSeries cache removal is not a regression: partitionRanges() is only reached from loadPreAggregations(), which never received a compilerCacheFn, so that cache was already a no-op passthrough. PreAggregations.test.ts asserts this.
  • No consumer mutates the returned arrays — groupedPartitionPreAggregations inner arrays are only read (RefreshScheduler.ts:451, :524), and the expandedPartition = true write at PreAggregations.ts:707 is idempotent.
  • release() clearing the cache is covered, including the cleanup-failure path.
  • No get/set race: both are synchronous within one tick before the first await, so two concurrent first-expansions share one Map rather than discarding one.

Findings

# Severity Location Issue
1 Medium OrchestratorApi.ts:27-31 max: 10000 is an entry count, not a heap bound, for MB-sized plans; hardcoded instead of honoring maxQueryCacheSize/maxQueryCacheAge; strong-ref array key outlives the compiler-cache generation that owns it
2 Low OrchestratorApi.ts:29 updateAgeOnGet: true is dead — line 310 sets unconditionally, which already re-arms TTL and recency
3 Low RefreshScheduler.ts:169CompilerApi.ts:928 compilerCacheFn has no remaining callers in the repo
4 Low partition-plan-cache.test.ts:155-162 hardcoded 10000 alongside a derived api.plans.max; 10001-iteration test proves lru-cache's own contract

Comments

All three added comments earn their place — OrchestratorApi.ts:26 explains the non-obvious object-identity key, :308 explains why set is called on hits, and PreAggregationPartitionRangeLoader.ts:460 explains the replace ordering. Each is one line. The only nit is that :308 implies updateAgeOnGet and the set are separate mechanisms, which is finding #2.

Review threads

The listing returned 2 unresolved threads with me as the first author. Resolved 0 — both concerns are still live against the current tree:

  • PreAggregationPartitionRangeLoader.ts:447 — the cache identity is a hand-picked seven-field subset, while partitionPreAggregationDescription (line 206) also reads updateWindowSeconds, partitionInvalidateKeyQueries, loadSql, sql, indexesSql and previewSql. Re-affirmed; I had the same finding this round and folded it into the existing thread rather than opening a second one. Since the cache is already scoped to the SQL-result array, keying the inner map on the description object itself is both exactly as unique and immune to drift as that function grows new inputs.
  • PreAggregations.test.ts:1588const describe = jest.spyOn(...) shadows Jest's global describe, and as any casts remain at lines 1630, 1647, 1648 where CLAUDE.md asks for a widened subclass. Unchanged; note partitionPreAggregationDescription and partitionRanges are private, so they need widening to protected in src before a subclass can reach them.

Not verified

I could not install dependencies in this environment (yarn install is not permitted here), so no test, tsc or lint run backs this review — it is static reading only. The PR body's claim of "adding schema-compiler as a dev dependency" also no longer matches the diff: no package.json is changed, which is consistent with 5818834 moving the test into cubejs-server-core. Worth correcting the description before merge.

· branch [`reproduce-postgres-11860`](https://github.com/cube-js/cube/tree/reproduce-postgres-11860)

Comment thread packages/cubejs-server-core/src/core/OrchestratorApi.ts Outdated
Comment thread packages/cubejs-server-core/src/core/OrchestratorApi.ts Outdated
Comment thread packages/cubejs-server-core/test/unit/partition-plan-cache.test.ts Outdated
Comment thread packages/cubejs-server-core/src/core/RefreshScheduler.ts
@claude

claude Bot commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Reviewing PR #11896

View job run

@ovr
ovr force-pushed the reproduce-postgres-11860 branch from 7ecf753 to 747fe89 Compare September 23, 2026 14:39
@claude

claude Bot commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Reviewing PR #11896

View job run

@ovr
ovr force-pushed the reproduce-postgres-11860 branch from 747fe89 to 5818834 Compare September 23, 2026 14:43
@claude

claude Bot commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ovr's task in 8m 24s —— View job


Approve with notes — 0 blocking, 1 low (comment hygiene); 2 prior threads re-affirmed

Full review

Verdict

The fix matches the symptom in #11860. QueryCache.cache is a plain nested object with no eviction, so the old ['partitions', JSON.stringify(buildRange)] key grew one multi-MB description list per distinct build range, pinned by CompilerCache's updateAgeOnGet: true (packages/cubejs-schema-compiler/src/adapter/QueryCache.ts:22, packages/cubejs-schema-compiler/src/compiler/CompilerCache.ts:15). Keying on a stable identity and overwriting a single slot bounds that to one plan per pre-aggregation × timezone. The ['timeSeries', …] entry had the same unbounded shape and is gone with it.

The new key is strictly more specific than the old one in every dimension, so it introduces no collision the previous code did not already have, and hit rate is a subset of before (previous range only, rather than any previous range) — nothing that was correct before becomes stale now.

Ordering is right: checkMaxPartitions and the whole map run before Object.assign, so a maxPartitions breach or a throw inside partitionPreAggregationDescription leaves the previous plan intact. And because there is no await between the slot read and the assign, concurrent partitionPreAggregations() calls for one identity still dedupe — the second caller resumes after the first's synchronous block and sees the new rangeKey. Tests cover both.

Notes (non-blocking)

Dropping the timeSeries memoization from partitionRanges() costs nothing in practice. packages/cubejs-server-core/src/core/RefreshScheduler.ts:170 is the only producer of compilerCacheFn, and it feeds only the loader built in expandPartitionsInPreAggregations (packages/cubejs-query-orchestrator/src/orchestrator/PreAggregations.ts:701). The partitionRanges() calls at PreAggregationPartitionRangeLoader.ts:273/275 run under loadPreAggregations(), which always gets the default pass-through compilerCacheFn — that cache was already inert there. The new test "ordinary query range generation remains uncached" pins the intent.

Steady-state floor, not a leak. The retained slot keeps its descriptions array alive for the life of the base-query LRU entry even when the range never repeats (0% hit rate is exactly the reporter's moving-range case). For the reported setup — ~10 rollups × 2 timezones × ~1,100 partitions — that is a permanent tens-of-MB resident floor rather than a transient. Bounded and a clear win over ~150 MB/h, just worth knowing it is a floor.

Posted inline: packages/cubejs-schema-compiler/test/unit/partition-plan-cache.test.ts:43 — the comment restates the three lines under it. (The suggestion block on that comment was malformed; corrected in a reply on the thread — just delete the line.)

Prior threads

The listing returned 6 threads authored by claude. 4 resolved as addressed by the current head — partitionPlanCache LRU sizing and its dead updateAgeOnGet (both gone from packages/cubejs-server-core/src), the 10000 literal in the deleted server-core copy of the test, and the "orphaned compilerCacheFn" concern (the call is back at RefreshScheduler.ts:170). 2 re-affirmed as still live, so their findings were not re-posted:

  • PreAggregationPartitionRangeLoader.ts:447 — the hand-picked seven-field identity omits fields the cached plan depends on. My own finding this round was the matchedTimeDimensionDateRange case (descriptions carry it via the ...this.preAggregation spread, so a hit can hand back a plan tagged with another query's matched range); it is the same root cause as that thread, which already proposes hashing preAggregation minus that field. Latent today only because the refresh scheduler is the sole compilerCacheFn producer.
  • packages/cubejs-query-orchestrator/test/unit/PreAggregations.test.ts:1588const describe = jest.spyOn(...) still shadows Jest's describe, and the as any casts at 1631/1648/1653 remain (CLAUDE.md asks for a test subclass widening the method instead).

Not verified

node_modules is not installed in this checkout, so I did not run yarn tsc, the orchestrator suite, or the new schema-compiler test. Review is static. Worth confirming that packages/cubejs-schema-compiler/test/unit/partition-plan-cache.test.ts compiles into dist/test/unit (schema-compiler's unit script runs jest dist/test/unit) and that Lerna orders query-orchestrator — a devDependency of schema-compiler at packages/cubejs-schema-compiler/package.json:63 — before it.

· [`reproduce-postgres-11860`](https://github.com/cube-js/cube/tree/reproduce-postgres-11860)

entries.set(JSON.stringify(key), entry);
return entry;
});
// Range loading is mocked; planning uses the real loader and compiler cache.

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.

Comment restates the three lines under it — jest.spyOn(loader, 'loadBuildRange') two lines down is "range loading is mocked", and new PreAggregationPartitionRangeLoader(...) with the CompilerCache-backed compilerCacheFn is "the real loader and compiler cache". A reader loses nothing by deleting it.

Suggested change
// Range loading is mocked; planning uses the real loader and compiler cache.
const loaders = ['UTC', 'Europe/Paris'].map(timezone => new PreAggregationPartitionRangeLoader(

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.

Correction: the suggestion block above is malformed — it replaces only line 43, so applying it duplicates line 44. Just delete line 43.

This branch has not been deployed

No deployments
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