fix: report query depth instead of failing or crashing on it - #11815
fix: report query depth instead of failing or crashing on it#11815waralexrom wants to merge 8 commits into
Conversation
|
Claude finished @waralexrom's task in 5m 25s —— View job Review complete — no blockers; 1 medium, 5 low findings posted inlineFull review
OverallThis is unusually well-diagnosed work. Three distinct failure modes, correctly separated into "counters that were far tighter than the stack behind them" and "no counter at all", with the fix matched to each. The measurements in the description are the kind of thing that makes a depth budget defensible rather than arbitrary, and enabling A few things I specifically liked:
Findings
Checked and fine
None of these findings should block. #1 is the one I'd want addressed before merge, since it is the guard undermining its own guarantee on the exact input class it was written for. |
| // A subquery is a whole plan hanging off the expression that carries it. This is | ||
| // the one recursive step here, taken once per level of subquery nesting. | ||
| match expr { | ||
| Expr::ScalarSubquery(subquery) | ||
| | Expr::Exists(Exists { subquery, .. }) | ||
| | Expr::InSubquery(InSubquery { subquery, .. }) => { | ||
| deepest_subquery = deepest_subquery | ||
| .max(depth + logical_plan_depth(subquery.subquery.as_ref())); | ||
| } |
There was a problem hiding this comment.
The guard's own walk recurses without a bound, on the one input shape it exists to refuse.
logical_plan_depth is carefully iterative for plan inputs and expression trees, but subqueries reach it through this call, so nesting depth d of ScalarSubquery/Exists/InSubquery costs d native frames of logical_plan_depth + expression_depths + the apply_expressions closure — and each frame allocates a HashMap and a Vec. Since no-recursion-limit removed prost's bound, this function is now the only thing standing between a deep plan and a stack overflow, and it measures before it can report. A plan nested past what the walk survives aborts the process inside the guard — exactly the failure mode the PR removes elsewhere.
Two things would close it:
- Carry the depth into the recursive call and bail out as soon as it exceeds
max_query_plan_depth(), rather than computing the full depth and comparing afterwards. That makes the walk's own recursion bounded by the budget by construction. - Thread one
depth_belowmemo through the subquery recursion instead of building a fresh one per call. Today the sameArc'd subquery plan reached from k expressions is re-walked k times, and nested sharing multiplies (2^d in the pathological case).
In practice the parser limit (128) bounds subquery nesting from the SQL path, so this is a defense-in-depth gap rather than a live crash — but the whole point of the change is that the depth budget is the thing that must not be trusted to some other layer.
| /// Serializing and deserializing both recurse per plan node and cost far more stack in a | ||
| /// debug build than in the release build that ships, hence the explicit size. | ||
| fn on_a_deep_enough_stack<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T { | ||
| std::thread::Builder::new() | ||
| .stack_size(32 * 1024 * 1024) | ||
| .spawn(f) | ||
| .unwrap() | ||
| .join() | ||
| .expect("serialization must not exhaust the stack") |
There was a problem hiding this comment.
32 MiB here means the load-bearing claim about the budget is never tested.
The doc on DEFAULT_MAX_QUERY_PLAN_DEPTH (lines 45–48) says the budget is really a stack budget and that 150 stays well inside the 4 MiB select worker gets. That is the property the whole design rests on — but plan_at_the_depth_limit_survives_the_serialization_roundtrip runs the roundtrip on 8× that stack, so the test passing tells us nothing about whether a depth-150 plan decodes on a worker.
Contrast parse_on_a_main_sized_stack in sql/parser.rs, which deliberately uses the production 8 MiB for exactly this reason — the rationale there ("the budget is only usable on a stack that fits it") applies here too, and the two helpers reach opposite conclusions from the same argument.
If a debug build genuinely can't decode 150 levels on 4 MiB, that is worth knowing and stating; a #[cfg(not(debug_assertions))] variant pinned to 4 * 1024 * 1024 would at least make CI's release runs enforce it.
| return Err(CubeError::user(format!( | ||
| "Query plan is nested too deeply to execute: {} levels against a limit of {}. \ | ||
| Reduce the nesting the query asks for -- chained stages, nested subqueries and \ | ||
| expressions all count -- or raise CUBESTORE_MAX_QUERY_PLAN_DEPTH.", | ||
| depth, limit | ||
| ))); |
There was a problem hiding this comment.
The message invites the operator to raise CUBESTORE_MAX_QUERY_PLAN_DEPTH, but the budget is only meaningful relative to CUBESTORE_SELECT_WORKER_STACK_SIZE (4 MiB default, and itself operator-settable). Someone who follows this advice to 600 on a node with the worker stack lowered to 1 MiB trades a reportable error for the unreportable stack overflow this PR is removing.
Worth naming the coupling — either in the message ("…and raise CUBESTORE_SELECT_WORKER_STACK_SIZE with it") or by deriving the ceiling from the configured worker stack rather than hardcoding 150 independently of it.
| fn max_query_plan_depth() -> usize { | ||
| static MAX_DEPTH: OnceLock<usize> = OnceLock::new(); | ||
| *MAX_DEPTH.get_or_init(|| { | ||
| let depth = env_parse_lenient( | ||
| "CUBESTORE_MAX_QUERY_PLAN_DEPTH", | ||
| DEFAULT_MAX_QUERY_PLAN_DEPTH, | ||
| ); | ||
| if depth == 0 { | ||
| DEFAULT_MAX_QUERY_PLAN_DEPTH | ||
| } else { | ||
| depth | ||
| } | ||
| }) | ||
| } |
There was a problem hiding this comment.
0 silently becomes the default. That is a defensible choice, but 0 is the value an operator most plausibly sets when they mean "no limit", and they get the tightest setting instead, with no warning — unlike env_parse_lenient, which at least logs when it discards a malformed value.
The same three-line pattern appears verbatim in sql/parser.rs::sql_parser_recursion_limit. A shared env_parse_positive(name, default) that logs the rejection would cover both and keep the two knobs behaving alike.
| pub fn check_multi_stage_depth(roots: &[Rc<MemberSymbol>]) -> Result<(), CubeError> { | ||
| let limit = max_multi_stage_depth(); | ||
| for root in roots { | ||
| let depth = multi_stage_depth(root); | ||
| if depth > limit { | ||
| return Err(CubeError::user(format!( | ||
| "Member '{}' chains {} multi-stage members deep, against a limit of {}. Each \ | ||
| one is planned as a separate stage, and this many cannot be planned. Collapse \ | ||
| the intermediate stages into fewer members, or raise \ | ||
| CUBEJS_MAX_MULTI_STAGE_DEPTH.", | ||
| root.full_name(), | ||
| depth, | ||
| limit | ||
| ))); | ||
| } | ||
| } | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
Two notes on this loop.
The memo is per-root. multi_stage_depth builds a fresh HashMap for every entry in roots, and all_used_symbols() returns every measure, dimension and filter symbol in the request. For a wide query over a shared member graph that is O(roots × graph) — the memoization inside the walk deliberately avoids re-expanding a shared symbol, and then the outer loop re-expands the whole graph anyway. Hoisting depth_below / measured out of multi_stage_depth and threading them across roots keeps the walk linear in the graph.
The guard is stage-only, and the crash it prevents isn't. a_plain_member_chain_is_not_a_multi_stage_chain pins that a 40-member calculation chain plans fine, and the module doc explains why (a calculation level costs a fraction of a stage). That's correct as a budget decision, but it means the process-abort failure mode is only narrowed, not closed: a long enough plain chain still exhausts the stack with no error, and unlike the multi-stage case nothing counts it. The PR's risk list mentions compile-time overflow but not this. Worth stating explicitly, since "refuses the chain instead of overflowing" reads as a stronger guarantee than what ships.
| //! Depth guard for chained multi-stage members. | ||
| //! | ||
| //! Every multi-stage member on a dependency path becomes its own CTE, and `MultiStageQueryPlanner` | ||
| //! plans them by descending from a member into its children. Planning runs synchronously on the | ||
| //! caller's thread, so a long enough chain exhausts that thread's stack and the process aborts — | ||
| //! a crash no error handler sees, on either side of the native boundary. | ||
| //! | ||
| //! The guard turns that into a reportable error. It measures only multi-stage members that are | ||
| //! planned as a stage: calculation chains recurse as well, but cost a fraction of a stage per | ||
| //! level, and a reference -- a view member proxying one, say -- inherits `multi_stage` from what | ||
| //! it resolves to while planning collapses it into that member and opens no stage of its own. | ||
|
|
||
| use crate::planner::symbols::MemberSymbol; | ||
| use cubenativeutils::CubeError; | ||
| use std::collections::{HashMap, HashSet}; | ||
| use std::rc::Rc; | ||
| use std::sync::OnceLock; | ||
|
|
||
| /// Multi-stage members a single dependency path may carry. | ||
| /// | ||
| /// Far above any hand-written model — real ones chain a handful of stages — and below the depth | ||
| /// at which planning runs out of stack, which a release build reaches around a few hundred | ||
| /// minimal stages, sooner for heavy ones and sooner still on a smaller caller stack. | ||
| const DEFAULT_MAX_MULTI_STAGE_DEPTH: usize = 32; |
There was a problem hiding this comment.
Per the repo's review guidance an explanatory comment runs to about three lines; this file opens with an 11-line module doc plus a 6-line doc on the constant, and serialized_plan.rs (lines 41–51, 101–106) and sql/parser.rs (the 9-line doc on DEFAULT_SQL_PARSER_RECURSION_LIMIT) are similar.
The content is genuinely load-bearing here — "the budget is a stack budget", "only members planned as a stage count", the measured per-level cost — so this isn't narration to delete. But most of these read as PR description pasted into the source, and the parts that will rot first (measured ceilings, "a release build reaches around a few hundred") will rot silently. Suggest keeping the one sentence per block that a later editor would break something by not knowing, and letting the PR body carry the rest.
For this file that's roughly: stages are planned recursively so a long chain overflows the stack; only members that open a stage are counted, because references collapse and calculations cost a fraction of a level.
| arc-swap = "1.7.1" | ||
| object_store = "0.11.1" | ||
| prost = "0.13.1" | ||
| prost = { version = "0.13.1", features = ["no-recursion-limit"] } |
There was a problem hiding this comment.
no-recursion-limit is a Cargo feature, so it unifies across the whole build graph — every crate that decodes protobuf in this workspace loses the 100-level bound, not just datafusion-proto on the plan path. The plan bytes are cluster-internal and the new check covers the encode side, but nothing replaces the bound on the decode side, which is where the stack actually gets consumed and where a worker running a mixed-version rollout is exposed.
The risk section already calls this out and the mitigation (deploy workers first) is the right one; the part worth pinning down in the code is that check_query_plan_depth is only reachable from to_serialized_plan. A worker that receives bytes it did not encode has no guard at all. If SerializedPlan::logical_plan re-ran check_query_plan_depth on the decoded plan it would still be too late to prevent an overflow — so if you want a real bound there, it has to come from a depth-counting decoder or from keeping a (higher, finite) prost limit rather than removing it.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #11815 +/- ##
=======================================
Coverage 80.33% 80.33%
=======================================
Files 500 500
Lines 106397 106397
Branches 3881 3881
=======================================
Hits 85472 85472
Misses 20375 20375
Partials 550 550
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:
|
Both budgets a deep query runs into are internal recursion caps that report
neither depth nor a way to raise them:
- `sqlparser`'s own recursion limit of 50, never configured, surfaced through
`From<ParserError>`'s `{:?}` as `Internal: RecursionLimitExceeded`;
- prost's compile-time decode limit of 100 protobuf levels, which is two
levels per logical-plan node and so caps a plan at about 48 nodes — roughly
24 chained multi-stage stages, since Cube Store inlines a CTE body at every
reference.
These tests state what the two paths owe the caller: nesting a generated query
reaches has to parse, a plan past prost's cap has to survive the serialization
roundtrip, and past whatever budget does apply the error has to name the depth
reached, the budget, and the knob that raises it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ion caps
Deep queries were refused, or aborted the node, by budgets nobody chose and
nothing reported.
- The SQL parser ran at `sqlparser`'s default of 50 nesting levels. It now
takes 128, configurable through `CUBESTORE_SQL_PARSER_RECURSION_LIMIT`.
- Plan serialization ran into prost's compile-time decode limit of 100
protobuf levels — two per plan node, so about 48 nodes. That cap is lifted
(`no-recursion-limit`) and replaced by an explicit budget on plan depth,
`CUBESTORE_MAX_QUERY_PLAN_DEPTH`, checked before the roundtrip. Unlike
prost's, this one is reported: a plan past it names the depth reached, the
budget and the knob, and it is a user error, because the query is what has
to change.
- `From<ParserError>` rendered `RecursionLimitExceeded` through `{:?}`, so the
one message that did exist became a bare variant name classified as an
internal error. It now names nesting depth and stays a user error.
Both budgets are really stack budgets: every level of nesting is a recursive
descent, and neither the parser nor the protobuf decoder grows its stack. The
`cubestore-main` runtime was leaving its threads at the 2 MiB platform default
while select workers sized theirs explicitly, so it now asks for 8 MiB, under
`CUBESTORE_MAIN_STACK_SIZE`. Measured on a release build: decoding takes a
plan 364 levels deep on 2 MiB and 604 on 4 MiB, and parsing takes 200 levels
of the costliest shape (nested subqueries, around 33 KiB a level) on 8 MiB.
Both defaults sit well inside what 8 MiB allows.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every multi-stage member on a dependency path is planned as its own stage, by descending from a member into its children. Planning is synchronous on the caller's thread, so a long enough chain exhausts that thread's stack and the process aborts — nothing on either side of the native boundary sees an error, and a served query dies without one. These tests state that a chain past the budget is refused with the depth, the member and the knob named, that a chain inside it still plans, and that a reference chain of the same length is not treated as a chain of stages. The four-entrypoint query over Cube Store was ignored for the protobuf decode limit it used to hit; with that limit gone it runs, so it is enabled here and its results are pinned. The harness checks the rollup answer against the same query over Postgres before snapshotting it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…flowing A chain of multi-stage members long enough to exhaust the planning thread's stack aborted the process. `buildSqlAndParams` is a synchronous native call, so that abort is a dead API worker and a request that ends with no error at all rather than something a caller can act on. Planning now measures the longest chain of multi-stage members reachable from each queried member and refuses anything past `CUBEJS_MAX_MULTI_STAGE_DEPTH` (32 by default), naming the depth, the member and the knob. The walk keeps its own stack and memoizes per symbol, so it survives the graphs it is there to refuse and terminates on a cyclic one. Only stages are counted. Reference and calculation chains recurse as well, but cost a fraction of a stage per level, and models built over views reach depths a stage budget would wrongly refuse. The budget is not a proof of safety: how much stack a stage needs depends on what it plans, and how much there is depends on the caller. A release build plans a few hundred minimal stages before the stack runs out, so 32 leaves a wide margin there, and a heavy stage narrows it. Chains deep enough to exhaust the stack while the model is still being compiled, before planning begins, are still not reported. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dary The protobuf encoding nests two message levels for an expression node just as it does for a plan node, so a deeply nested expression is as deep an encoding as a chain of stages, on a plan of two nodes. With prost's own decode limit gone, nothing else bounds it. Also pins the boundary itself: a plan of exactly the budget has to be served, which the previous case missed by one node and so left the comparison free to be either strict or not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lifting prost's decode limit removed the only bound on how deeply a message could nest, and the budget that replaced it counted plan nodes and the plans of subqueries but not expression nesting. A nested arithmetic expression a hundred levels deep is a two-node plan the budget read as trivially shallow, and it decoded with nothing stopping it. Depth is now one measure over all three, because the encoding nests them the same way: a plan node, an expression node and a subquery's root each cost a level, and they add along a path rather than competing for the maximum. The expression walk keeps its own stack too, so only subquery nesting recurses. Two things the budget's own documentation got wrong: deserializing happens in the select worker, whose stack is `CUBESTORE_SELECT_WORKER_STACK_SIZE` and not the 8 MiB the main runtime now asks for, so that is the stack the default is measured against; and parsing is what runs on the main runtime. The parser budget also applies to streaming sources now. They built their own parser at sqlparser's default of 50, so a Kafka source's select statement was refused at a depth the error attributed to a limit and an environment variable that had no bearing on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A view member is a proxy that inherits `multi_stage` from what it resolves to, and it sits a dependency level above it. Planning collapses the proxy before it plans anything, so it opens no stage — but a budget that counts every multi-stage symbol on the path counts it, and the same chain that plans on the cube is refused one member short through the view. Also drops the module note describing the `#[ignore]`d shape and the `--ignored` run that no longer exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A reference inherits `multi_stage` from the member it resolves to, and planning resolves the chain before it does anything, so the reference opens no stage of its own. Counting it charged a view's re-export of a chain an extra level and refused a query that plans on the cube it re-exports. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c967e2b to
70656cf
Compare
|
Claude finished @waralexrom's task in 55s —— View job Re-review after force-push — no new findings; the push was a rebase, all 7 prior threads still applyFull re-review
What changed in this pushNothing in the diff. Every blob SHA on the branch is identical to the one I reviewed at
The eight commits are the same eight, rewritten by a rebase onto a newer Prior threadsRe-affirmed all 7; none resolved, since every one is still live on unchanged lines. In the order I'd act on them:
Verdict unchanged: none of these block, and #1 remains the one worth addressing, since it's the guard undermining its own guarantee on exactly the input class it was written for. |
Problem
A deeply encoded query fails in three different ways, and none of them says that depth is the reason.
Error decoding expr as protobuf: … recursion limit reached. The same query ungrouped succeeds.Error: Internal: RecursionLimitExceeded.Nothing at compile or plan time says a depth budget exists, and none of the three messages names depth. Flattening the encoding cures all three.
Cause
All three reproduce, and they are two different animals.
(1) and (3) are counters, not stack exhaustion.
sqlparser's default recursion limit of 50;CubeStoreParsernever calledwith_recursion_limit. Reproduces at nesting depth 47.From<ParserError> for CubeErrorformatted the error with{:?}.ParserError::RecursionLimitExceededcarries no message of its own, so the variant name became the message, classifiedInternal— hence the exactInternal: RecursionLimitExceededfrom the report.datafusion-protonests two message levels per logical-plan node, capping a plan at about 48 nodes. Cube Store inlines a CTE body at every reference, so one chained stage arrives asProjection+SubqueryAlias= 2 nodes — roughly 24 stages, which is where the report lands. Reproduced byte-identically at plan depth 54.to_serialized_plan()and thenexecute_router_plandecodes it again.Both counters were far more conservative than the stack behind them. Measured on a release build, 2 MiB of thread stack decodes a plan 364 levels deep, so prost gave up with roughly 87% of the stack still unused.
(2) is stack exhaustion, with no counter involved.
buildSqlAndParamsis a synchronous native call, and multi-stage planning descends once per stage. A long enough chain exhausts the calling thread's stack and the process aborts —fatal runtime error: stack overflowis not a panic and not catchable, on either side of the native boundary, so a dead worker and a Bad Gateway is all the caller sees.What changed
Cube Store — the counters become budgets that report themselves.
CUBESTORE_SQL_PARSER_RECURSION_LIMIT.no-recursion-limit) and replaced by an explicit check on plan depth before the roundtrip, underCUBESTORE_MAX_QUERY_PLAN_DEPTH(150). Past it the query is refused with the depth reached, the budget and the knob named, as a user error. Depth is one measure over everything the encoding nests — plan nodes, expression nodes and the plans of subqueries carried in expressions — added along a path, and the walk keeps its own stack rather than recursing.RecursionLimitExceedednow names nesting depth and stays a user error.cubestore-mainwas leaving its tokio threads at the 2 MiB platform default while select workers sized theirs explicitly; it now asks for 8 MiB, underCUBESTORE_MAIN_STACK_SIZE. Both budgets are stack budgets — neither the parser nor the protobuf decoder grows its stack — so this is what makes raising them real rather than nominal.Tesseract — the crash becomes an error. Planning measures the longest chain of multi-stage members reachable from each queried member and refuses anything past
CUBEJS_MAX_MULTI_STAGE_DEPTH(32), naming the depth, the member and the knob. The walk is iterative and memoized per symbol, so it survives the graphs it exists to refuse and terminates on a cyclic one. Only members that are actually planned as a stage count: a reference — a view member proxying one, say — inheritsmulti_stagefrom what it resolves to while planning collapses it, and calculation chains recurse too but cost a fraction of a stage per level.How it was verified
Generated models with depth as a parameter, so every claim above is a measurement.
Internal: RecursionLimitExceeded. Serialization: the ticket's decode error, and without the plan-depth check a 301-node plan aborts the process during encode, not only decode. Planner: a 40-stage chain planned happily instead of being refused, and a 32-stage chain re-exported by a view was refused at 33.test_four_entrypoints_in_one_querywas ignored for exactly this protobuf limit. It is enabled here and passes against a live Cube Store, with the rollup answer checked against the same query over Postgres.cubesqlplanner --lib --features integration-cubestore1377 passed / 0 failed;cubestore --lib348 passed with 2 failures that are the known environmental ones (an external CSV answering 403, and one that passes in isolation and only fails under parallel load).Risks
CUBESTORE_MAIN_STACK_SIZE= 8 MiB applies to tokio's blocking threads too, so withmax_blocking_threadsat its default the reserved address space grows. Stacks commit lazily, so this is virtual, not resident — but an environment withulimit -vset would notice.CUBEJS_MAX_MULTI_STAGE_DEPTHis read directly in Rust and is not registered inpackages/cubejs-backend-shared/src/env.ts; there is no config channel into the planner for it, and the alternative is plumbing it throughBaseQueryOptionsfrom JS.recursion limit reachedthis PR set out to remove. It is transient and self-resolving, but it is the wrong order to roll out in.🤖 Generated with Claude Code