Skip to content

feat: Queue - support fast track feature - #11618

Open
ovr wants to merge 7 commits into
masterfrom
feature/query-queue-fast-track
Open

feat: Queue - support fast track feature#11618
ovr wants to merge 7 commits into
masterfrom
feature/query-queue-fast-track

Conversation

@ovr

@ovr ovr commented Aug 21, 2026

Copy link
Copy Markdown
Member

QUEUE ADD_AND_RETRIEVE inserts and claims a queue item in one atomic operation, but nothing on the JS side used it. Enqueueing a query cost five Cube Store round-trips before it could start executing, and between QUEUE ADD and QUEUE RETRIEVE another node could take the concurrency slot, so the enqueueing node often paid for the retrieval and got nothing back.

…E ADD_AND_RETRIEVE

`QUEUE ADD_AND_RETRIEVE` inserts and claims a queue item in one atomic operation, but
nothing on the JS side used it. Enqueueing a query cost five Cube Store round-trips before
it could start executing, and between `QUEUE ADD` and `QUEUE RETRIEVE` another node could
take the concurrency slot, so the enqueueing node often paid for the retrieval and got
nothing back.

`QueueDriverConnectionInterface` gains `addAndRetrieve`, whose response always carries a
claim slot. A claim is an ownership transfer: the caller owns an active item and must
execute and acknowledge it, so it gets its own method instead of riding along on
`addToQueue`. `QueryQueue` hands the claimed `QueryDef` straight to `sendProcessMessageFn`
and skips reconcile, the retrieval and the queue state lookup.

| Step | Normal | Fast track |
|---|---|---|
| `QUEUE ADD` | 1 round-trip | folded into one command |
| `QUEUE TO_CANCEL` + `QUEUE LIST` (reconcile) | 2 round-trips | skipped |
| `QUEUE RETRIEVE` | 1 round-trip | folded into one command |
| `QUEUE LIST` (the `Waiting for query` event) | 1 round-trip | skipped, the claim carries the state |
| Window for another node to steal the slot | between ADD and RETRIEVE | none |

Off by default, `CUBEJS_QUEUE_FAST_TRACK=true` makes `QueryQueue` call `addAndRetrieve`.
Claiming is then up to the driver: the Cube Store one negotiates the `queueAddAndRetrieve`
capability and falls back to `QUEUE ADD`, the memory one never claims. An unclaimed
response carries a `null` claim and the caller continues through reconcile with nothing
lost, because the item is enqueued either way.

The flag is opt-in because a `sendProcessMessageFn` override which drops the claim leaves
an active item with nobody running it, and the query stalls until `TO_CANCEL` reclaims it.

Two side effects, both documented in DEVELOPMENT.md: `queueSize` drops by one on the fast
track since a claimed item is never pending, and submission-time orphan collection no
longer runs — it still runs after every completed query, and a submission only skips
reconcile while the concurrency budget is free, which is exactly when there is nothing to
reclaim.

Also subscribes the `streamStarted` listener before the query is dispatched instead of
after, which retires the race the removed TODO described: a stream handler which starts
fast used to emit the event before anyone listened for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 62.22222% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.33%. Comparing base (725c045) to head (da24f5c).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
...-query-orchestrator/src/orchestrator/QueryQueue.ts 60.97% 10 Missing and 6 partials ⚠️
packages/cubejs-backend-shared/src/env.ts 0.00% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##           master   #11618   +/-   ##
=======================================
  Coverage   59.33%   59.33%           
=======================================
  Files         228      228           
  Lines       18222    18242   +20     
  Branches     3675     3684    +9     
=======================================
+ Hits        10812    10824   +12     
- Misses       6863     6866    +3     
- Partials      547      552    +5     
Flag Coverage Δ
cube-backend 59.33% <62.22%> (+<0.01%) ⬆️

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.

…iver

Follow-up on the `QUEUE ADD_AND_RETRIEVE` fast track.

`executeInQueue` used to read `CUBEJS_QUEUE_FAST_TRACK` and pick between `addToQueue` and
`addAndRetrieve`, which made the queue responsible for a decision only the driver can
finish anyway — it is the one which negotiates the `queueAddAndRetrieve` capability. It now
always calls `addAndRetrieve`, and the Cube Store driver combines the flag with the
capability in `useFastTrack()`, next to the identical `useExternalId()`. A driver which does
not claim returns a `null` claim, so the caller only ever sees "claimed or not".

`keyScore` and `orphanedTime` are gone from both methods. Cube Store never read them, it
derives its own from `priority` and `options.orphanedTimeout`; the memory driver now does
the same instead of having them computed for it by the caller. That also drops a latent
`NaN`: a query carrying an `orphanedTimeout` key with an `undefined` value used to produce
`undefined * 1000` as its orphan deadline, which never compares as orphaned. It falls back
to the queue default now.

Also dropped comments which restate the code they sit on.
@mintlify

mintlify Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
cubed3 🟢 Ready View Preview Aug 21, 2026, 5:07 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

ovr added 2 commits August 21, 2026 19:22
`addAndRetrieve` was a second insert method which differed from `addToQueue` only in
whether the driver also claimed the item, and every caller had to pick one. There is one
`addToQueue` again: `AddToQueueResponse` always carries a claim slot, and the driver decides
whether to fill it.

The Cube Store driver picks `QUEUE ADD_AND_RETRIEVE` over `QUEUE ADD` in one place, so the
non-fast path no longer builds the command twice by re-entering itself, and the memory
driver loses its override entirely.
@ovr
ovr marked this pull request as ready for review August 21, 2026 19:35
@ovr
ovr requested review from a team and keydunov as code owners August 21, 2026 19:35
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ovr's task in 4m 50s —— View job


Review complete — no blockers; 1 medium (claim hand-off leaks an active item on failure), 3 low (breaking interface arity, weak assertion, test gaps). 4 inline comments posted.

Full review

Todos

  • Read the diff (git diff origin/master...HEAD)
  • Review the QueryQueue.ts fast-track logic
  • Review the driver implementations (Cube Store + Local) and cross-check the Rust ADD_AND_RETRIEVE grammar/response shape
  • Review tests and docs
  • Post findings

What's good

The shape of this is right. Keeping the decision "do I claim?" entirely inside the driver — one addToQueue, an optional 5th tuple element, memory always returning null — means QueryQueue has a single code path with a branch at the end rather than two parallel enqueue flows. claimedQuery() reconstructing exactly the same ClaimedQuery that claimQueryForProcessing produces is what makes sendProcessMessageFn work unchanged for custom receivers.

A few specific things I checked and found correct:

  • Wire format. The parameter order QUEUE ADD_AND_RETRIEVE [EXCLUSIVE] PRIORITY ? [ORPHANED ?] [EXTERNAL_ID ?] ?key ?payload ?concurrency matches parser.rs:702-723 (parse_sql_options! is order-agnostic, concurrency is the trailing positional). The CubeStoreClaimResponse fields line up with the id / added / pending / active / payload / extra columns in cachestore.rs:413-421, and payload: None unless claimed matches the Option<String> doc on QueueAddAndRetrieveResponse.
  • Capability gate. 1.7.25 is above the arrowFormat floor, so the added === 'true' string comparison behaves the same as it does on the existing QUEUE ADD path.
  • decodeClaimFromRow sharing. Extracting it means RETRIEVE and ADD_AND_RETRIEVE can't drift; the refactored retrieveForProcessing fallback tuple is byte-for-byte the old one.
  • The stream fix is real and independent of the fast track. Subscribing to streamStarted before the dispatch closes the TODO that the old code carried, and dispose() in finally makes the listener/timer leak-free on the throw path.
  • orphanedTime in the local driver is now options.orphanedTimeout ?? this.orphanedTimeout instead of 'orphanedTimeout' in query ? ... : .... That's a quiet improvement: a query carrying an explicit orphanedTimeout: undefined used to produce NaN, and now falls back to the default.
  • Docs are unusually good — DEVELOPMENT.md names the two side effects (the queueSize drop, and reconcile no longer collecting orphans at submission time) instead of leaving them to be discovered, and the reasoning for why skipping TO_CANCEL is safe (a claim only happens when the budget is free, which is exactly when there's nothing to reclaim) holds up.

Findings

# Severity Where Issue
1 Medium QueryQueue.ts:313 The claim hand-off is unguarded. claimedQuery() can throw on a null queue id and sendProcessMessageFn is a documented extension point that can reject — in both cases the item is already active in Cube Store and nothing frees the processing lock, so it stalls until TO_CANCEL. processQuery wraps the identical call in try/catch; this one doesn't.
2 Low queue-driver.interface.ts:82 addToQueue loses two leading parameters from a published interface. Worth a breaking-change note in the changelog — a plain-JS out-of-tree driver shifts its args silently.
3 Low QueryQueue.abstract.ts:377 expect(stream).toBeDefined() passes for null, which is the exact failure mode the test guards.
4 Low QueryQueue.abstract.ts:608 No fast-track coverage for stream / persistent (EXCLUSIVE) queries, and the added === 0 && claim !== null branch that QueryQueue.ts:322 depends on is untested.

Smaller observations (not posted inline)

  • On the fast track toProcessQueryKeys is logged as undefined rather than an array, and queueIndex: -1 now means both "claimed, never queued" and "not found in the pending list". The new fastTrack field lets a consumer disambiguate, but any existing log/metrics pipeline that assumes toProcessQueryKeys is an array will need to tolerate undefined.
  • claimedQueueId = claimQueueId ?? queueId is defensive but currently unreachable — both come from rows[0].id of the same response row, so they're always identical.
  • Security: nothing user-controlled reaches the SQL string; concurrency, priority, key and payload all go through bound parameters, and the fastTrack ? ' ?' : '' splice only ever appends a placeholder. No concerns.

Not verified

I couldn't run the test suite or the linter — node_modules isn't installed in this checkout and a full monorepo yarn install plus build wasn't practical here. The fast-track tests also need a live Cube Store ≥ 1.7.25, so CI is the real signal on those.

· branch `feature/query-queue-fast-track`

ovr added 2 commits August 21, 2026 21:38
The four settings were hardcoded, so comparing two queue configurations meant editing the
file between runs. Defaults are unchanged.

Measured with them on a release Cube Store 1.7.25, 1000 queries, `WORKERS=0`,
`BENCH_CONCURRENCY=500`, 64KB responses, 16KB payloads:

| Driver call | Fast track off | Fast track on |
| --- | --- | --- |
| `retrieveForProcessing` | 1000 | 0 |
| `getQueryStageState` | 1000 | 0 |
| `getQueriesToCancel` (reconcile) | 1980 | 1000 |
| `getActiveAndToProcess` (reconcile) | 1980 | 1000 |
| total driver calls | 10960 | 7000 |

Wall clock is 12s either way: the bench is bound by its 1500ms handler and its 10ms pusher
interval, so it measures round trips, not latency.

At `BENCH_CONCURRENCY=50` only 50 of 1000 queries are claimed - the budget saturates after
the initial fill and the claim condition steps aside, which is what keeps the fast track
from jumping the priority order.
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ovr's task in 5m 11s —— View job


Review complete — no blockers; 2 medium, 4 low. Feature is correctly gated (env var + Cube Store 1.7.25 capability), and the Cube Store side reserves a slot per pending item so the claim can't jump the priority queue. 6 inline comments posted.

Full review — findings, verification notes, and checklist

Checklist

  • Read the diff
  • Review QueryQueue.ts fast-track logic
  • Review driver implementations (CubeStore + Local)
  • Review tests & docs
  • Post findings

What I verified

I cross-checked the JS side against the Rust implementation it depends on, since most of the risk in this change lives at that seam:

  • No priority inversion. queue_add_and_retrieve (cache_rocksstore.rs:1451) only claims when active + backlog < concurrency, i.e. it keeps a slot in reserve for every already-pending item. So a fast-tracked query can't take the slot a higher-priority pending item is waiting for. The DEVELOPMENT.md reasoning matches the code.
  • Dedup is safe. An existing Active item is never claimed twice (try_claim_queue_item bails unless status is Pending), and an existing Pending item is claimed by the second caller with its stored payload — matching the concurrent clients execute the query once test.
  • Exclusive/persistent works in principle. QUEUE ADD_AND_RETRIEVE EXCLUSIVE requires x-process-id; WebSocketConnection.ts:75 does set it. Untested though — see the inline note on the test file.
  • Queue drainage is preserved. Skipping reconcileQueue() on the claim path is fine because executeQuery reconciles after setResultAndRemoveQuery (QueryQueue.ts:1123), and API-level continueWaitTimeout retries re-enter addToQueue and will claim a still-pending item.
  • Argument order is correct. values.push(concurrency) lands after key/value, matching QUEUE ADD_AND_RETRIEVE ? ? ? in parser.rs:1337.
  • Local driver parity. Moving the keyScore / orphanedTime arithmetic into LocalQueueDriverConnection reproduces the old formulas exactly, and the ?? fallback is actually stricter than the old 'orphanedTimeout' in query check (which produced NaN when the key was present but undefined).

I did not run the build or test suite — this is a fresh checkout without installed workspace deps, and the fast-track tests need a live Cube Store. Worth confirming yarn tsc + yarn unit in cubejs-query-orchestrator locally, since the addToQueue signature change is repo-wide.

Findings

# Severity Where Issue
1 Medium QueryQueue.ts:313 sendProcessMessageFn rejection escapes to the caller, unlike the guarded call in processQuery; fails the request while leaving the item Active
2 Medium QueryQueue.abstract.ts:608 No coverage for persistent/exclusive or stream queries on the fast track — the two combinations that can hard-fail rather than degrade
3 Low QueryQueue.ts:380 claimedQuery throws on a missing queue id after the item is already active; logging + reconcile would be a softer failure mode
4 Low QueryQueue.ts:334 toProcessQueryKeys becomes undefined instead of [] in the Waiting for query event, changing the log schema for consumers
5 Low queue-driver.interface.ts:82 Breaking change to a published interface (signature + tuple arity) — needs a changelog note for out-of-tree queue drivers
6 Low CubeStoreQueueDriver.ts:148 Row type claims active/payload/extra for plain QUEUE ADD, which doesn't return them

Things I liked

  • waitForQueryStream genuinely fixes the pre-existing race the old TODO documented, and the stream handler which starts immediately test (with streamHandlerDelay = 0) pins it — that's a real bug fix riding along, not just a refactor.
  • decodeClaimFromRow shared between RETRIEVE and ADD_AND_RETRIEVE mirrors the same de-duplication done on the Rust side; the two decoders can't drift.
  • The env-var docs correctly state the version floor and the graceful fallback, and DEVELOPMENT.md is honest about the two observable side effects (queueSize off by one, TO_CANCEL no longer running at submission time).
  • AddToQueueResponse returning null from LocalQueueDriverConnection rather than faking a claim keeps the memory driver's semantics obvious, and the addToQueue never claims in memory test locks it in.
· branch `feature/query-queue-fast-track`


if (claim) {
// The item is active already, there is nothing for reconcile to pick up
await this.sendProcessMessageFn(this.claimedQuery(queryKeyHash, queueId, claim));

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 claim hand-off has no failure handling, so a failure leaks an active queue item.

By the time addToQueue returns a claim, Cube Store has already moved the item to active and this process owns the processing lock (as DEVELOPMENT.md puts it, "a receiver which drops the hand-off leaves the item active with nobody running it"). Two things on this line can throw:

  • claimedQuery() throws 'Queue driver claimed a query without reporting its queue id' when claim[1] and queueId are both null;
  • sendProcessMessageFn is a documented extension point (message bus / worker dispatch) and can reject.

Either way the exception propagates out of executeInQueue and nothing frees the lock — the item sits active with no runner until TO_CANCEL reclaims it on the heartbeat/orphan timeout, and the fast track has just skipped the reconcileQueue() that would have run TO_CANCEL. The equivalent call in processQuery (line ~814) is deliberately wrapped:

try {
  await this.sendProcessMessageFn(claimed);
} catch (e: any) {
  this.logger('Error while sending process message', { ... });
}

Suggest the same shape here, plus queueConnection.freeProcessingLock(queryKeyHash, processingId, true) on failure so reconcile can re-pick the item immediately instead of waiting out the orphan timeout. (For the null-queueId case specifically, falling back to reconcileQueue() is likely better than throwing — the item is still recoverable, the caller's query is not.)

Fix this →

Comment thread packages/cubejs-base-driver/src/queue-driver.interface.ts

// The handler can emit `streamStarted` and finish before executeInQueue awaits for
// it, which is safe only because the listener is subscribed before the dispatch
expect(stream).toBeDefined();

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.

expect(null).toBeDefined() passes, and null is exactly the failure mode this test guards against — waitForQueryStream resolves null when the streamStarted event is missed. The assertion only fails indirectly, via for await (const chunk of null) throwing a TypeError two lines down, which reads as an unrelated crash.

Suggested change
expect(stream).toBeDefined();
expect(stream).not.toBeNull();

});

// eslint-disable-next-line no-unused-expressions
options.cacheAndQueueDriver === 'cubestore' && describe('with CUBEJS_QUEUE_FAST_TRACK enabled', () => {

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.

Two gaps in this block, both on paths the PR specifically changes:

  1. No fast-track stream test. The listener-reordering in waitForQueryStream exists because the fast track dispatches sooner than reconcile did — that's the narrowest window in the whole change, and it's only exercised with the flag off (stream handler which starts immediately, line 367). A queryHandler: 'stream' + persistent: true case here would also cover ADD_AND_RETRIEVE EXCLUSIVE, which Cube Store rejects outright without an x-process-id header (cachestore.rs:393) — currently nothing checks that combination works at all.
  2. The added === 0 && claim !== null branch is untested. CubeStoreQueueDriver.ts:155 explicitly documents it ("An item which already existed is never added twice, but it still can be claimed"), and QueryQueue.ts:322 depends on it by reading the query def out of claim[4] instead of calling getQueryDef. The third test here only asserts the opposite case (an active item is not re-claimed). A pending-but-unclaimed item that a second addToQueue then claims would pin the branch down.

Nit on line 609: jest.setTimeout isn't scoped to the describe, so this 10s value applies to every test declared after it in the file too (there's a matching one at line 498 already).


if (claim) {
// The item is active already, there is nothing for reconcile to pick up
await this.sendProcessMessageFn(this.claimedQuery(queryKeyHash, queueId, claim));

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 dispatch isn't guarded the way processQuery guards it. In processQuery (line ~813) a throwing sendProcessMessageFn is caught and logged as Error while sending process message, and the caller keeps waiting on getResultBlocking until the orphan reclaim picks the item up. Here the rejection escapes the outer try and is rethrown out of executeInQueue, so a custom sendProcessMessageFn (the whole point of the option — handing the query to another process) turns a queue-dispatch hiccup into a failed API request, while the item stays Active in Cube Store with nobody processing it.

The default handler ((claimed) => { this.executeQuery(claimed); }) never throws, so this only bites custom implementations — but that's exactly the configuration this feature is aimed at.

Suggested change
await this.sendProcessMessageFn(this.claimedQuery(queryKeyHash, queueId, claim));
if (claim) {
// The item is active already, there is nothing for reconcile to pick up
const claimed = this.claimedQuery(queryKeyHash, queueId, claim);
try {
await this.sendProcessMessageFn(claimed);
} catch (e: any) {
this.logger('Error while sending process message', {
queueId: claimed.queueId,
queryKey: claimed.query.queryKey,
requestId: claimed.query.requestId,
error: (e.stack || e).toString(),
queuePrefix: this.redisQueuePrefix
});
}
} else {

Comment on lines +380 to +382
if (claimedQueueId === null) {
throw new Error('Queue driver claimed a query without reporting its queue id');
}

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 the worst of both worlds: by the time we get a claim back, Cube Store has already flipped the item to Active, so the throw fails the request and leaves the item wedged until the orphan/heartbeat reclaim. Since a claim without an id is a driver bug rather than a user-visible condition, it'd be safer to log it and fall back to reconcileQueue() (which will re-derive the id via retrieveForProcessing / skip processing and free the lock) instead of aborting the request.

Not reachable with the current Cube Store driver (ADD_AND_RETRIEVE always returns id), so this is about the failure mode, not a live bug.

Comment on lines +334 to +343
const [active, toProcess] = claim ? [claim[2], undefined] : await queueConnection.getQueryStageState(true);

this.logger('Waiting for query', {
...waitingContext,
queueSize,
activeQueryKeys: active,
toProcessQueryKeys: toProcess,
active: active.indexOf(queryKeyHash) !== -1,
queueIndex: toProcess.indexOf(queryKeyHash),
queueIndex: toProcess ? toProcess.indexOf(queryKeyHash) : -1,
fastTrack: !!claim,

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.

Skipping getQueryStageState is the right call for latency, but it silently changes the shape of the Waiting for query event: on the fast track toProcessQueryKeys becomes undefined (not []) and queueIndex is -1 rather than the real position. Anything downstream that treats toProcessQueryKeys as an array (log processors, Cube Cloud dashboards, logger assertions) will now see undefined. [claim[2], []] would keep the event schema stable at no cost, and queueIndex: -1 is already correct for a claimed item since it's never pending.

Comment thread packages/cubejs-base-driver/src/queue-driver.interface.ts
});

// eslint-disable-next-line no-unused-expressions
options.cacheAndQueueDriver === 'cubestore' && describe('with CUBEJS_QUEUE_FAST_TRACK enabled', () => {

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.

Good coverage of the happy path, the dedup path and the budget-exhausted path. Two gaps worth closing before this graduates from experimental:

  1. Persistent / exclusive queries. buildAddCommand emits EXCLUSIVE for queryKey.persistent, and Cube Store hard-errors on QUEUE ADD_AND_RETRIEVE EXCLUSIVE when the connection has no x-process-id (sql/cachestore.rs:392, and again in queue_add_and_retrieve). WebSocketConnection does send the header, so it should work — but nothing here proves it, and this is the one combination where the fast track can fail with a user error rather than degrading to the normal path.
  2. Streams on the fast track. The stream handler which starts immediately test exercises the reordered waitForQueryStream subscription, but only with fast track off. The fast track shortens the window between subscription and dispatch further; running that same test inside this describe would pin the interaction the PR body is really about.

Comment on lines +147 to +148
const command = fastTrack ? 'ADD_AND_RETRIEVE' : 'ADD';
const rows = await this.driver.query<CubeStoreClaimResponse & { added: string }>(`QUEUE ${command}${modifiers}${fastTrack ? ' ?' : ''}`, values);

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.

Minor typing nit: the row type claims active / payload / extra unconditionally, but plain QUEUE ADD only returns id, added and pending (sql/cachestore.rs). It's safe today because decodeClaimFromRow is only reached when fastTrack is true, but the type is lying about the non-fast-track branch. CubeStoreClaimResponse & { added: string } vs. { id, added, pending } | CubeStoreClaimResponse & { added: string } would keep the compiler honest if someone later reads rows[0].active outside the fastTrack guard.

The pusher pushed a query every 10ms, so a run only ever measured a queue under a burst.
`BENCH_PERIOD_MS` spreads the queries evenly over that window instead, which is what
decides whether the queue ever backlogs. Unset keeps the 10ms push, so existing runs are
unchanged.

Concurrency 10 with the harness' 1500ms handler serves 6.67 queries/s. Sweeping the arrival
rate across it, 1000 queries, single process, release Cube Store 1.7.25:

| Period | Arrival | Driver calls off | on | Claimed |
| --- | --- | --- | --- | --- |
| 60s | 16.7/s, 2.5x over capacity | 14059 | 14234 | 10/1000 |
| 180s | 5.6/s, 0.84x | 11000 | 7000 | 1000/1000 |
| 300s | 3.3/s, 0.5x | 11000 | 7000 | 1000/1000 |

Below capacity every query is claimed and it is 11 round trips per query against 7 - RETRIEVE
and LIST go to zero, reconciles halve. Above capacity the fast track claims one budget's
worth and stands down, and enabling it costs a little rather than nothing (+1.2% single
process, +3.0% with two workers - small, one run each, but the sign is consistent).

Pacing also fixes a measurement problem: the 10ms push at concurrency 10 timed out 150-160
queries per run against the 120s continueWaitTimeout and ran only ~860 handlers. Paced,
every run completes all 1000 with no continue-wait at all.
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ovr's task in 1m 49s —— View job


Re-review of the new commit — bench pacing only; no new blockers. 1 low posted inline, 6 prior threads re-affirmed, 2 resolved.

Full review

Todos

  • Diff the new commits since the last review
  • Review QueueBench.abstract.ts pacing change
  • Re-check prior threads for staleness / dedup
  • Post findings

Scope of this pass

Only one commit landed since my last review (3f11ebe):

  • da24f5c chore(query-orchestrator): let the queue bench pace its arrivalsQueueBench.abstract.ts only, +9/-3.

No production code changed, so everything I said about QueryQueue.ts, CubeStoreQueueDriver.ts, LocalQueueDriverConnection.ts and the tests still stands verbatim.

The pacing change

This is the right instrument for the question the PR is asking. Pushing every 10ms at concurrency 10 only ever measured a queue in permanent overload — where the fast track is structurally unable to help, because queue_add_and_retrieve refuses to claim unless active + backlog < concurrency. Sweeping arrival rate across service capacity is what separates "fast track does nothing" from "fast track removes RETRIEVE and LIST entirely," and the table in the commit message shows exactly that discontinuity: 11 driver calls per query → 7 below capacity, ~parity (+1.2%) above it. Reporting the small regression above capacity rather than only the win below it is the right call.

Two details I checked:

  • await running inside the setInterval callback doesn't break pacing. setInterval ignores the returned promise, so ticks keep firing on schedule even while a query is in flight — the pusher is a true open-loop arrival process, not a closed loop. That's what makes the "arrival rate vs. capacity" framing valid.
  • Defaults are preserved. BENCH_PERIOD_MS unset → periodMs = 0 → the old 10. parseInt of a non-numeric value yields NaN, and NaN > 0 is false, so a typo'd env var falls back to the old behaviour rather than producing a NaN interval. (BENCH_CONCURRENCY has no such guard — a typo there gives concurrency: NaN — but that's a bench knob, not worth code.)

The measurement fix buried at the end of the commit message is the more important part: the unpaced run was timing out 150–160 of 1000 queries against continueWaitTimeout and only running ~860 handlers, which means every prior number from this bench was computed over a truncated, partly-abandoned workload. Worth keeping in mind if any earlier figures were quoted anywhere.

Finding

# Severity Where Issue
1 Low QueueBench.abstract.ts:328 Integer rounding is multiplied by totalQueries, and the Math.max(1, …) floor silently turns periodMs < totalQueries back into a burst — a run can report a window it didn't use

Prior threads

Re-affirmed, still unresolved and still applicable (nothing on these lines changed):

  • QueryQueue.ts:313 — unguarded claim hand-off leaves the item Active with no runner (the one substantive finding in this PR)
  • QueryQueue.ts:343toProcessQueryKeys becomes undefined in the Waiting for query event
  • QueryQueue.ts:382claimedQuery throws after the item is already active
  • QueryQueue.abstract.ts:377toBeDefined() passes for null
  • QueryQueue.abstract.ts:608 ×2 — no fast-track coverage for stream / EXCLUSIVE, and the added === 0 && claim !== null branch
  • CubeStoreQueueDriver.ts:148 — row type claims active/payload/extra for plain QUEUE ADD

Resolved the two queue-driver.interface.ts:82 threads — @ovr confirmed QueueDriverConnectionInterface is internal, not public API, so the breaking-change note isn't needed.

Not verified

Same as before: no node_modules in this checkout, so no yarn tsc / yarn unit / lint run, and the fast-track tests need a live Cube Store ≥ 1.7.25. CI is the signal there. The benchmark numbers in the commit message are single runs and I have no way to reproduce them here.

· branch [`feature/query-queue-fast-track`](https://github.com/cube-js/cube/tree/feature/query-queue-fast-track)

totalQueries: 1_000,
currency: parseInt(process.env.BENCH_CONCURRENCY || '50', 10),
totalQueries,
pushIntervalMs: periodMs > 0 ? Math.max(1, Math.round(periodMs / totalQueries)) : 10,

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.

Two ways the realised arrival rate can differ from the one the run is labelled with, both worth knowing when the numbers end up in a commit message:

  • Integer rounding is amplified by totalQueries. The effective window is totalQueries * round(periodMs / totalQueries), so anything that doesn't divide evenly drifts by up to totalQueries / 2 ms. At the ratios in the commit table (60000/1000 = 60ms) it's exact, but e.g. BENCH_PERIOD_MS=45500 BENCH_TOTAL_QUERIES=1000 runs a 46s window while reporting 45.5s. The Math.max(1, …) floor is the extreme case: any periodMs < totalQueries silently becomes a 1ms push, i.e. a burst, which is exactly the regime the flag exists to avoid.
  • setInterval doesn't await the callback, so pacing is preserved (good — the await running at line 289 doesn't back-pressure the pusher), but the timer can't sustain sub-~1ms ticks and drifts under event-loop load at small intervals.

Cheap fix: log the derived interval and the implied arrival rate alongside benchSettings (it's already printed each tick), so a run is self-describing rather than relying on the operator to recompute it. Not blocking — bench-only.

Fix this →

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

data source driver javascript Pull requests that update Javascript code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant