fix(server-core): release and connection-test dedicated pre-aggregation drivers - #11465
Conversation
|
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
SummaryThe root-cause analysis is correct and the fix is well-aimed. Findings
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, Nit: Tests
Gaps, in priority order:
Not verifiedDependencies aren't installed in this checkout (no |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #11465 +/- ##
===========================================
- Coverage 79.46% 59.26% -20.20%
===========================================
Files 480 224 -256
Lines 98810 17946 -80864
Branches 3636 3644 +8
===========================================
- Hits 78515 10635 -67880
+ Misses 19774 6791 -12983
+ Partials 521 520 -1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
… the external driver from its instance
16bce88 to
5281491
Compare
|
Claude finished @igorlukanin's task in 3m 0s —— View job Re-review of
|
| # | Sev | Status |
|---|---|---|
| 1 | High | Fixed. OrchestratorStorage.releaseConnections uses allSettled + try/finally around storage.clear(), and both CubejsServerCore.releaseConnections and resetInstanceState moved their cleanup into finally. Failure still propagates. Four tests cover it, including that a slow sibling api finishes before the failure is reported — which Promise.all would not have given you. |
| 2 | Medium | Fixed. hasPreAggregationsEnvVars memoized per dataSource in preAggregationsEnvVars, back to once per data source. |
| 3 | Low | Fixed. recordingExternalDriverFactory records the external driver, the wrapper is swapped into a copy of options so QueryCache/PreAggregations resolve through it too, and release() closes the recorded instance. Both directions tested (never-used → 0 builds; used → released once, no rebuild). Swapping it into options rather than only the class field is the right call — wrapping just this class's reads would have left the orchestrator's own external driver unreleasable. |
| 4 | Low | Fixed. driverCacheKey's docstring now states outright that the factory passes the credentials predicate and OrchestratorApi's tracking passes the raw request, so it may hold two keys for one shared driver, and why that's harmless. The call site carries the same note. |
Finding 5 (probe fan-out) was informational and the PR body already covers it.
Verified independently
The cache-key change hinges on usePreAgg being the only input that distinguishes the credentials, and it is: resolveDriver receives preAggregations: usePreAgg || false (server.ts:656), the same term the key is derived from — so a key can no longer disagree with the connection the driver was built for. Both collapse cases check out: hasSeparatePreAggEnv === false and isCustomDriverFactory() === true each force usePreAgg false, both requests resolve identically, one entry is correct. The two deleted aliasing writes are exactly what that subsumes.
release() no longer re-enters either factory, and Promise.resolve(driver) is identity for the native promises both factories return, so no extra unhandled-rejection surface — the recorded promise is the same object the original caller already handles.
New findings (both low)
server.ts:938— a throwingfinallyreplaces the pending exception, so a rejectingcancel()(or a throwingstartScheduledRefreshTimer()) swaps the release failure for the cleanup one. Still non-zero exit; only the diagnostic changes. Inline.- A driver resolved after
release()has cleared the map — an in-flight query during shutdown — is recorded into the emptied map and never closed. The pool is about to die with the process, so this matters only for the LRU-eviction case the PR explicitly defers, and it's strictly better than before. Not worth code now; worth remembering whenOrchestratorStorage.disposelands.
Tests
The new groups are aimed at the failure modes rather than the happy path: the OrchestratorStorage test asserts the map is emptied and that the slow sibling finished, which distinguishes allSettled from Promise.all — a Promise.all implementation passes the first assertion and fails the second. "The record is emptied, so a second release is a no-op" pins the invariant the callers' finally blocks rely on. External coverage asserts on build counts, which is the only thing that separates "released what was built" from "built in order to release".
Not verified
Still no node_modules in this checkout, so I reviewed statically and did not re-run the suites, tsc, or eslint. Promise.allSettled/PromiseRejectedResult are covered by lib: ["es2023"]. Leaving the run to CI; codecov reports the patch at 95.8%.
| if (this.scheduledRefreshTimerInterval) { | ||
| await this.scheduledRefreshTimerInterval.cancel(); | ||
| if (this.scheduledRefreshTimerInterval) { | ||
| await this.scheduledRefreshTimerInterval.cancel(); |
There was a problem hiding this comment.
Low: a finally that throws replaces the pending exception, so if cancel() rejects (or startScheduledRefreshTimer() throws in resetInstanceState) the release failure is silently swapped for the cleanup failure. Both still exit non-zero, so the observable outcome is right — the diagnostic just points at the wrong thing. release() already logs each driver error before rethrowing, so the information isn't lost; noting it only in case you'd rather the release error win.
Summary
A deployment with a dedicated pre-aggregation connection leaked that driver's connection pool on every orchestrator teardown, and never connection-tested it.
OrchestratorApi.release()andtestConnection()iteratedseenDataSources, 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 todefault— so on most deploymentsrelease()reached no internal driver at all.OrchestratorApinow 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 toQueryOrchestrator, the query path, the pre-aggregation subsystem and the data source probe all record through it — nothing has to guess which drivers exist.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 customdriverFactorytakes 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_aggsuffix 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.The same change viewed from the load side: a probe now tests every distinct connection a driver has been built for, where before it tested only what
addDataSeenSourcehad announced — in practice justdefault, and often nothing at all. Requests that share a connection resolve to one driver and are still tested once, so the fan-out is bounded by distinct connections rather than by requests; but on a tenant with many data sources a liveness probe every few seconds now issues N round-trips instead of one. That is the intended direction — a probe that tests one connection out of N is not much of a probe — and it is the reason the union withseenDataSourcesis kept narrow.Not in scope:
OrchestratorStorage's LRU has nodispose, 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, sincerelease()can now neither construct a driver nor be stalled by one.Test plan
test/unit/driver-lifecycle.test.ts(23 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 customdriverFactoryoverriding the env vars; release never constructing a driver (never-queried, failed-to-resolve, and the external one); an external driver that was used released exactly once without re-entering its factory; a failing release reported without stranding the others, and each of the three callers still completing its own teardown; the record emptied so a second release is a no-op; the readiness probe still forcing the primary connection when only the pre-aggregation driver exists; tracking bounded across repeated requests and probes; the pre-aggregation env-var scan memoized per data source;rollupOnlyModeunchanged.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-coreunit suites green:driver-lifecycle,index,OrchestratorApi,OptsHandler,CompilerApi— 99 tests.tscandeslintclean.RefreshScheduler.test.tsfails on unmodifiedmasterin this package (order-dependent); verified by stashing, unrelated to this diff and unchanged by it.