fix(query-orchestrator): enforce rollup-only mode on the streaming query path - #11480
Conversation
|
Claude finished @igorlukanin's task in 3m 11s —— View job Review complete — 1 high, 2 low. The orchestrator-side fix looks right; the
|
| try { | ||
| return await this.orchestrator.streamQuery(query); | ||
| } catch (err) { | ||
| this.logger('Error querying db', { | ||
| query: query.query?.replace(/\s+/g, ' '), | ||
| params: query.values, | ||
| error: ((err as Error).stack || err), | ||
| requestId: query.requestId, | ||
| }); | ||
|
|
||
| // Shaped like executeQuery's rejection so the gateway reports a query | ||
| // error rather than falling through to its Internal Server Error branch. | ||
| throw { error: (err as Error).toString() }; | ||
| } |
There was a problem hiding this comment.
This catch-all swallows ContinueWaitError and breaks the SQL API's retry-while-building path.
orchestrator.streamQuery → loadAllPreAggregationsIfNeeded → QueryQueue throws ContinueWaitError (QueryQueue.ts:364) whenever a pre-aggregation build is still in flight — this is a routine outcome on the streaming path, not an error. Today it propagates as an Error with message === 'Continue wait', which is exactly what gateway.ts:1995 keys on:
const e = err.message === 'Continue wait' ? { error: 'Continue wait' } : err;That normalisation feeds handleError's Continue-wait branch → 200 with error: 'Continue wait', and the native transport retries on it (rust/cubesql/cubesql/src/compile/engine/df/scan.rs:898 — note it's eq_ignore_ascii_case("continue wait"), an exact match, not a substring test).
After this change the rejection is { error: 'Error: Continue wait' }:
err.messageisundefined, sogateway.ts:1995no longer recognises it;handleErrorfalls into thee.errorbranch → 400 "Orchestrator error";- the error string is
'Error: Continue wait', which fails the exact-match check inscan.rs, so the client does not retry.
Net effect: a query issued over the SQL API while its pre-aggregation is building now fails hard instead of waiting. It also logs Error querying db for a non-error, whereas executeQuery deliberately logs Continue wait for the same condition.
ContinueWaitError is already imported in this file — suggest letting it through untouched so the existing gateway normalisation keeps working:
| try { | |
| return await this.orchestrator.streamQuery(query); | |
| } catch (err) { | |
| this.logger('Error querying db', { | |
| query: query.query?.replace(/\s+/g, ' '), | |
| params: query.values, | |
| error: ((err as Error).stack || err), | |
| requestId: query.requestId, | |
| }); | |
| // Shaped like executeQuery's rejection so the gateway reports a query | |
| // error rather than falling through to its Internal Server Error branch. | |
| throw { error: (err as Error).toString() }; | |
| } | |
| try { | |
| return await this.orchestrator.streamQuery(query); | |
| } catch (err) { | |
| // A pre-aggregation still building is not a query error: let it through | |
| // unchanged so the gateway maps it to a retryable `Continue wait`. | |
| if (err instanceof ContinueWaitError) { | |
| throw err; | |
| } | |
| this.logger('Error querying db', { | |
| query: query.query?.replace(/\s+/g, ' '), | |
| params: query.values, | |
| error: ((err as Error).stack || err), | |
| requestId: query.requestId, | |
| }); | |
| // Shaped like executeQuery's rejection so the gateway reports a query | |
| // error rather than falling through to its Internal Server Error branch. | |
| throw { error: (err as Error).toString() }; | |
| } |
Worth a regression test asserting a ContinueWaitError from the orchestrator still reaches the caller as Continue wait — the currently-untested half of this fix is where the behaviour change hides. Fix this →
CUBEJS_ROLLUP_ONLY=trueis meant to make a pre-aggregation miss fail instead of falling through to the source database. On the streaming path it did nothing: the query ran against the warehouse and returned correct rows, so an operator got no signal that acceleration had been lost.QueryOrchestratorhas two entry points that both load pre-aggregations, but only one applied the guard.fetchQuerycheckedrollupOnlyModeafter derivingusedPreAggregations;streamQuerycalled the identicalloadAllPreAggregationsIfNeededand then went straight tocachedQueryResult, never checking. That check was the only enforcement site in the codebase, so anything reaching the orchestrator throughstreamQuerybypassed rollup-only completely — andstreamQueryis the streaming / SQL API path, reached from the twopersistent: truecall sites inApiGateway.The bypass is the transport, not the planner. Multi-stage matching already fails closed — Tesseract's all-or-nothing rollback returns empty usages, which
fetchQueryrejects — so this affected any query over the streaming path, multi-stage or not.This matters beyond a missed optimisation: rollup-only is a cost-control and SLA guarantee, and it also disables source-database connection testing. A deployment could therefore be configured as if the warehouse were unreachable while still querying it on every SQL API request.
What changed
The miss check is extracted into one
checkRollupOnlyModemethod and called from both entry points. InstreamQueryit runs after pre-aggregations are loaded but before the stream is created, which is where an error can still be returned to the caller rather than surfacing mid-flight; the error message is unchanged, so a miss now looks the same whichever transport was used.The guard tests
preAggregationsTablesToTempTables.lengthrather than re-derivingusedPreAggregations. That derivation feeds two response shapes that onlyfetchQueryneeds, and the emptiness condition is equivalent —R.fromPairscan collapse duplicate keys but never turns a non-empty array into zero keys — so the cheaper test is the same boolean, not an approximation of it.OrchestratorApi.streamQuerynow normalises a thrown error the wayexecuteQueryalready does. Without it, this failure reachedApiGateway.handleErroras a bareErrorand was reported as a 500 "Internal Server Error" with a stack, while the identical miss on/v1/loadis a query error. The message the SQL client receives was already correct; the server-side log was not.Behaviour change
A rollup-only deployment whose SQL API queries were silently missing pre-aggregations will now see those queries fail. That is the point of the setting, and it is the same error
/v1/loadhas always raised, so nothing new needs handling on the client. The fix reveals a pre-existing miss rather than creating one — but it is worth calling out for anyone who had come to rely, knowingly or not, on the fall-through.No API, configuration, or schema changes.
Tests
New unit coverage in
packages/cubejs-query-orchestrator/test/unit/QueryOrchestratorRollupOnly.test.ts, over a realQueryOrchestrator:streamQuery, and the source database is never queriedfetchQueryEach assertion was checked against a deliberately broken implementation: removing the guard from
streamQuery, making it throw whenever rollup-only is on, making it ignore the flag, and rewording the message all turn a different test red.Documentation already described the fixed behaviour — "Cube will only fulfill queries using pre-aggregations", and
rollupOnlyModeas "an error will be thrown if a query can't be served from a pre-aggregation" — so no docs change was needed here; the code had drifted from what was written.