fix(server-core): build one orchestrator api per id, not one per concurrent caller - #11834
fix(server-core): build one orchestrator api per id, not one per concurrent caller#11834paveltiunov wants to merge 6 commits into
Conversation
…urrent caller `getOrchestratorApi()` is asynchronous and writes the orchestrator cache only at the end, so concurrent callers of a cold id all miss the cache and each cache an api of their own. Since #11661 a replaced cache entry is released, and releasing an api closes its Cube Store web socket for good -- so every one of those writes tore the connection out from under the caller that had just been handed the previous api, which then failed with `ConnectionError: Cube Store connection is closed`. Concurrency of two is the everyday case rather than a rarity: one `/v1/load` with `total: true` fetches the api once for its data query and once for its count query (`gateway.ts` -> `getSqlResponseInternal`), so a single request is enough to race with itself. On the deployment this was reported from, every failing request carried `total: true`, including one that was alone on the deployment for 35 seconds; a burst of five arriving in the same millisecond produced ten builds and failed all five. The build is now memoized by id while it runs, so a burst shares one api and the entry is written once. The memo is registered before the first `await` inside the build, so nothing can interleave between the cache miss and it. It is dropped once the build settles: a success is in the cache by then, and a failure must not become the cached answer for that id. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gBgEB5wBDFxWrtBhk7G7X
|
Claude finished @paveltiunov's task in 3m 30s —— View job Approach is right and the fix is correct — 0 blocking, 1 medium (teardown doesn't clear the memo), 4 low (dead guard + over-long comments + test timer leak). 5 inline comments posted. Full reviewTodos
The fix itselfThe core reasoning holds up. Two details I checked and found fine:
The diagnosis is convincing too. Findings
#1 is the one worth acting on before merge, or at least deciding about deliberately. A build in flight across a reset both lands its pre-reset api in the freshly cleared storage and — new with this PR — gets served from the memo to every post-reset caller until it settles. The first half is pre-existing; the memo widens it. #2 is not a bug, just dead insurance with a misleading justification. #3 is worth a look because it can undermine the suite's own assertions. TestsCoverage of the actual fix is good, and better than most concurrency-bug tests: the six cases separate sharing (one api per id), safety (nothing released behind a caller's back), the real-world trigger ( Two gaps I'd consider:
Agreed that an e2e spec isn't warranted here — this is microtask ordering with no deterministic user-drivable surface. Security / performanceNothing to flag. No input handling or auth logic is touched. The memo is bounded by concurrent cold ids and each entry is removed when its build settles, so it can't grow unbounded; if anything it reduces work by collapsing duplicate builds. Note on verification
|
Review follow-ups. `resetInstanceState()` clears the orchestrator storage and reloads the env, but a build still in flight kept handing its pre-reset api -- built from the pre-reset context -- to every caller arriving until it settled. It now clears the memo alongside the storage. That does not stop the in-flight build from setting its api into the freshly cleared storage, which is how master behaved too; what it stops is the memo widening that from "the caller that started the build" to "everyone who arrives during it". The test's teardown moves into `afterEach`, so a test that fails before its last line no longer leaves the scheduled refresh timer running. `releaseConnections()` rather than `shutdown()`: it is the one that cancels that interval, while `shutdown()` only clears the compiler cache and releases connections. Also dropped `externalDbType: 'cubestore'` from the driver test -- the external factory is what the test needs, and the dbType additionally sent dev mode through the `CubeStoreHandler` branch for no benefit. Trimmed the comments to what the test names cannot say. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gBgEB5wBDFxWrtBhk7G7X
|
Claude finished @paveltiunov's task in 1m 37s —— View job Re-reviewed after Full re-reviewTodos
What changed since the last review
Finding #1 (medium — memo not cleared on teardown): fixed. The residual half I mentioned — the in-flight build still Finding #3 (low — leaked refresh timers): fixed, and the teardown choice is correct. I checked the claim in the new Finding #5 (low — 14-line helper JSDoc): fixed. The block is gone, Still standingBoth re-affirmed rather than re-posted; the unresolved threads carry the detail.
The fix, re-verifiedUnchanged and still correct. I also confirmed the extraction is safe to do: no subclass in the repo overrides TestsThe six cases still cover the fix well — sharing, nothing released behind a caller's back, the One gap remains: Security / performanceNothing to flag. No input handling or auth logic touched. The memo is bounded by concurrent cold ids, each entry removed when its build settles, and cleared on reset — it collapses duplicate builds rather than adding work. Note on verification
|
…s really for Review follow-ups on comment length, and a correction. The reviewer was right that the identity check in the `finally` guarded a state that could not occur: with `set` reachable only after a falsy `get`, and `delete` the only other writer, the entry could only ever be this build's own promise. That stopped being true one commit ago. `resetInstanceState()` now clears the map as well, so a build it drops mid-flight settles to find the entry owned by a later caller's build, and an unguarded `delete` would take that build's memo with it -- putting its callers back to building an api each, which is the whole defect this PR is about. Reachable only when the dropped build fails: one that succeeds fills the cache on its way out, and callers read the cache before the memo. So the check stays and its comment now names the writer that makes it load-bearing, with a test that fails without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gBgEB5wBDFxWrtBhk7G7X
|
Claude finished @paveltiunov's task in 1m 33s —— View job Re-reviewed after Full re-reviewTodos
What changed since the last review
Finding #4 (13-line field JSDoc): fixed. Down to three lines stating the invariant and its consequence. Finding #2 (the
Without the identity check, step 4 deletes B's entry, and every caller arriving while B is still building goes back to building an api of its own — the original bug, re-entered through the reset. So the New test:
The guard on The reachability claim in the test's comment also checks out: a build that succeeds writes Findings
Remaining gapsNeither is blocking; noting them so they're a deliberate choice.
Security / performanceNothing to flag. No input handling or auth logic touched. The memo is bounded by concurrent cold ids, each entry removed when its build settles, and cleared on reset. Note on verification
|
Review follow-ups. Every other case in the file pins `contextToOrchestratorId` to a constant, so a memo keyed too loosely -- or not keyed at all -- would satisfy all of them. The new case gives each caller an id of its own and expects an api each; keying the memo on a constant fails it, and none of the others notice. The note about a replaced entry being released moves above the imports, where it reads as the file's premise rather than as an explanation of `const cores`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gBgEB5wBDFxWrtBhk7G7X
|
I'll analyze this and get back to you. |
…y cannot say Review nits. The first two sentences restated the test name and the `resetInstanceState()` call in it; what survives is the reason build 0 has to throw, which nothing else in the body explains. `waitFor` now drains through `flushReleases` rather than re-spelling the same `setImmediate` promise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gBgEB5wBDFxWrtBhk7G7X
|
Claude finished @paveltiunov's task in 1m 39s —— View job Re-reviewed after Full re-reviewTodos
What changed since the last reviewFour things, and they close everything I had left open. The two-id gap is closed (
Findings
That's the only thing I found this round. The fix, re-verifiedUnchanged since
I also re-confirmed the release-spy assertions aren't vacuous, since the whole suite leans on them. TestsEight cases now, and the set covers the fix from every angle that matters: sharing within an id, separation across ids, nothing released behind a caller's back, the No further gaps I'd hold the PR for. The succeeding half of the reset crossing (an in-flight build landing its pre-reset api in freshly cleared storage) remains untouched, as agreed two rounds ago — it predates this PR, and fixing it means releasing the in-flight api rather than forgetting its promise. Security / performanceNothing to flag. No input handling or auth logic touched. The memo is bounded by concurrent cold ids, each entry removed when its build settles, and cleared on reset — it collapses duplicate builds rather than adding work. Prior threadsAll eight are resolved, seven by the author's commits and one by mine. Nothing re-affirmed, nothing skipped as a duplicate. Note on verification
|
"One id for every caller" parses as one id per caller, which is the opposite of what the fixture does -- and it now sits a few lines above a test named "callers of different ids get an api each", where that misreading has somewhere to land. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gBgEB5wBDFxWrtBhk7G7X
|
Claude finished @paveltiunov's task in 54s —— View job Re-reviewed after Full re-reviewTodos
What changed since the last reviewThree test-only commits;
The fix, re-verifiedUnchanged and still correct. TestsEight cases, and the set now separates every dimension that matters: sharing within an id, not sharing across ids, nothing released behind a caller's back, the The one residual I noted last round and said I wouldn't re-raise still stands as known and deliberate: a build in flight across a reset still writes its pre-reset api into the freshly cleared storage. That's how master behaves too, and fixing it means releasing the in-flight api rather than forgetting its promise — a different change than this regression fix. ThreadsAll nine of my prior threads are already resolved; nothing to resolve or dedupe this round. Security / performanceNothing to flag. No input handling or auth logic touched. The memo is bounded by concurrent cold ids, each entry removed when its build settles, and cleared on reset — it collapses duplicate builds rather than adding work. Note on verification
|
|
Drivers tests: the 5 Failing on run 34420899436 (head
Why it isn't this PR's. The root error is the Java bridge failing to load, identically in all five:
Three things put it outside this diff:
No fix to port. Nothing in the repo or in open PRs references Re-run: not spending a manual one — the three pushes since have already re-queued these jobs on newer heads (9193, 9194, 9195 on the current head Generated by Claude Code |
Codecov Report✅ All modified and coverable lines are covered by tests.
Additional details and impacted files@@ Coverage Diff @@
## master #11834 +/- ##
===========================================
- Coverage 80.30% 60.24% -20.06%
===========================================
Files 500 239 -261
Lines 106354 19213 -87141
Branches 3886 3888 +2
===========================================
- Hits 85405 11575 -73830
+ Misses 20399 7089 -13310
+ Partials 550 549 -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:
|
|
Following up on the CI comment above — and the answer turns out to settle it for every red job here.
The same code is green in 9191 and red in 9192/9194, so the code isn't what changed. What did change is that I pushed six times in about half an hour, and That also explains the BigQuery failure specifically, which is a different one from the JDBC bridge and worth naming. All 5 of its failures are: 111 tests passed and all 96 snapshots matched in that same job, so the driver connected and queried fine — only the pre-aggregation-backed tests failed, and only by not finding tables that had just been built. What I'm doing about each:
No code change is warranted for any of the three. I'll report where the current head lands. Generated by Claude Code |

Check List
Issue Reference this PR resolves
CORE-884 — a regression from CORE-823 / #11661.
Description of Changes Made
CubejsServerCore.getOrchestratorApi()is asynchronous and writes the orchestrator cache only at the end of the build, so concurrent callers of a cold id all miss the cache together and each cache an api of their own. Since #11661 a replacedOrchestratorStorageentry is released (disposeAfter, reasonset), andWebSocketConnection.close()is terminal — so every one of those writes closed the Cube Store web socket of the api the previous caller had just been handed, and that caller's next query failed withConcurrency of two is the everyday case rather than a rarity: one
/v1/loadwithtotal: trueruns its data query and its count query throughPromise.all, each fetching the api for itself (gateway.ts→getSqlResponseInternal), so a single request is enough to race with itself.The build is now memoized by orchestrator id while it runs. The memo is registered before the first
awaitinside the build, so nothing can interleave between the cache miss and it, and it is dropped once the build settles — a success is in the cache by then, and a failure must not become the cached answer for that id.resetInstanceState()clears the memo alongside the orchestrator storage.Evidence from the deployment that reported it
v1.7.29, both API pods up for 4.5h with no restarts, so the id keeps going cold through LRU eviction (
OrchestratorStoragemax 100) on a multi-vhost deployment.total: true.jti/vhost/iat), all five failed and all at ~2.04s in — ten concurrent builds, one surviving api.Tests
New
packages/cubejs-server-core/test/unit/getOrchestratorApi.test.ts, 8 cases. Six of them fail against the parent commit; the one that matters most is the link from "an api was released" to "a Cube Store connection was closed":Two more were added from review, each verified by mutation rather than assumed:
callers of different ids get an api each— every other case pinscontextToOrchestratorIdto a constant, so a memo keyed too loosely would satisfy all of them. Keying the memo on a constant fails this one.a build that outlives a reset does not drop the build that replaced it— pins the identity check in thefinally, whichresetInstanceState()clearing the memo makes load-bearing. Reducing it to a plaindeletefails this one in 77ms.No e2e spec: the bug is a microtask-ordering race inside
server-corewith no user-drivable surface that reproduces it deterministically.Suites run locally:
cubejs-server-coreunit (8/8 new; the 4 pre-existing failures inRefreshScheduler/OptsHandlerare identical with this file removed entirely, so they are not from this change) andcubejs-cubestore-driver(22/22, so the terminal-close semantics from #11661 are untouched). The new file also exits clean underjest --detectOpenHandleswithout--forceExit.🤖 Generated with Claude Code
https://claude.ai/code/session_014gBgEB5wBDFxWrtBhk7G7X