refactor(query-orchestrator): LocalQueueDriver - drop processingId - #11596
refactor(query-orchestrator): LocalQueueDriver - drop processingId#11596ovr wants to merge 7 commits into
Conversation
…queue LocalQueueDriver kept six parallel maps keyed by query hash plus a Redis-era `processingLocks[hash] = processingId` lease that had to be released explicitly via `freeProcessingLock`. Cube Store has no lock token at all: a queue item's `status == Active` *is* the lock, set atomically by `QUEUE RETRIEVE` and released only by `QUEUE ACK`/`QUEUE CANCEL`, both of which delete the row. `freeProcessingLock` was already a no-op there and `processingId` was never sent to Cube Store in any command. Collapse the six maps into one Cube Store shaped record per key (`id`, `status`, `priority`, `created`, `heartbeat`, `orphaned`, `payload`, `extra`) plus a `byId` index, and drop the processing lock and `processingId` concepts from the interface, both drivers and QueryQueue. Removing the lock is safe because `retrieveForProcessing` was inverted from mutate-then-discover to discover-then-mutate: concurrency is checked before any write, so every failure path leaves the queue untouched and there is nothing to roll back. `insertedCount === 1` now implies the execute gate passes. Divergences this fixes in the memory driver: | behaviour | before | after (Cube Store parity) | | --- | --- | --- | | retrieve failure | took the lock, wrote `active`/`heartBeat`, returned null | returns a fail tuple, mutates nothing | | retrieve on unknown key | activated a nonexistent query | creates nothing | | concurrency check | after taking the lock | before any mutation | | orphaned | `recent` survived activation, so running queries were cancelled | pending items only | | stalled | `heartBeat` written even when not activated | active items only | | getQueriesToCancel | `stalled.concat(orphaned)`, duplicate hashes | one pass, each item once | | addToQueue dedup | returned the caller's fresh queueId | returns the existing item id | The two `onlyLocalTest` tests existed only to exercise the separate lock and are replaced by eight parity tests that run against both drivers. Verified with 74/74 unit tests and the same abstract suite green against a real Cube Store 1.7.23 (25/25). Note: this removes `freeProcessingLock`, `getNextProcessingId` and the `ProcessingId` type from `QueueDriverConnectionInterface` in @cubejs-backend/base-driver. Both implementations are in-repo and updated here; no other caller exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @ovr's task in 7m 28s —— View job Review: the lock removal holds up — I verified the new
|
| # | Severity | Where | Issue |
|---|---|---|---|
| 1 | medium | QueryQueue.abstract.ts:401-407 |
withConnections skips cleanup when the callback throws, so a failing assertion leaks an active item into a concurrency: 1 shared queue and cascades into timeouts in every later test |
| 2 | low | QueryQueue.abstract.ts:450, :473 |
Assertions compare against the whole queue for the shared prefix, coupling them to leftovers from earlier tests |
| 3 | low | LocalQueueDriverConnection.ts:126-132 |
mergeDef returns the live item.payload when extra is null (the old getQueryStageState deep-cloned), and the return shape varies with whether optimisticQueryUpdate ran |
| 4 | low | LocalQueueDriverConnection.ts:252-260 |
On dedup this returns the original addedToQueueTime while Cube Store returns now; unpinned divergence in a parity-focused refactor. Also AddToQueueOptions.queueId is now read by no driver but still documented as an input |
| 5 | low | LocalQueueDriverConnection.ts:341-350 |
User-visible memory-mode change: an executing query is no longer cancelled at orphanedTimeout, only on a stale heartbeat. Correct (matches Cube Store) but deserves a release note |
Details are in the inline comments.
Not issues, for the record
optimisticQueryUpdatenow returningtruefor a missing item matchesQUEUE MERGE_EXTRA, which also always reports success, andQueryQueueignores the value — ownership is still enforced where it matters, bysetResultAndRemoveQueryreturningfalseon a stalequeueId.updateHeartBeataccepting pending items is harmless becauseisStalledis only consulted for active ones, and it's an improvement: a node holding a stale id can no longer refresh a different item that took over the same key.orphaned: options.orphanedTimeout ? … : nulltreating0as unset mirrorsif (options.orphanedTimeout)in the Cube StoreQUEUE ADDbuilder.state.resultPromisesstill grows unboundedly forisJob: truequeries that nobody blocks on (setResultAndRemoveQuerycreates and resolves a promise,getResultBlockingis the only thing that deletes it). Pre-existing, out of scope, but this PR is the natural place to notice it.
Testing / verification caveat
This checkout has no node_modules, so I could not run yarn tsc, yarn lint, or the unit suite myself — I'm relying on your reported 74/74 + clean tsc/lint, and on reading the Rust source for the Cube Store parity claims. The eight new parity tests are a real improvement over the two onlyLocalTest lock tests they replace (which asserted almost nothing beyond console.log); findings 1 and 2 are about their robustness under failure, not their coverage.
…ument `keyScore` was a Redis-era positional argument that no driver reads: both Cube Store and the memory driver derive ordering from priority plus insertion time. It was also lossy, since `time + (10000 - priority) * 1E14` reaches ~1e18, well past the 2^53 exact integer range, which quantised its time component to ~128ms. Remove it from `QueueDriverConnectionInterface.addToQueue`, both implementations, the QueryQueue call site and the test helpers that were computing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewing PR #11596
|
| await connections[0].getQueryAndRemove(connections[0].redisHash(key), null); | ||
| } | ||
|
|
||
| await connection.freeProcessingLock('race', processingId1, true); | ||
| await connection.freeProcessingLock('race2', processingId4, true); | ||
| connections.forEach(connection => queue.queueDriver.release(connection)); | ||
| } | ||
| }; | ||
|
|
There was a problem hiding this comment.
Cleanup is skipped on the exact path where it matters most. keys is only assigned from fn's resolved value, so any failing expect inside the callback throws before the assignment and the finally block iterates an empty array — the queue items stay in the shared queue.queueDriver prefix.
That is the worst case for this suite: concurrency: 1, and e.g. the saturated test leaves saturated-1 in active state. A leaked active item permanently saturates the queue, so every later test that goes through executeInQueue (orphaned, stream handler, …) times out. One clear assertion failure turns into a cascade of unrelated 10s timeouts, and the real cause is buried.
Registering keys eagerly instead of returning them keeps cleanup unconditional:
| await connections[0].getQueryAndRemove(connections[0].redisHash(key), null); | |
| } | |
| await connection.freeProcessingLock('race', processingId1, true); | |
| await connection.freeProcessingLock('race2', processingId4, true); | |
| connections.forEach(connection => queue.queueDriver.release(connection)); | |
| } | |
| }; | |
| const keys: QueryKey[] = []; | |
| const track = (...queryKeys: QueryKey[]) => { keys.push(...queryKeys); }; | |
| try { | |
| await fn(track, ...connections); | |
| } finally { | |
| for (const key of keys) { | |
| await connections[0].getQueryAndRemove(connections[0].redisHash(key), null); | |
| } |
(each test then calls track(key) right after addQuery instead of return [key].) A try/catch per removal would also help, so one failed cleanup doesn't skip the rest.
| }); | ||
| }); | ||
|
|
||
| test('retrieveForProcessing leaves the item pending when the queue is saturated', async () => { |
There was a problem hiding this comment.
These two assertions (getActiveQueries() here, getToProcessQueries() on line 473) compare against the entire queue for the shared prefix, so they only hold if no other test left anything behind — and this describe runs after orphaned, orphaned with custom ttl, stream handler, etc. Combined with the cleanup gap in withConnections, a leak from any earlier test in the file fails these for a reason that has nothing to do with the semantics under test.
Scoping the assertion to the keys the test owns keeps the intent and removes the coupling:
| test('retrieveForProcessing leaves the item pending when the queue is saturated', async () => { | |
| expect((await connection.getActiveQueries()).filter(([k]) => k === hash)).toEqual([[hash, queueId]]); |
| if (existing) { | ||
| return [ | ||
| 0, | ||
| existing.id, | ||
| pending, | ||
| existing.payload.addedToQueueTime, | ||
| ]; | ||
| } | ||
|
|
There was a problem hiding this comment.
Deliberate-looking divergence from Cube Store worth a comment (or a parity test): on dedup Cube Store's addToQueue returns data.addedToQueueTime, i.e. now, while this returns the original existing.payload.addedToQueueTime. Since added === 0 the value only reaches the Added to queue log via queueSize/addedToQueueTime, and the local answer is arguably the more useful one — but the new addToQueue dedupes by key test asserts only added/queueId, so nothing pins this down either way, and the whole point of the refactor is that the two drivers agree.
Same spot: options.queueId is now ignored (the sequence owns the id, matching Cube Store), which is right, but AddToQueueOptions.queueId is still documented as an input in queue-driver.interface.ts. Since no driver reads it anymore, its doc comment could say it is only used for the skip-queue path.
| * The orphaned timeout only ever applies to pending items, never to ones being executed. | ||
| */ | ||
| protected isOrphaned(item: LocalQueueItem, now: number): boolean { | ||
| if (item.orphaned !== null) { | ||
| return item.orphaned < now; | ||
| } | ||
|
|
||
| return now - item.created > this.orphanedTimeout * 1000; | ||
| } | ||
|
|
There was a problem hiding this comment.
This is a faithful port of filter_to_cancel in rust/cubestore/cubestore/src/cachestore/cache_rocksstore.rs:671 (custom deadline wins, else created + driver timeout, pending only) — nice.
Worth calling out in the PR body / release notes though, because it is user-visible for memory mode: previously recent survived activation, so a query still executing past orphanedTimeout (default 120s) was picked up by getQueriesToCancel, removed and cancelled. Now an active item is only cancelled once its heartbeat goes stale (heartBeatInterval * 4), and processQuery refreshes that every heartBeatInterval, so a long-running dev-mode query runs until executionTimeout instead of being killed at 120s. That is exactly the Cube Store behaviour and it is the bug fix you describe for isJob: true builds, but anyone relying on the old cap in development will see the difference.
…ty QUEUE RETRIEVE The old comment claimed no rows meant an old Cube Store without EXTENDED support, but the driver sends `EXTENDED` unconditionally: a server too old to know the keyword fails at SQL parse rather than answering with zero rows. Any server that accepts the query returns exactly one row, success or the all-NULL failure row, so zero rows is a broken counterparty and not a compatibility path. Throw `Empty response on QUEUE RETRIEVE`, matching how addToQueue already handles an empty `QUEUE ADD` response. That makes null unreachable from both drivers, so drop it from `RetrieveForProcessingResponse` and destructure the tuple directly in processQuery. The 'retrieveForProcessing on an unknown key' test can now assert unconditionally instead of guarding its expectations behind `if (result)`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @ovr's task in 5m 43s —— View job Re-review after
|
| # | Severity | Where | Issue |
|---|---|---|---|
| new | low | queue-driver.interface.ts:65 |
orphanedTime is now dead in both drivers — dropping it with keyScore avoids a second breaking interface change later. Also setResultAndRemoveQuery is typed Promise<unknown> while its new doc describes a boolean contract |
Re-affirmed 5 prior threads that still apply to the current diff (left unresolved rather than re-commented):
| Severity | Where | Issue |
|---|---|---|
| medium | QueryQueue.abstract.ts withConnections |
cleanup skipped when the callback throws, leaking an active item into the concurrency: 1 shared queue |
| low | QueryQueue.abstract.ts:444, :467 |
assertions compare against the whole shared-prefix queue |
| low | LocalQueueDriverConnection.ts:126-132 |
mergeDef returns the live item.payload when extra is null |
| low | LocalQueueDriverConnection.ts:249-259 |
dedup returns the original addedToQueueTime; Cube Store returns now (CubeStoreQueueDriver.ts:110) |
| low | LocalQueueDriverConnection.ts:340-349 |
user-visible memory-mode change: an executing query is no longer cancelled at orphanedTimeout |
Line numbers shifted by a few in a77efa9, but the code and the fix direction are unchanged in every case.
Still worth a release note
ProcessingId, RetrieveForProcessingSuccess, RetrieveForProcessingFail, freeProcessingLock, getNextProcessingId and the addToQueue arity are all reachable from @cubejs-backend/base-driver's public surface (src/index.ts:5 re-exports the whole module). Nothing in-repo is affected and QueryOrchestrator only accepts memory/cubestore at runtime, so a third-party driver cannot actually be plugged in — but it is still a published type removal, so a CHANGELOG/breaking note is warranted, alongside the memory-mode orphaned-cancellation change.
Verification caveat (unchanged)
This checkout has no node_modules, so yarn tsc, yarn lint and the unit suite could not be run here — I'm relying on your reported 74/74 + clean tsc/lint. Everything above is from reading the TypeScript and the CubeStore Rust source.
… argument Neither driver reads it: both derive the per item deadline from `options.orphanedTimeout`, which is in seconds, so passing an absolute millisecond deadline alongside it was redundant and a source of drift between the two. `addToQueue` is now `(queryKey, queryHandler, query, priority, options)`. The computation feeding it in executeInQueue goes too, including the `'orphanedTimeout' in query ? ... : this.orphanedTimeout` fallback that only ever fed `orphanedTime`. The queue wide default still applies, it's just applied driver side now: LocalQueueDriverConnection.isOrphaned falls back to the orphanedTimeout option when an item has no explicit deadline, and Cube Store omits the `ORPHANED` clause in the same case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @ovr's task in 2m 51s —— View job Re-review after
|
| # | Severity | Where | Issue |
|---|---|---|---|
| 1 | low | queue-driver.interface.ts:63 |
With orphanedTime gone, AddToQueueQuery.orphanedTimeout is the single source of the deadline but is still typed unknown and assigned into a number? field; setResultAndRemoveQuery is still Promise<unknown> against a documented boolean contract |
| 2 | low | QueryQueue.abstract.ts:294, :305 |
orphanedTimeout: time in the query object is a millisecond epoch parked under the same name as the seconds option — now demonstrably dead, since no driver reads query.orphanedTimeout |
The previous round's orphanedTime thread is implemented, so I resolved it; its secondary Promise<unknown> nit is carried into finding 1.
Prior threads re-affirmed (left unresolved, not re-commented)
| Severity | Where | Issue |
|---|---|---|
| medium | QueryQueue.abstract.ts:395-401 |
withConnections assigns keys from the callback's return value, so a failing expect skips cleanup and leaks an active item into the concurrency: 1 shared queue |
| low | QueryQueue.abstract.ts:443, :466 |
whole-queue assertions couple these tests to leftovers from earlier tests (:505/:514 in the orphaned test are the same shape) |
| low | LocalQueueDriverConnection.ts:131 |
mergeDef returns the live item.payload when extra is null |
| low | LocalQueueDriverConnection.ts:256 |
dedup returns the original addedToQueueTime; Cube Store returns now |
| low | LocalQueueDriverConnection.ts:342-348 |
user-visible memory-mode change: an executing query is no longer cancelled at orphanedTimeout |
Still worth a release note
addToQueue's arity has now changed three times in this PR (keyScore, then orphanedTime), on top of the ProcessingId / RetrieveForProcessingSuccess / RetrieveForProcessingFail / freeProcessingLock / getNextProcessingId removals — all reachable from @cubejs-backend/base-driver's public surface. Nothing in-repo is affected and QueryOrchestrator only accepts memory/cubestore at runtime, so no third-party driver can actually be plugged in, but it is still a published type removal and deserves a CHANGELOG/breaking note alongside the memory-mode orphaned-cancellation change.
Verification caveat (unchanged)
This checkout has no node_modules, so yarn tsc, yarn lint and the unit suite could not be run here — I'm relying on your reported 74/74 + clean tsc/lint. Everything above is from reading the TypeScript and the CubeStore Rust source.
| * @param options The per item orphaned deadline comes from options.orphanedTimeout, in seconds | ||
| */ | ||
| addToQueue(keyScore: number, queryKey: QueryKey, orphanedTime: number, queryHandler: string, query: AddToQueueQuery, priority: number, options: AddToQueueOptions): Promise<AddToQueueResponse>; | ||
| addToQueue(queryKey: QueryKey, queryHandler: string, query: AddToQueueQuery, priority: number, options: AddToQueueOptions): Promise<AddToQueueResponse>; |
There was a problem hiding this comment.
Dropping orphanedTime is the right call — with it gone, options.orphanedTimeout is the single input for the deadline, so the two remaining unknowns in this file are now the only weak spots in that contract:
AddToQueueQuery.orphanedTimeout(line 27) isunknown, andQueryQueue.ts:256assigns it straight intoAddToQueueOptions.orphanedTimeout?: number. That assignment only type-checks because the source isunknown-into-any-ish andqueryis loosely typed; typing itorphanedTimeout?: numberwould make the seconds contract enforced at the one place a caller can get it wrong. It matters more now than before, since the parameter that used to carry the absolute ms deadline is gone and this field is what everything derives from.setResultAndRemoveQuery(line 84) is still declaredPromise<unknown>while the doc comment right above it describes a boolean contract and both implementations returnPromise<boolean>. Tightening it makesif (!(await ...))inQueryQueuetype-checked instead of relying onunknownbeing truthy-testable.
Both are nits; neither blocks.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #11596 +/- ##
===========================================
- Coverage 84.06% 59.33% -24.74%
===========================================
Files 257 228 -29
Lines 81582 18210 -63372
Branches 0 3688 +3688
===========================================
- Hits 68581 10804 -57777
+ Misses 13001 6858 -6143
- Partials 0 548 +548
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:
|
Only QueryQueue.executeInQueue reads `query.orphanedTimeout`, and only to copy it into `AddToQueueOptions`, which is where drivers pick it up. It is absent on most queries, and every other declaration of the same field already models that (QueryCache.CacheOptions, PreAggregations, PreAggregationPartitionRangeLoader), so requiring it here was the outlier. Making it optional lets the 'orphaned with custom ttl' test drop `orphanedTimeout: time` from its query object. That value was dead - the test calls addToQueue directly, so nothing ever read it - and worse, it parked a millisecond epoch under the same name as the seconds based option that actually drives the assertion. The two newer helpers also no longer need their `<any>` casts, which existed only to satisfy the required field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @ovr's task in 1m 55s —— View job Re-review after
|
| Severity | Where | Issue |
|---|---|---|
| medium | QueryQueue.abstract.ts:395-401 |
withConnections assigns keys from the callback's return value, so a failing expect skips cleanup and leaks an active item into the concurrency: 1 shared queue |
| low | queue-driver.interface.ts:84 |
setResultAndRemoveQuery typed Promise<unknown> against a documented boolean contract (the AddToQueueQuery half of this thread is now partially addressed — optional, still unknown) |
| low | QueryQueue.abstract.ts:443, :466 |
whole-queue assertions couple these tests to leftovers from earlier tests — and orphaned with custom ttl itself has the same shape at :289/:313, which this commit touched without changing |
| low | LocalQueueDriverConnection.ts:131 |
mergeDef returns the live item.payload when extra is null |
| low | LocalQueueDriverConnection.ts:256 |
dedup returns the original addedToQueueTime; Cube Store returns now |
| low | LocalQueueDriverConnection.ts:342-348 |
user-visible memory-mode change: an executing query is no longer cancelled at orphanedTimeout |
Still worth a release note
Unchanged from the last round: ProcessingId, RetrieveForProcessingSuccess, RetrieveForProcessingFail, freeProcessingLock, getNextProcessingId and three addToQueue arity changes are all reachable from @cubejs-backend/base-driver's public surface. Nothing in-repo is affected and QueryOrchestrator only accepts memory/cubestore at runtime, so no third-party driver can actually be plugged in — but it is still a published type removal, and it deserves a CHANGELOG/breaking note alongside the memory-mode orphaned-cancellation change. This commit adds a (compatible) widening to that list.
Verification caveat (unchanged)
This checkout has no node_modules, so yarn tsc, yarn lint and the unit suite could not be run here — I'm relying on your reported clean tsc/lint. Everything above is from reading the TypeScript and the CubeStore Rust source.
…DriverConnection.ts Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Reviewing PR #11596
|
Explain why best-effort cleanup is acceptable: the queue prefix is randomized per run, so items left behind by a failed assertion cannot bleed into another run sharing the same Cube Store. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @ovr's task in 1m 28s —— View job Re-review after
|
| Severity | Where | Issue |
|---|---|---|
| low (was medium) | QueryQueue.abstract.ts:398 |
withConnections cleanup skipped on a failing expect — now documented, cascade scope is intra-run only |
| low | QueryQueue.abstract.ts:450, :471 |
whole-queue assertions couple these tests to leftovers from earlier tests |
| low | queue-driver.interface.ts:84 |
setResultAndRemoveQuery typed Promise<unknown> against a documented boolean contract |
| low | LocalQueueDriverConnection.ts:256 |
dedup returns the original addedToQueueTime; Cube Store returns now |
| low | LocalQueueDriverConnection.ts:342-348 |
user-visible memory-mode change: an executing query is no longer cancelled at orphanedTimeout |
Still worth a release note
Unchanged: ProcessingId, RetrieveForProcessingSuccess, RetrieveForProcessingFail, freeProcessingLock, getNextProcessingId and the addToQueue arity changes are all reachable from @cubejs-backend/base-driver's public surface. Nothing in-repo is affected and QueryOrchestrator only accepts memory/cubestore at runtime, so no third-party driver can actually be plugged in — but it is a published type removal, and it deserves a CHANGELOG/breaking note alongside the memory-mode orphaned-cancellation change.
Verification caveat (unchanged)
This checkout has no node_modules, so yarn tsc, yarn lint and the unit suite could not be run here — I'm relying on your reported clean tsc/lint and 74/74. Everything above is from reading the TypeScript and the CubeStore Rust source.

Check List
Description of Changes Made
LocalQueueDriverkept six parallel maps keyed by query hash plus a Redis-eraprocessingLocks[hash] = processingIdlease that had to be released viafreeProcessingLock, whereas Cube Store has no lock token at all — a queue item'sstatus == Activeis the lock, set atomically byQUEUE RETRIEVEand released only byQUEUE ACK/QUEUE CANCEL— which is whyfreeProcessingLockwas already a no-op there andprocessingIdwas never sent to Cube Store in any command. This collapses those maps into one Cube Store shaped record per key (id,status,priority,created,heartbeat,orphaned,payload,extra) plus abyIdindex and drops the processing-lock andprocessingIdconcepts fromQueueDriverConnectionInterface, both drivers andQueryQueue; removing the lock is safe becauseretrieveForProcessingis inverted from mutate-then-discover to discover-then-mutate, so every failure path leaves the queue untouched and there is nothing left to roll back. As a result the memory driver stops reporting running queries as orphaned (recentused to survive activation, which cancelledisJob: truebuilds mid-flight), stops activating unknown keys, stops returning duplicate hashes fromgetQueriesToCancel, and returns the existing item id on dedup instead of the caller's fresh one. The twoonlyLocalTesttests existed only to exercise the separate lock and are replaced by eight parity tests that run against both drivers; verified with 74/74 unit tests, cleantsc/lint, and the same abstract suite green against a real Cube Store 1.7.23 (25/25).Note
This removes
freeProcessingLock,getNextProcessingIdand theProcessingIdtype fromQueueDriverConnectionInterfacein@cubejs-backend/base-driver. Both implementations are in-repo and updated here; no other caller exists.Unrelated pre-existing issue found while verifying:
yarn integration:cubestorecannot start against publishedcubejs/cubestoreimages becausebeforeAllissuesQUEUE CLEAR, which even thev1.7.23image rejects (the release image lags its tag). I ran the suite via a local harness without that statement.