Skip to content

fix(server-core): release and connection-test dedicated pre-aggregation drivers - #11465

Open
igorlukanin wants to merge 1 commit into
masterfrom
igor/core-723-pre-aggregation-drivers-are-never-released-or-connection
Open

fix(server-core): release and connection-test dedicated pre-aggregation drivers#11465
igorlukanin wants to merge 1 commit into
masterfrom
igor/core-723-pre-aggregation-drivers-are-never-released-or-connection

Conversation

@igorlukanin

Copy link
Copy Markdown
Member

Summary

A deployment with a dedicated pre-aggregation connection leaked that driver's connection pool on every orchestrator teardown, and never connection-tested it.

  • Root cause. OrchestratorApi.release() and testConnection() iterated seenDataSources, which holds plain data source names. The driver factory caches a dedicated pre-aggregation driver separately, so that driver was unreachable from either path. addDataSeenSource() is also called from exactly one place — the standalone readiness probe, hardcoded to default — so on most deployments release() reached no internal driver at all.
  • The fix. OrchestratorApi now wraps the driver factory it is constructed with and records every driver actually requested, then releases those instances. Because the wrapper is what it passes to QueryOrchestrator, the query path, the pre-aggregation subsystem and the data source probe all record through it — nothing has to guess which drivers exist.
  • Release no longer goes back through the factory. It previously called the factory to obtain the driver it was about to close, which after a failed resolution built a fresh driver — opening a connection — purely to close it. It now releases already-resolved instances and skips what never resolved. Each driver is released as it resolves, so one whose initialization hangs cannot stall the others or the orchestrator's own cleanup.
  • Teardown completes, then reports. One driver refusing to close no longer strands the rest or the caller's cache clear, but the failure still propagates — shutdown must exit non-zero on it, and a reset must not reload over state it could not free.
  • One key rule, one place. driverCacheKey() decides when a pre-aggregation request earns its own connection, and it takes that decision as an argument rather than re-deriving it, so the cache key cannot disagree with the credentials actually used. That fixes a second, order-dependent duplicate: keying on the request flag alone meant a pre-aggregation build arriving before the first query cached separately, and the next query then built another driver for the same connection — which is exactly what a refresh worker does. It also covers the case where a custom driverFactory takes precedence over pre-aggregation env vars, where the two requests resolve to identical credentials and must therefore share one driver. The two compensating aliasing writes this replaces are deleted; the @pre_agg suffix now appears once in the tree instead of twice under two different rules.

Behaviour change worth noting

testConnection() now covers dedicated pre-aggregation connections, so a broken one that was previously invisible to the readiness and liveness probes will now fail them. That is the point of the change, but it is the one way a deployment could newly report unhealthy. Probes are otherwise no chattier than before: requests that share a connection resolve to one driver and are tested once.

Not in scope: OrchestratorStorage's LRU has no dispose, so an evicted orchestrator is still never released. Closing that would need to avoid dropping pools under queries still running on the evicted orchestrator, which deserves its own change — this one makes it easier, since release() can now neither construct a driver nor be stalled by one.

Test plan

  • New test/unit/driver-lifecycle.test.ts (15 tests): both drivers released and tested with a dedicated pre-aggregation connection; named data sources; a single driver for both request orders without one, and for a custom driverFactory overriding the env vars; release never constructing a driver (never-queried, and failed-to-resolve); a failing release reported without stranding the others; the readiness probe still forcing the primary connection when only the pre-aggregation driver exists; tracking bounded across repeated requests and probes; rollupOnlyMode unchanged.
  • Every test red-checked against eight wrong implementations — release-by-seenDataSources (the original bug), no identity dedup, release re-resolving through the factory, dropping the readiness-probe union, a cache key omitting the credentials term, a no-op release, swallowed release errors, and unbounded per-call tracking. Each is caught by a distinct group.
  • cubejs-server-core unit suites green: driver-lifecycle, index, OrchestratorApi, OptsHandler, CompilerApi — 91 tests. tsc and eslint clean.
  • RefreshScheduler.test.ts fails on unmodified master in this package (order-dependent); verified by stashing, unrelated to this diff and unchanged by it.
  • CI must pass

@igorlukanin
igorlukanin requested a review from a team as a code owner August 3, 2026 22:20
@claude

claude Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @igorlukanin's task in 4m 21s —— View job


Review: solid fix, one caller-side gap — 1 high, 1 medium, 3 low (4 inline comments)

Full review
  • Read the diff (git diff origin/master...HEAD)
  • Review OrchestratorApi.ts tracking / release() / testConnection()
  • Review utils.ts cache key + server.ts factory keying
  • Review test coverage
  • Check release() / testConnection() call sites across server-core, api-gateway, cubejs-server
  • Post review

Summary

The root-cause analysis is correct and the fix is well-aimed. seenDataSources really is populated from exactly one place (gateway.ts:2927, hardcoded 'default'), so wrapping the factory is the only way to know which drivers exist without duplicating the factory's caching rule. Deriving the cache key from usePreAgg rather than the caller's flag is the right call and removes both compensating aliasing writes plus a genuine order-dependent duplicate on the refresh-worker path. Releasing already-resolved instances instead of re-entering the factory, and deduping by driver identity, are both correct. Promise.allSettled + rethrow is the right shape for teardown. Code comments are unusually good — they explain why, including the alternatives rejected.

Findings

# Sev Where Issue
1 High OrchestratorApi.ts:394-400 release() can now realistically reject, and all three callers do their cleanup after an unguarded await — storage keeps spent APIs with dead pools, shutdown skips timer cancellation, reset skips reloadEnvVariables/startScheduledRefreshTimer. Needs try/finally.
2 Medium server.ts:605-606 hasPreAggregationsEnvVars() (full process.env scan + Set alloc) moved above the cache-hit early return, so it now runs on every driver request rather than once per data source. Memoize per dataSource.
3 Low OrchestratorApi.ts:381 The external driver is still released through its factory, so an unused external driver is constructed + connection-tested purely to be closed — the pattern this PR removes for internal drivers.
4 Low OrchestratorApi.ts:83 The @pre_agg suffix still appears twice; the driverCacheKey docstring ("cached and tracked under") implies this call site uses it.
5 Low testConnection() Probe fan-out grows from 1 internal test to one per requested driver, per probe — no longer just default. Shared connections dedup, so this is bounded by distinct connections, but on a many-data-source tenant a k8s liveness probe every few seconds now issues N round-trips. Intended direction; worth being explicit about in the PR body alongside the pre-agg-coverage note, since it's the same behaviour change viewed from the load side.

Finding 1 is the only one I'd consider blocking. Note it is not introduced by the diff in isolation — the callers were always unguarded — but the diff is what makes that path reachable: before it, release() typically had no internal driver to fail on.

Nit: driverCacheKey lands in a previously-empty core/utils.ts. Colocating with the other driver-resolution helpers (or with hasPreAggregationsEnvVars's consumers) would make it easier to find than a generic utils.

Tests

driver-lifecycle.test.ts is genuinely good — it exercises the real resolveDriver path so isCustomDriverFactory() doesn't silently disable usePreAgg, it covers both request orders, and the "release does not build drivers" group asserts on construction counts, which is what actually distinguishes the fix from the bug. The red-check against eight wrong implementations is the right discipline.

Gaps, in priority order:

  1. No test that a caller can finish tearing down when one release() rejects (finding 1). The existing test asserts the sibling driver still closed within one api; the failure mode is one level up, in OrchestratorStorage.releaseConnections.
  2. No coverage of the external driver in release() (finding 3) — rollupOnlyMode covers it only for testConnection.
  3. No assertion that requestedDrivers is emptied by release(), which the code deliberately does and which finding 1 depends on.

Not verified

Dependencies aren't installed in this checkout (no node_modules), and building query-orchestrator + friends to run Jest wasn't practical here — so I reviewed statically and did not independently reproduce the 91 green tests, tsc, or eslint. lib: ["es2023"] in tsconfig.base.json does cover Promise.allSettled/PromiseRejectedResult, so that part will compile. Leaving the suite to CI.

• branch `igor/core-723-pre-aggregation-drivers-are-never-released-or-connection`

@github-actions github-actions Bot added the javascript Pull requests that update Javascript code label Aug 3, 2026
Comment on lines +394 to +400
errors.forEach((error) => {
this.logger('Error during release', {
error: (error as Error)?.stack || error,
});
});

throw errors[0];

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.

Throwing here is right, but the callers aren't ready for it. Before this PR release() iterated seenDataSources, which is only ever populated by the readiness probe — so on most deployments it touched no internal driver and effectively could not throw. Now it releases every driver, so a single pool whose release() rejects makes release() reject, and every caller does its cleanup after an unguarded await:

  • OrchestratorStorage.releaseConnections()this.storage.clear() is skipped, so the map keeps orchestrator APIs that are already spent: their requestedDrivers was cleared at the top of release() and some pools are closed. getOrchestratorApi() will hand those back out (dead pools), and a second release() on them is a no-op — the pools that did survive are now unreachable forever.
  • CubejsServerCore.releaseConnections() (server.ts:901) — clearInterval(this.maxCompilerCacheKeep) and scheduledRefreshTimerInterval.cancel() are skipped, so a graceful shutdown can hang on a live timer instead of exiting.
  • resetInstanceState() (server.ts:559) — reloadEnvVariables() and startScheduledRefreshTimer() are skipped, leaving the dev server with no refresh timer.

The PR's intent ("shutdown must exit non-zero", "a reset must not reload over state it could not free") is satisfied by propagating the error after the local cleanup runs. Simplest fix is try { ... } finally { ... } in the three callers — at minimum OrchestratorStorage.releaseConnections:

public async releaseConnections() {
  try {
    await Promise.all([...this.storage.values()].map(api => api.release()));
  } finally {
    this.storage.clear();
  }
}

Worth a test too: the suite covers "one driver refusing to close does not strand the others" within one api, but not that the caller can still finish tearing down.

Comment on lines +605 to +606
const hasSeparatePreAggEnv = hasPreAggregationsEnvVars(dataSource);
const usePreAgg = preAggregations && hasSeparatePreAggEnv && !this.optsHandler.isCustomDriverFactory();

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.

Moving these two above the cache-hit early return puts them on the hot path: driverFactory is invoked per query and per pre-aggregation check, and hasPreAggregationsEnvVars('default') allocates a Set and iterates all of process.env on every call (env.ts:146). Previously it ran once per cache miss, i.e. once per data source.

The key does have to be computed before the lookup, so the ordering itself is necessary — but the inputs can be memoized per dataSource inside this closure (they only change across a reloadEnvVariables(), which discards the orchestrator anyway):

const preAggEnvCache: Record<string, boolean> = {};
// ...
const hasSeparatePreAggEnv = preAggEnvCache[dataSource] ??= hasPreAggregationsEnvVars(dataSource);


await this.releaseDriverInstance(resolved);
}),
this.releaseDriver(this.options.externalDriverFactory),

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 external driver still goes back through its factory — the exact pattern the PR removes for internal drivers. In server.ts the external closure caches into externalPreAggregationsDriverPromise, so if the external driver was never used (or its last resolution failed and nulled the cache), release() constructs an external driver, testConnection()s it, and immediately closes it. On a deployment configured with an external pre-aggregation store but no pre-aggregation traffic, shutdown opens a connection just to shut it down — and a failure there now rejects the whole release() (see the other thread).

Recording the external driver the same way (or tracking externalPreAggregationsDriverPromise resolution) would make the rule uniform: release closes what was built, never builds.

// One entry per distinct request, overwriting the previous promise for the
// same one: a re-request returns the same connection, and the factory owns
// its cache, so the latest promise is the one worth holding.
this.requestedDrivers.set(`${dataSource}${preAggregations ? '@pre_agg' : ''}`, {

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.

Nit, against the PR description's claim that "the @pre_agg suffix now appears once in the tree instead of twice under two different rules": it still appears twice — here, and in driverCacheKey(). The two genuinely key on different things (this one on the request, the helper on the credentials), which is defensible, but the driverCacheKey docstring says "cached and tracked under", implying this call site uses it. Either call the helper here with preAggregations and note in the doc why the tracking key may over-count (harmless: identity dedup settles release, and the tested set settles the probe), or narrow the helper's doc to the factory cache only.

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.83333% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.20%. Comparing base (3728d62) to head (16bce88).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
...ges/cubejs-server-core/src/core/OrchestratorApi.ts 95.23% 0 Missing and 2 partials ⚠️

❗ There is a different number of reports uploaded between BASE (3728d62) and HEAD (16bce88). Click for more details.

HEAD has 1 upload less than BASE
Flag BASE (3728d62) HEAD (16bce88)
cubesql 1 0
Additional details and impacted files
@@             Coverage Diff             @@
##           master   #11465       +/-   ##
===========================================
- Coverage   79.45%   59.20%   -20.26%     
===========================================
  Files         480      224      -256     
  Lines       98810    17925    -80885     
  Branches     3636     3642        +6     
===========================================
- Hits        78511    10612    -67899     
+ Misses      19778     6792    -12986     
  Partials      521      521               
Flag Coverage Δ
cube-backend 59.20% <95.83%> (+0.09%) ⬆️
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