fix(server-core): release orchestrators evicted from the LRU - #11661
Conversation
|
Claude finished @paveltiunov's task in 3m 46s —— View job Review: sound fix for a real leak — 1 high, 3 medium, 4 low. Details below.Full review (5 inline comments posted)
The diagnosis is right and well-evidenced: The findings below are all about the new lifecycle rather than the LRU hook itself. HighUse-after-release is unguarded, and on the Cube Store path it re-creates the leak — Medium
Low
TestsGood coverage of the paths the PR set out to cover, and the fixtures are honest about what they stub. Gaps worth adding, matching the findings above: an operation started after No docs changes needed; this is internal lifecycle behaviour. |
|
Claude finished @paveltiunov's task in 2m 20s —— View job Re-review of
|
|
Claude finished @paveltiunov's task in 1m 41s —— View job Re-review of
|
|
Claude finished @paveltiunov's task in 2m 3s —— View job Re-review of
|
|
The two findings that couldn't be posted inline (unchanged lines) — one fixed, one deferred.
That also closed the test gap in the same finding: both fixtures now register a seen data source and hold a separate external driver, so the per-datasource loop is actually exercised and the two closes are counted apart.
Also noted, not changed: a timed-out Local verification on Generated by Claude Code |
|
Claude finished @paveltiunov's task in 2m 10s —— View job Re-review of
|
|
Claude finished @paveltiunov's task in 1m 31s —— View job Re-review of
|
|
Claude finished @paveltiunov's task in 1m 33s —— View job Re-review of
|
|
I gave a wrong explanation for this earlier in the thread and want to correct it: I said the Codecov failures were downstream of the npm-registry 504 window that killed the driver jobs, i.e. no coverage artifacts to merge. That was wrong. It has now failed on the current head What actually fails. Not the Codecov upload — the artifact download before it: preceded by ~20 successful downloads. The step ( - name: Download all coverage artifacts
uses: actions/download-artifact@v4
with:
path: all-coverageWith no Proposed patch. The coverage artifacts are with:
pattern: coverage-*
path: all-coverageThat drops the large binaries from the transfer entirely, which both removes the failure mode and makes the job faster. Why I am not pushing it here. There is no existing fix to port — this would be a new One thing to flag: if Re-running the failed job once now, since a transient artifact download is exactly the kind of thing a re-run settles. I will keep watching until the PR is green or merged. Generated by Claude Code |
|
Re-run spent, and it failed identically — so this is deterministic, not a flake, and my "a re-run should settle it" in the previous comment was wrong. The re-run also let me pin the exact artifact, which corrects the mechanism I guessed at there.
That is the Docker buildx build-record artifact, uploaded automatically by Two corrections to what I wrote before:
This makes the patch from my previous comment not just a tidy-up but the actual fix, because - name: Download all coverage artifacts
uses: actions/download-artifact@v4
with:
pattern: coverage-*
path: all-coverageStill not pushing it here — it is a repo-wide Everything else on Generated by Claude Code |
`close()` called `webSocket.close()` and left it there, which does not release anything: the 'close' handler re-sends whatever was in flight over a fresh connection, and the 5s heartbeat on that one keeps the socket -- and everything reachable from the interval, including the orchestrator holding the driver -- alive for the life of the process. A query arriving after the close re-opened the connection just as readily. A single `closed` flag makes the close terminal. `initWebSocket()` refuses once it is set, which covers both the late query and the re-send path, whose `catch` then fails the messages that were in flight instead of re-opening for them. Messages already in flight are still given the chance to be answered -- an eviction can land mid-query and Cube Store may already be working on the answer -- so the socket goes away once the last of them settles, or immediately if there are none. A query Cube Store never answers is bounded by the existing no-heartbeat timeout, which closes the socket and lands on the same path. Alongside it, two things that leaked a connection nobody was waiting for: - The heartbeat interval is now stopped through `teardown()` wherever the socket dies, not only in the 'close' handler. It is the timer, not the socket, that keeps the graph reachable. - The 'error' handler no longer reconnects once the socket has been established. Retrying is only useful while somebody is still waiting for `readyPromise`; after that it raised a connection no caller asked for and kept it alive on its own heartbeat, while what was in flight is re-sent by the 'close' handler anyway. The re-send itself now also bails out when everything it was scheduled for has settled in the meantime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HF1JQbLKkZ4XLd1hLXYVmA
`OrchestratorStorage` keeps `OrchestratorApi` instances in an `LRUCache` with no `dispose` handler, so an entry pushed out by `max` -- or replaced, deleted, expired -- is dropped without `release()` ever being called. Its drivers stay open, and with them the Cube Store WebSocket. Only `releaseConnections()` at shutdown released anything. Nothing else closes that socket: the driver pings it every 5s, so neither side times it out, and that live timer keeps the whole orchestrator reachable from a GC root. A collected socket would not be closed anyway -- V8 runs no destructors and neither `ws` nor `net` registers a finalizer. In Node a socket is closed explicitly or never. So a deployment whose `contextToOrchestratorId` has high cardinality (per-user ids, or ids that rotate on a timer) leaked one Cube Store connection per eviction. Measured in production: ~450 new sockets/minute per API pod (429 on a pod 43s after restart, 5,198 at 16 min; a pod serving no traffic held 1), 65,551 established sockets on the router they pointed at, and that region's ingress OOM-killed 38-46 times in six hours. Reproduced on deployments pinned to v1.7.2 and v1.7.7, so it is not version-specific. `disposeAfter` covers every removal reason. It is `disposeAfter` rather than `dispose` because `release()` is async and calls into the drivers, while `dispose` runs synchronously inside `set()`/`delete()`. The scheduled releases are tracked in `pendingReleases` so `releaseConnections()` can await them: `clear()` only schedules them, so without that shutdown could return with the connections still open. A release that fails is swallowed rather than left as an unhandled rejection, and each entry removes itself from the set when it finishes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HF1JQbLKkZ4XLd1hLXYVmA
32925d2 to
a7e21f3
Compare
|
Claude finished @paveltiunov's task in 5m 46s —— View job Re-review of the rewritten branch: much simpler fix, but the drain went with it — 1 high, 1 medium, 4 low.Full review (3 inline comments posted; no stale threads — every prior thread is already resolved)
The branch is now two commits and 315 lines instead of the previous lifecycle machinery, and the relocation is the right instinct: the leak's proximate cause is that What the rewrite drops, though, is the drain, and that changes what an eviction costs. HighEviction now fails in-flight work instead of leaking it — Medium
Low
TestsThe four Gaps, matching the findings above: nothing asserts what happens to an operation still running on an evicted orchestrator (the high finding — a test here would have to decide what the intended behaviour is, which is the useful part); nothing covers an orchestrator that never built the external driver, asserting the factory isn't called on release; and nothing covers a
|
|
Reworked to the approach from the parallel PR — force-pushed as What changed, and why the earlier direction was the wrong one: I had built the release lifecycle up inside The replacement is a single Several findings from the previous rounds stop existing rather than being fixed, which is the clearest sign the shape was wrong:
Still standing from the earlier reviews, unchanged: One behaviour worth calling out for review: The Generated by Claude Code |
| // interval holds a reference to the socket, so it is never garbage collected either. | ||
| // disposeAfter, not dispose: release() is async and calls into the drivers, while | ||
| // dispose runs synchronously inside set()/delete(). | ||
| disposeAfter: (api: OrchestratorApi) => { |
There was a problem hiding this comment.
Nothing drains the orchestrator before this release, and close() is now terminal — so an eviction mid-request now fails that request instead of leaking.
This rewrite drops the trackOperation/drain machinery, so OrchestratorApi.release() is master's version: it closes the data-source drivers and the external driver the moment disposeAfter fires, with no regard for what is still running. Paired with the new terminal close(), the two changes compose into a behaviour change that neither has on its own:
- Before this PR: eviction did nothing at all. An in-flight query on the evicted api kept working; the cost was the leaked socket.
- After: eviction releases immediately.
WebSocketConnectiononly drains messages already written to the socket; anything after that — the next Cube Store queue round trip of a query still being executed, aContinue waitpoll that resumes, a pre-aggregation build — hitsinitWebSocket(), seesthis.closed, and throwsConnectionError: Cube Store connection is closed. Permanently, by design.
The same applies to the data-source side: server.ts's driverPromise memo is not cleared by release(), so the held api keeps handing back a driver whose pool has been ended.
Who still holds an evicted api:
- The cold-start race.
getOrchestratorApi()(server.ts:572-577) is not de-duplicated by id, so two concurrent requests for a neworchestratorIdboth build an api and bothset()it; the first is disposed and released while the request that created it is still using it. Two concurrent first requests for a new tenant is not an edge case in a high-cardinalitycontextToOrchestratorIddeployment — it is the common case. RefreshSchedulerholds oneorchestratorApifor a whole refresh run (RefreshScheduler.ts:620-633).- Any request that outlives the arrival of
maxdistinct ids.
At the eviction rate the PR body measures (~450/min/pod), (3) alone is a steady trickle of failed queries. The leak is real and worth closing, but trading it for hard request failures needs either a drain (what the earlier iteration of this branch had), or de-duplicating getOrchestratorApi() by id plus a grace period before the release, or at minimum a decision recorded here that failing those requests is acceptable.
There was a problem hiding this comment.
The mechanism is real and I verified both halves rather than taking them on trust, so recording that here — but the remedy is not mine to choose, so this thread stays open pending the author's decision.
Confirmed:
- The cold-start race is real.
getOrchestratorApi()checkshas()atserver.ts:575and then awaitscontextToOrchestratorId,orchestratorOptionsandgetOrchestratorInitializedOptionsbeforeset()at the end, so two concurrent requests for a cold id both build an api and bothset()it. The loser is disposed and released while the request that created it still holds it. - The data-source side is real too.
driverPromiseis a closure-local memo pergetOrchestratorApi()call andrelease()does not clear it, so a held api keeps handing back a driver whose pool has been ended. - The composition is the new part. Before this PR eviction released nothing, so neither of the above had consequences; the cost was the socket. Now eviction releases immediately and
close()is terminal, so the next Cube Store round trip of a query still executing getsConnectionError: Cube Store connection is closed.
On severity, the numbers from the PR body make case (3) concrete rather than theoretical: ~450 orchestrator creations/minute/pod against max: 100 means the whole cache turns over roughly every 13 seconds, and Cube queries routinely run longer than that. So on the tenant that motivated this fix, shipping as-is trades leaked sockets for a steady trickle of failed queries. That is worth being explicit about rather than filed as a nit.
What I have not done is re-add the drain. The trackOperation/drain machinery was removed deliberately by the author in favour of this approach, and re-introducing it on a review finding would reverse that decision rather than implement it. Three options are on the table and the choice is the author's:
- A minimal active-operation counter with a bounded wait in
release()— roughly 30 lines, not the ~360 the earlier iteration grew into (no re-arm, no stream hold, no external-driver wrapper). - De-duplicate
getOrchestratorApi()by id, which removes cause (1) outright, and accept the residual from (2) and (3). - Ship as-is and record that failing those requests is acceptable, presumably alongside a larger
max.
Flagged to the author with (1) as my recommendation, since it is the difference between fixing the leak and fixing it without failing queries. Will implement whichever is chosen and report back here.
Generated by Claude Code
Review follow-ups on the `closed` flag. The drain was unbounded and re-checked only from the 'message' handler, so a message Cube Store never answers kept the socket -- and the heartbeat interval that makes it reachable -- for the life of the process. The no-heartbeat close does not save it: Cube Store keeps answering the pings of a connection whose query simply never completes, so nothing would call `closeIfDrained()` again. That is the leak this branch exists to close, behind a narrower door. `close()` now arms a bound; when it fires, whatever is left is rejected naming the count, and the socket is torn down. A stuck message costs one query instead. `closeIfDrained()` also took the socket the answer arrived on rather than reading `this.webSocket`: a late answer on a socket the re-send path has already superseded was being read as the current connection having drained. Dropped the `established` flag as well. It existed to stop the 'error' handler reconnecting a socket nobody was waiting for any more, but with `closed` making the close terminal that reconnect is no longer orphaned -- it is assigned to `this.webSocket`, so it is reachable and the close reaps it. The heartbeat is still stopped there, since a 'close' does not always follow an 'error'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HF1JQbLKkZ4XLd1hLXYVmA
`OrchestratorStorage` swallows the rejection so that one orchestrator failing to close cannot fail shutdown -- which also means nothing said so. On the eviction path there was never a caller to tell, and at shutdown `releaseConnections()` used to propagate the error and no longer can. A driver that fails to close takes its connection with it, and that is the one signal an operator has that this fix is not working in their deployment, so `release()` logs it through the api's own logger before rethrowing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HF1JQbLKkZ4XLd1hLXYVmA
|
Claude finished @paveltiunov's task in 3m 48s —— View job Re-review of
|
… timer The bound on how long a closed connection waits for its in-flight messages was its own `setTimeout`. The heartbeat interval is already the timer that owns "this socket has waited long enough", and it is already the thing keeping the socket reachable, so the check belongs there: one timer to reason about, and no chance of a stray one outliving the socket it was bounding. The interval keeps pinging while a closed connection drains, so one that is draining legitimately is not dropped for inactivity, and the closed branch returns before the ordinary no-heartbeat check -- that check cannot bound this wait, since Cube Store keeps answering the pings of a connection whose query is never completed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HF1JQbLKkZ4XLd1hLXYVmA
`release()` called `releaseDriver(this.options.externalDriverFactory)` unconditionally, and that factory *creates* the connection on first call -- `server.ts` builds the driver and runs `testConnection()` inside it. Harmless while release only happened at shutdown; with a release on every LRU eviction it means an orchestrator that never touched Cube Store opens a connection purely to close it, which is churn against exactly the router this branch exists to relieve, at the eviction rate the PR describes. The factory is wrapped in the constructor -- before `QueryOrchestrator` captures it off `options` -- so the release can tell whether anything built the driver, and the flag is set on fulfilment: `server.ts` releases the driver and clears its memo when `testConnection()` fails, so a build that threw left nothing open. The release closes through the unwrapped factory, or it would set the flag again on the way out. The data-source loop was already guarded this way by `seenDataSources`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HF1JQbLKkZ4XLd1hLXYVmA
|
Pushed Medium, The drain bound moved into the heartbeat interval, replacing the Low, Low, Still open and unchanged: the high finding on the thread above, which needs a decision rather than a patch. Generated by Claude Code |
|
Claude finished @paveltiunov's task in 4m 31s —— View job Re-review of
|
…y path Dropping the `established` flag left a fatal hole, and the reasoning in that commit message was incomplete: it covered a live connection, where the retry's reconnect lands in `this.webSocket` and a later close reaps it, and missed the closed one, where there is no reconnect to reap -- only a rejection with no owner. `initWebSocket()` refuses once `closed` is set, and the 'error' handler handed that rejected promise to `resolve`. On a socket whose `readyPromise` has already settled, `resolve` returns without adopting what it was given, so nothing ever observes the rejection -- and an unhandled rejection is fatal under Node's default `--unhandled-rejections=throw`, which would take the API pod with it. Two changes, each standing on its own: - The handler returns early when the connection is closed. There is nothing to retry towards, and this also stops arming a pointless timer per evicted connection. - The retry uses `.then(resolve, reject)` rather than `resolve(promise)`, so any rejection is observed whether or not `readyPromise` has settled. The early return covers the case above, but a close landing between the timer being armed and firing does not go through it, and neither would anything else `initWebSocket()` throws. The test emits the 'error' rather than provoking one: `ws` reports a dying socket as 'close' against the mock, and the errors that do reach this handler in the wild -- a protocol error on an established socket, or a connect retry still pending from before the close -- are not reproducible there. The handler's contract is what is being pinned, and the test fails against the previous commit with exactly the fatal rejection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HF1JQbLKkZ4XLd1hLXYVmA
|
Claude finished @paveltiunov's task in 2m 32s —— View job Re-review of
|
…se it" This reverts commit c22a475, keeping only the release logging. The guard is not worth its complexity. For any orchestrator that served a query the external driver has already been built -- the Cube Store queue and cache go through the same factory -- so the guard only saves an open/close for one that was created and never used, and the wrapper it needs drew two defects of its own in review. Master's unconditional release stays. For the record, since it is the reason the guard existed: the operation it skips is not free. `CubeStoreDriver.testConnection()` runs `SELECT 1`, which opens the socket, so releasing a never-built external driver does connect + query + close. That cost is now accepted for the minority of evictions where it applies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HF1JQbLKkZ4XLd1hLXYVmA
|
Claude finished @paveltiunov's task in 2m 58s —— View job Re-review of
|
…rrors The early return added in 52abc59 skips every path that settles `readyPromise`: `'open'` will not fire, `teardown()` above has stopped the interval, the 'close' handler does not touch it, and the retry that used to settle it -- by adoption, since `resolve(promise)` on an unsettled promise adopts -- is exactly what the early return skips. So a socket still CONNECTING when the connection is closed left the `await this.initWebSocket()` in `sendMessage` pending for good. That is an eviction landing while an orchestrator's first query is connecting, and it turned the rejection that path used to produce into a request hung on nothing, which is worse than the failure it replaced. Rejecting before the return restores it, and is a no-op once the socket has opened. Also corrected the comment above it: what was written to the socket is rejected by the 'close' handler that `ws` emits after an 'error', and *not* by the drain bound, which cannot fire any more because `teardown()` has stopped the interval that evaluates it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HF1JQbLKkZ4XLd1hLXYVmA
|
Claude finished @paveltiunov's task in 3m 30s —— View job Re-review of
|
… say Neither is a defect; both were noted in review as harmless and are cheaper to remove than to keep explaining. `currentConnectionTry` was incremented above the closed branch, i.e. on a path that returns without attempting a connection. It now counts only real tries, which is what paces the retries and spends the `maxConnectRetries` budget a cold connect needs. `closedAt` was re-stamped by every `close()`, so a second call on a draining connection restarted the bound. That is reachable: `release()` closes the data-source and external drivers separately, which is the same connection twice when one `CubeStoreDriver` backs both. It now records when the close was first asked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HF1JQbLKkZ4XLd1hLXYVmA
|
Claude finished @paveltiunov's task in 1m 17s —— View job Re-review of
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #11661 +/- ##
===========================================
- Coverage 84.52% 58.49% -26.04%
===========================================
Files 261 237 -24
Lines 85440 18902 -66538
Branches 0 3840 +3840
===========================================
- Hits 72221 11056 -61165
+ Misses 13219 7282 -5937
- Partials 0 564 +564
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:
|
|
Correction on On The mechanism I described still holds, but it is a race, not a certainty.
That fits every observation, including why it failed on four consecutive heads and then passed: those heads were slower to reach What this changes: Also cleared on this head: Current state of Generated by Claude Code |
Check List
Issue Reference this PR resolves
CORE-823
Description of Changes Made
Two halves of one leak: nothing asked the driver to close, and asking it to close did not close it.
OrchestratorStoragenever released an evicted orchestrator. It keepsOrchestratorApiinstances in anLRUCachewith nodisposehandler, so an entry pushed out bymax— or replaced, deleted, expired — was dropped withoutrelease()ever being called. OnlyreleaseConnections()at shutdown released anything.Nothing else closes that socket: the driver pings it every 5s, so neither side times it out, and that live timer keeps the whole orchestrator reachable from a GC root. A collected socket would not be closed anyway — V8 runs no destructors and neither
wsnornetregisters a finalizer. In Node a socket is closed explicitly or never.disposeAfternow covers every removal reason —disposeAfterrather thandisposebecauserelease()is async and calls into the drivers, whiledisposeruns synchronously insideset()/delete(). The scheduled releases are tracked inpendingReleasessoreleaseConnections()can await them;clear()only schedules them, so without that shutdown could return with the connections still open. A failing release is swallowed there so one orchestrator cannot fail shutdown, and logged byOrchestratorApi.release()before rethrowing, since that is otherwise the only signal an operator would have that this fix is not working in their deployment.WebSocketConnection.close()did not close the connection. It calledwebSocket.close()and stopped there, so the'close'handler re-sent whatever was in flight over a fresh connection and the heartbeat on that one kept the socket alive again; a query arriving after the close re-opened it just as readily. Releasing the driver therefore freed nothing, which is why the dispose hook alone would not have been enough.A single
closedflag makes the close terminal.initWebSocket()refuses once it is set, which covers both a late query and the re-send path, whosecatchthen fails the messages that were in flight instead of re-opening for them. Messages already in flight are still given the chance to be answered — an eviction can land mid-query and Cube Store may already be working on the answer — so the socket goes away once the last of them settles, or immediately if there are none.That wait is bounded, and the bound lives in the existing heartbeat interval rather than a timer of its own: that interval already owns "this socket has waited long enough" and is already the thing keeping the socket reachable. It cannot be left to the ordinary no-heartbeat check, because Cube Store keeps answering the pings of a connection whose query is simply never completed — so without an explicit bound a single wedged message would keep the socket, and the interval, for the life of the process. Pinging continues while a closed connection drains, so one draining legitimately is not dropped for inactivity. When the bound fires, whatever is left is rejected naming the count: a stuck message costs one query.
Two smaller things in the same file:
teardown()wherever the socket dies, not only in the'close'handler. It is the timer, not the socket, that keeps the graph reachable.'error'handler returns early when the connection is closed — there is nothing to retry towards, and it stops arming a pointless timer per evicted connection — but rejectsreadyPromisefirst, since on a socket that never opened that is the only thing left which can settle it. The retry itself uses.then(resolve, reject)rather thanresolve(promise): on a socket whosereadyPromisehas settled,resolvereturns without adopting what it was given, so a rejection handed to it was never observed, which is fatal under Node's default--unhandled-rejections=throw. The re-send also bails out when everything it was scheduled for has settled in the meantime.Measured in production before the fix
~450 new sockets/minute per API pod (429 on a pod 43s after restart, 4,566 at 10 min, 5,198 at 16 min; a pod serving no traffic held 1), 65,551 established sockets on the Cube Store router they pointed at, and that region's ingress OOM-killed 38–46 times in six hours. Reproduced on deployments pinned to v1.7.2 and v1.7.7, so it is not version-specific.
Behaviour change worth a reviewer's attention
close()is terminal, so a caller still holding a released driver getsConnectionError: Cube Store connection is closedrather than a silently re-opened socket. That is the trade that makes the leak impossible, and it is what an eviction now costs:release()has no drain above the socket, so the next Cube Store round trip of a query still executing on an evicted orchestrator fails. Reachable through the cold-start race ingetOrchestratorApi()(has()atserver.ts:575, then several awaits beforeset(), so two concurrent requests for a cold id bothset()and the loser is released while in use), throughRefreshSchedulerholding one api across a run, and through any request outliving the arrival ofmaxdistinct ids. Discussion and options are on this thread.Tests
packages/cubejs-server-core/test/unit/OrchestratorStorage.test.ts(new, 7 tests): eviction, replacement and same-instance re-set;releaseConnections()waits for the releasesclear()schedules; it resolves and clears even when one release rejects; a failing release is logged; a finished release is forgotten rather than accumulating in the set.packages/cubejs-cubestore-driver/test/websocket-connection.test.ts(+7 tests, in the existing suite that drives a mock Cube Store over real TCP): the socket closes when nothing is in flight; the connection stays OPEN until an in-flight query is answered and closes once drained; a query issued after close does not establish a new connection; a socket that dies while draining does not re-open; a message Cube Store never answers is given up on rather than holding the socket; a query still connecting when the close lands is rejected rather than hung; and a socket error on a closed connection leaves no unhandled rejection.Each was verified to fail against the commit it guards — the connecting-close one by hanging to the jest timeout, the unhandled-rejection one via
process.on('unhandledRejection'). All 15 pre-existing tests in that suite still pass, which is the check on theteardownand retry-path changes not regressing the re-send behaviour.cd packages/cubejs-cubestore-driver && yarn unit: 22 passed.cd packages/cubejs-server-core && yarn unit: 108 passed, 1 failed —Refresh Scheduler › Exponential backoff, which fails identically onmasterwith this change reverted, so it is pre-existing and unrelated.Follow-ups, not in this PR
server.tsconstructsnew OrchestratorStorage()with no arguments, so the LRU always uses the defaultmax: 100and silently ignorescompilerCacheSize/maxCompilerCacheKeepAlive/updateCompilerCacheKeepAlive. Two things belong with that change rather than ahead of it:ttlAutopurgeis unset, so an expired entry is only reaped when something touches the cache, andnew OrchestratorStorage({})throws becausemaxis thenundefined. Both are latent while the storage is built with no arguments.getOrchestratorApi()is not de-duplicated by id, andgetQueryOrchestrator()(RefreshScheduler.ts:238, 621, 812, 831) reaches the queues and cache driver without going through the api at all.release()calls the external driver factory unconditionally, andCubeStoreDriver.testConnection()runsSELECT 1, so releasing an orchestrator that never built that driver does a connect + query + close. Guarding it was tried and reverted as not worth the complexity — the wrapper it needs drew two defects in review. How often it applies depends on the config: undercacheAndQueueDriver: 'cubestore'the queue and cache go through the same factory (QueryOrchestrator.ts:90), so any orchestrator that served a query has already built the driver and the memo makes the release build a no-op; undermemorywith Cube Store still external, an orchestrator that served only non-pre-aggregated queries never touches the factory and every eviction does pay the round trip.Generated by Claude Code