Skip to content

fix(cubestore): transmit the router's planning flags with the query - #11628

Merged
waralexrom merged 3 commits into
masterfrom
cubestore-transmit-planning-flags
Aug 24, 2026
Merged

fix(cubestore): transmit the router's planning flags with the query#11628
waralexrom merged 3 commits into
masterfrom
cubestore-transmit-planning-flags

Conversation

@waralexrom

Copy link
Copy Markdown
Member

A select worker does not execute the physical plan the router sends it -- it plans its own
half from the logical plan it receives (worker_plan -> worker_context ->
CubeQueryPlanner::new_on_worker), and it read the planning flags from its own
configuration. Two hops are involved: router -> worker (NetworkMessage::Select) and
worker -> select subprocess (WorkerMessage::Select, where the subprocess is respawned with
Config::default(), i.e. from env).

When the values at the two ends disagree, the two halves of a split plan do not fit
together and the query returns silently wrong rows rather than failing. Measured by
skewing the router and the select subprocess:

Skew (router / select subprocess) Result
topk_strategy: FullMerge / Streaming 4 cluster top-k tests, wrong order (url10 instead of url2)
group_by_limit_factor: 0 / 2 limit_pushdown_group_nonprefix_order: sum 100 instead of 1110
group_by_limit_factor: 2 / 0 harmless
coalesce_under_hash_aggregate, push_partial_aggregate_below_merge harmless both ways

So the bundle holds exactly topk_strategy and group_by_limit_factor.
group_by_limit_per_partition is deliberately left out: it only changes the layout inside
the worker (both resort_worker_subtree paths end in a CoalescePartitionsExec, so the
schema and partition count the router sees are the same either way).

What this does

  • PlanningFlags { group_by_limit_factor, topk_strategy }, carried as a field of
    WorkerPlanningParams -- a struct that already travelled on both hops.
  • The router stamps its own values into ClusterSendExec; the worker plans from what it
    received.
  • The field is Option<PlanningFlags> with #[serde(default)]. A sender that does not send
    it is an older binary, which planned its half from its own configuration -- so the
    receiver falls back to its own configuration (worker_context), not to a hardcoded
    default. Since this branch does not change any default, that fallback is exact in both
    cases: the value is set explicitly on every node of a cluster, or it is unset and both
    binaries default to the same thing.
  • WorkerExec::new takes worker_partition_count: usize instead of the whole
    WorkerPlanningParams -- it only ever read that field, and this way the struct can grow
    without touching plan construction.
  • TopKAggregateStrategy gets serde derives deliberately without #[serde(other)]: a
    strategy from a newer node that this binary does not know must fail the query loudly.
  • Three tests in cluster::tests pin the wire contract (message without the flags, older
    receiver, round trip) and one pins the configuration fallback. flexbuffers+serde
    compatibility was checked empirically in both directions.

Release ordering -- important

This must ship before the default flip (cubestore-change-perf-defaults, #11600). The
flags only help when both nodes are new: an older worker does not know the field and plans
from its own configuration. If the default flip lands at the same time or earlier, there is
a "new router + old worker" window with silently wrong results that no fallback can close.

Testing

cargo test --no-fail-fast in rust/cubestore -- lib, cluster, in-process, migration and
multi-process suites green.

Counter-examples that pass because of this branch (the subprocess with a skewed env now
obeys the router):

CUBESTORE_GROUP_BY_LIMIT_FACTOR=0 CUBESTORE_TOPK_STRATEGY=streaming \
  cargo test -p cubestore-sql-tests --test cluster -- limit_pushdown topk

and the reverse skew (group_by_limit_factor: 0 in Config::test, run with
CUBESTORE_GROUP_BY_LIMIT_FACTOR=2): limit_pushdown_group_nonprefix_order passes, where
without the branch it returned 100 instead of 1110.

A select worker plans its own half of a split plan from the logical plan it
receives, and it read `group_by_limit_factor` and `topk_strategy` from its own
configuration. When the values at the two ends of a hop disagree, the halves do
not fit together and the query returns silently wrong rows instead of failing.

Both flags now travel in `WorkerPlanningParams` as `PlanningFlags`, stamped by
the router into `ClusterSendExec` and reused by the worker for both hops. The
field is optional: a sender that omits it predates the flags and planned from
its own configuration, so the receiver falls back to its own configuration
rather than to a hardcoded default.
@waralexrom
waralexrom requested a review from a team as a code owner August 24, 2026 09:51
@github-actions github-actions Bot added cube store Issues relating to Cube Store rust Pull requests that update Rust code labels Aug 24, 2026
@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 4m 21s —— View job


Review: sound fix, no correctness blockers — 0 high, 1 medium, 2 low (all posted inline)

Full review
  • Gather context (diff, serialization paths, both hops)
  • Verify the flag-bundle boundary (which flags are in, which are deliberately out)
  • Check wire compatibility story against the actual transport
  • Check all ClusterSendExec / WorkerExec construction sites propagate the new field
  • Review tests
  • Post findings

Verdict

The diagnosis and the fix both hold up. I independently checked the two claims the PR rests on and they're not just empirical — they're true by construction, which is a stronger statement than the PR makes for itself:

group_by_limit_per_partition really is safe to leave out. In resort_worker_subtree (distributed_partial_aggregate.rs:437-485) the flag only chooses whether a CoalescePartitionsExec is inserted under the partial aggregate. Both branches fall through to the same Some((CoalescePartitionsExec::new(trimmed), true)) — same schema, same partition count, same is_hash, so the router node built from it is identical either way. Worth saying that in the PR description; "it only changes the layout inside the worker" undersells it.

coalesce_under_hash_aggregate and push_partial_aggregate_below_merge are likewise safe by construction, not just "harmless in the suite." push_sorted_partial_aggregate_below_merge (:213-261) rewrites SPM ← PartialAgg into SPM ← PartialAgg over the pre-merge input — the output schema and the single output partition are unchanged. drop_sort_merge_under_global_aggregate swaps an SPM for a CoalescePartitionsExec strictly below the aggregate, likewise 1-partition and schema-preserving. Neither can reach the boundary the router observes. Since the PR justifies their exclusion only by measurement, and measurement can't distinguish "safe" from "not covered by these tests," the structural argument is the one worth writing down.

The compatibility machinery is justified. I checked whether NETWORK_MESSAGE_VERSION already rejects mixed-version clusters, which would have made Option<PlanningFlags> + #[serde(default)] dead weight. It's been 1 since forever (cluster/message.rs:73) and isn't bumped per release, so mixed versions are a real supported state and serde-level compat is the right layer. Falling back to the receiver's own config rather than a hardcoded default is also the correct choice, and the reasoning in the comment at query_executor.rs:582-586 is exactly right.

Propagation is complete: every ClusterSendExec construction site (planning.rs:2103 via ::new, plus the two struct literals at query_executor.rs:1843 and :1915) carries the field, and worker_planning_params() is the only producer of the wire struct besides no_worker().

WorkerExec::new taking worker_partition_count: usize instead of the whole struct is a good call — it was the only field ever read, and it keeps six call sites from churning as the struct grows.

Release ordering as stated is correct and load-bearing: a new router paired with an old worker is exactly the case no fallback can close, so this has to precede #11600.

Findings

# Sev Where What
1 Medium cluster/mod.rs:2408 planning_flags_fall_back_to_the_receiver_configuration doesn't test the fallback — only from_config. And nothing pins worker_planning_params() emitting Some(flags), the one line the whole branch turns on.
2 Low config/mod.rs:1323 TopKAggregateStrategy wire names are now the Rust identifiers, implicitly; a future variant rename is a silent break in a mixed cluster. Also: the "fail loudly" failure is loud in the worker's log only — the router sees a closed socket (cluster/mod.rs:1458-1463).
3 Low cluster/mod.rs:238 PlanningFlags could derive PartialEq, Eq (free — TopKAggregateStrategy already does).

Finding 1 is the only one I'd want addressed before merge, and it's a test gap rather than a defect in the shipped behavior.

Not checked

I did not compile or run the suite — cubestore's build is long and CI covers it. The review is by reading; the PR reports lib/cluster/in-process/migration/multi-process suites green plus the two skew counter-examples, and the flexbuffers behavior the compat story depends on is pinned by the three new wire tests.

Security, performance, and docs: nothing to raise. No new inputs cross a trust boundary (the flags travel on the existing intra-cluster channel, which already carries the whole serialized plan), the change is plan-construction only with no runtime cost, and neither env var is documented in docs-mintlify, so there's nothing to keep in sync.

· branch `cubestore-transmit-planning-flags`

Comment thread rust/cubestore/cubestore/src/cluster/mod.rs
Comment thread rust/cubestore/cubestore/src/config/mod.rs Outdated
Comment thread rust/cubestore/cubestore/src/cluster/mod.rs Outdated
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 59.34%. Comparing base (113d611) to head (8d47c6d).
⚠️ Report is 16 commits behind head on master.

❗ There is a different number of reports uploaded between BASE (113d611) and HEAD (8d47c6d). Click for more details.

HEAD has 1 upload less than BASE
Flag BASE (113d611) HEAD (8d47c6d)
cubesql 1 0
Additional details and impacted files
@@             Coverage Diff             @@
##           master   #11628       +/-   ##
===========================================
- Coverage   84.06%   59.34%   -24.72%     
===========================================
  Files         257      228       -29     
  Lines       81582    18222    -63360     
  Branches        0     3675     +3675     
===========================================
- Hits        68581    10814    -57767     
+ Misses      13001     6861     -6140     
- Partials        0      547      +547     
Flag Coverage Δ
cube-backend 59.34% <ø> (?)
cubesql ?

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.

Cover the line the whole mechanism turns on: `worker_planning_params()`
emitting `Some(flags)`. A regression to `None` would have been papered over
by the receiver's configuration fallback, with the silently-wrong-rows
failure mode this is meant to close.

Pin the strategy names on the wire with explicit `serde(rename)` (the same
names `CUBESTORE_TOPK_STRATEGY` accepts), so renaming a variant cannot break
a mixed-version cluster, and state the deployment constraint the fallback
cannot cover: a value set on the router alone is not reproducible on a
receiver that gets no flags.
@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 1m 46s —— View job


Re-review of 7beafc8: all three prior findings addressed — 0 new blockers, 1 cosmetic nit

Full review
  • Diff the new commit against the previously reviewed head
  • Check each of the three prior findings against the new code
  • Look for new issues introduced by the follow-up commit
  • Resolve stale threads and post

Prior findings

# Prior finding Status
1 planning_flags_fall_back_to_the_receiver_configuration tested only from_config; nothing pinned worker_planning_params() emitting Some(flags) Addressed
2 TopKAggregateStrategy wire names were the Rust identifiers, implicitly Addressed
3 PlanningFlags could derive PartialEq, Eq Addressed

1. The test is renamed to planning_flags_from_config with a doc comment saying what it actually covers, and cluster_send_exec_sends_its_planning_flags (query_executor.rs:2556) now covers the load-bearing line: it builds a ClusterSendExec with topk_strategy: FullMerge / group_by_limit_factor: 3 and asserts params.flags == Some(flags). A regression of that line to None now fails a test instead of being papered over by the receiver's config fallback. Using an exhaustive struct literal rather than a constructor is the right choice here — a future field that forgets to travel makes this test stop compiling.

I checked the pieces the new test leans on rather than assuming: PreSerializedPlan::try_new is the 3-arg (LogicalPlan, PlanningMeta, Option<String>) form (serialized_plan.rs:876), the PlanningMeta literal covers all three fields (planning.rs:93-105), MockCluster is generated unconditionally by #[automock] (cluster/mod.rs:87) so it's available here, and the struct literal matches all ten ClusterSendExec fields (query_executor.rs:1473-1492).

2. #[serde(rename = "streaming" | "vectorized_streaming" | "full_merge")], and planning_flags_strategy_wire_names pins each one by deserializing from a hand-built struct with the strategy as a &str — so a rename that changes the wire name fails the test. The three names are all accepted by env_topk_strategy (config/mod.rs:1353-1357), so the doc claim that these are "the same names CUBESTORE_TOPK_STRATEGY accepts" holds (env parsing is lenient and also takes vectorized, v2, full-merge, …; the serde names are a subset, which is the right direction).

3. Derived, and the round-trip test now asserts params.flags == Some(flags) in one line.

Two doc changes beyond what was asked, both good: group_by_limit_per_partition now says at its definition why it stays out of PlanningFlags, and the fallback comment in worker_context (query_executor.rs:582-586) now names the one deployment it cannot reproduce — a value set on the router alone — instead of implying the fallback is always exact. That was the sharper version of the claim; the earlier wording overstated it slightly.

New issues

Nothing substantive. One cosmetic nit: config/mod.rs:1330 runs to ~150 chars where the rest of that doc block wraps at 100 —

/// the query rather than fall back to a value the sender did not plan with. The receiving node fails to deserialize the message and drops the connection, so

rustfmt doesn't reflow doc comments, so CI won't catch it; a manual rewrap is all it needs.

Not checked

cargo check --tests -p cubestore isn't in this job's allowlist, so the new test code is reviewed by reading, not compiled — CI covers it. Everything the two new tests reference is verified against the current source (signatures, field lists, MockCluster availability), so I'd expect it to build, but that's inference rather than a green compile.

· branch `cubestore-transmit-planning-flags`

Keep the three facts that matter -- both halves must be planned from one
value, the wire names are the env names, an unknown strategy fails the
deserialize -- and drop the restatements.
@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 1m 17s —— View job


Re-review of 8d47c6d: docs-only, prior nit fixed — 0 blockers, 1 cosmetic nit

Full review
  • Diff the new commit against the previously reviewed head
  • Check the prior cosmetic nit (over-long doc line at config/mod.rs:1330)
  • Look for new issues
  • List/resolve stale threads, post

What changed

8d47c6d touches config/mod.rs only — 9 insertions, 18 deletions, all doc comments. No code, no tests, no behavior. The two rewrapped blocks are the group_by_limit_factor accessor doc (:586-591) and the TopKAggregateStrategy type doc (:1313-1320).

The prior nit is fixed: the ~150-char line is gone and nothing in config/mod.rs, cluster/mod.rs or query_executor.rs now exceeds 100 columns.

Do the shortened docs still carry the claims?

Yes — I checked each compressed sentence against the code rather than against the previous wording:

  • "Both halves of a split plan must be planned from the same value, or the router combines a worker stream whose ordering it does not have" — the surviving one-sentence form of the failure mode, and still the correct one.
  • "a worker uses its own value only when the sender sent no flags" — matches the unwrap_or_else(|| PlanningFlags::from_config(...)) in worker_context (query_executor.rs:582-586).
  • "The wire names are the env names and must survive a variant rename"streaming / vectorized_streaming / full_merge are all accepted by env_topk_strategy (:1343-1348), so the claim holds. Env parsing is a superset (also default, v1, vectorized, v2, full-merge, fullmerge), which is the harmless direction — the serde names being a subset means every wire name is a valid env value, not the reverse.
  • "an unknown strategy fails the message deserialize, which the receiver logs and the sender sees as a dropped connection" — matches cluster/mod.rs:1458-1463.

What was dropped is the explicit "setting CUBESTORE_TOPK_STRATEGY on a select worker alone has no effect" line. That's the one operationally surprising consequence and the terse version only implies it, but it is implied clearly enough by the sentence that replaced it — not worth a round trip.

New issues

One cosmetic nit, posted inline: the three [PlanningFlags] intra-doc links (:590, :600, :1318) don't resolve, because PlanningFlags lives in crate::cluster and isn't among the cluster items imported at :14. cargo doc will warn; the prose reads fine regardless. Pre-existing at two of the three sites, surfaced now because this commit rewrites the third.

Prior threads

All three earlier threads are already resolved — nothing to re-affirm or clean up.

Not checked

Docs-only diff, so nothing to run. cargo doc isn't in this job's allowlist, so the broken-link nit is by reading the import list, not from a rustdoc run.

· branch `cubestore-transmit-planning-flags`

///
/// Both halves of a split plan must be planned from the same value, or the router combines a worker
/// stream whose ordering it does not have and returns wrong rows instead of failing. So it rides in
/// [`PlanningFlags`], and a worker uses its own value only when the sender sent no flags.

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.

Nit (cosmetic, applies to all three sites — this line plus :590 and :600): PlanningFlags lives in crate::cluster and isn't imported into config/mod.rs (only Cluster, ClusterImpl, ClusterMetaStoreClient are, at :14), so [PlanningFlags] doesn't resolve and cargo doc reports broken_intra_doc_links. The reference reads fine to a human either way; [crate::cluster::PlanningFlags] would also make it clickable.

Otherwise the rewrap is good — the ~150-char line from the previous head is gone, and the shortened text keeps the load-bearing claims (both halves must plan from the same value; fallback only when no flags were sent; no catch-all variant).

@waralexrom
waralexrom merged commit d48a64e into master Aug 24, 2026
106 of 108 checks passed
@waralexrom
waralexrom deleted the cubestore-transmit-planning-flags branch August 24, 2026 13:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cube store Issues relating to Cube Store rust Pull requests that update Rust code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants