Skip to content

fix(cubestore): inline aggregate dropped rows past the first partition - #11631

Merged
waralexrom merged 3 commits into
masterfrom
cubestore-single-value-equals-returns-no-rows
Aug 25, 2026
Merged

fix(cubestore): inline aggregate dropped rows past the first partition#11631
waralexrom merged 3 commits into
masterfrom
cubestore-single-value-equals-returns-no-rows

Conversation

@waralexrom

Copy link
Copy Markdown
Member

Summary

A grouped query over a rollup could silently return no rows — or an undercount — when every group-by dimension was pinned to a single value by a filter. InlineAggregateExec carried its PlanProperties over to a new child instead of recomputing them, so it kept reporting one output partition while its input had several; the parent then executed only partition 0 and dropped every row living in the rest, with no error anywhere. Fixes CORE-778 (reported by Maersk on assetmgmt-prod).

Changes

  • InlineAggregateExec::with_new_children delegates the input swap to the AggregateExec the node was converted from, then re-converts. DataFusion recomputes the properties there and preserves the output schema, which it derives from aggregate expression names its own rules may rewrite (try_new would regenerate them and can trip schema_check). If the new input is no longer sorted on the group keys, the plain hash aggregate is kept — without the limit, which means "stop after N complete groups" on the streaming path but "cap on distinct groups" for a hash aggregate.
  • GroupByLimitAggregateExec refreshed only the output partitioning and kept orderings and constants projected from the previous input; it now uses the same delegation.
  • RollingWindowAggExec and AggregateTopKExec also carry their properties over, but build them from their own output schema plus constants and read the input's partition count live, so nothing can go stale. Both sites now say so, since the same pattern loses rows in a node whose partitioning follows the input.

How it fired

All of these had to hold at once, which is why it looked intermittent:

  • every group-by column pinned to a single value by the filter — that is what makes the aggregate Sorted and hands it to the streaming implementation. With IN (a, b) there is no constant, the plan stays PartiallySorted* and is correct, which is the whole = vs IN asymmetry in the report;
  • the leading sort key column outside the filter set, so partition pruning cannot collapse the scan — a rollup partitioned by a time dimension the query does not filter is exactly that shape;
  • the matching rows outside the first scanned partition. Rebuilding a pre-aggregation moves the split points, so the same query can start or stop reproducing.

Sequence inside a single physical-optimizer run, no router/worker boundary involved: ensure_partition_merge puts a CoalescePartitionsExec under the aggregate, the node is converted with a cached partition count of 1, and DataFusion's EnforceSorting::remove_bottleneck_in_subplan then strips that coalesce and reattaches the multi-partition subtree.

Present since the DataFusion 46 upgrade (4ef3442827), which introduced the node.

Testing

  • sql::tests::single_value_equals_scans_every_partition — end-to-end regression test on the reported shape: a rollup-like table whose sort key leads with a time dimension, several partitions, both group columns pinned. Fails on the old with_new_children (returns no rows), passes with the fix. It probes the last month of the range on purpose: a probe near the start of the sort key can land in partition 0 and go green with the bug present.
  • The existing group_by_limit_aggregate::tests::output_partitioning_follows_rechilded_input covers the same class for the second node and still passes.
  • partition_filter::tests::no_false_negatives_{two,three}_columns — exhaustive check that MinMaxCondition never prunes a range holding a matching row. Partition pruning was the first suspect and is clean; this pins that.
  • cargo test -p cubestore --lib green (317 tests), several consecutive runs.

waralexrom and others added 2 commits August 24, 2026 20:19
InlineAggregateExec carried its PlanProperties over to a new child instead
of recomputing them. The node is converted from an AggregateExec while a
CoalescePartitionsExec sits under it, so the cached output partitioning is
1; EnforceSorting then strips that coalesce as an avoidable bottleneck and
reattaches the multi-partition subtree. The stale count stays 1, the parent
executes only partition 0, and every row in the remaining partitions is
dropped with no error.

Delegate the input swap to the AggregateExec the node was built from, then
re-convert. DataFusion recomputes the properties there and preserves the
output schema, which it derives from aggregate expression names its own
rules may rewrite. When the new input is no longer sorted on the group keys
the plain hash aggregate is kept, without the limit: on the streaming path
it counts complete groups off a sorted input, while a hash aggregate reads
it as a cap on distinct groups and would truncate arbitrary ones.

Reachable only when every group-by column is pinned to a single value by
the filter (which is what makes the aggregate sorted), the leading sort key
column is outside the filter set so partition pruning cannot collapse the
scan, and the matching rows live outside the first partition. A rollup
partitioned by a time dimension that the query does not filter is exactly
that shape.

Also adds an exhaustive check that MinMaxCondition never prunes a range
holding a matching row -- partition pruning was the first suspect and is
clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the new child

GroupByLimitAggregateExec refreshed only the output partitioning when its
input was swapped, leaving the rest of the plan properties -- orderings and
constants projected from the previous input -- behind. Delegate the swap to
the aggregate it was built from, the same way the inline aggregate does, so
DataFusion recomputes all of them and preserves the output schema. If the
rebuilt aggregate no longer fits the trimming path it is returned as is.

The other two nodes that carry their properties over, RollingWindowAggExec
and AggregateTopKExec, build them from their own output schema plus
constants and read the input's partition count live, so nothing there can
go stale; say so at both sites, since the same pattern loses rows in a node
whose partitioning follows the input.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@waralexrom
waralexrom requested a review from a team as a code owner August 24, 2026 18:31
@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 5m 47s —— View job


Review: fix looks correct — 0 blocking, 2 medium, 4 low. 5 inline comments posted.

Full review
  • Gather context / fetch diff
  • Review inline_aggregate change
  • Review group_by_limit_aggregate change
  • Review rolling.rs / topk/execute.rs comments
  • Review partition_filter + sql tests
  • Post review

Verdict

The diagnosis and the fix are sound. Delegating the input swap to the wrapped AggregateExec and re-converting is the right shape: it is the only place that recomputes input_order_mode, required_input_ordering and the output partitioning together, and going through with_new_children rather than try_new is what preserves the aggregate-expression-derived output schema (try_new regenerates the names and can trip schema_check). The PR body's account of the trigger — ensure_partition_merge inserting a coalesce, conversion caching a count of 1, then EnforceSorting::remove_bottleneck_in_subplan stripping the coalesce — matches what's in optimizations/mod.rs and distributed_partial_aggregate.rs.

Two things I liked in particular:

  • The rolling.rs and topk/execute.rs comments. Adding a note at the two correct carry-over sites explaining why they're correct is the thing that stops the next person from copying the broken pattern, and it's what makes this a fix for a class rather than an instance.
  • The custom Debug impls skipping source. Easy to miss that a derived Debug would double the printed subtree at every nested aggregate.

A side effect worth naming: contains_multi_partition_partial_aggregate (distributed_partial_aggregate.rs:813) branches on partition_count() > 1 of the inline node. The stale cache was making it report 1, so that guard was silently under-firing too — the fix corrects a second, quieter consumer of the same bad value. That is likely a behavior change in limit placement for some plans, which is desirable, but it's not covered by any test here.

Findings

# Severity Where Issue
1 Medium sql/mod.rs:6890 Regression test asserts rows only, never the plan shape — it stops covering the bug the moment planning changes. Plus a 60s compaction-poll loop.
2 Medium group_by_limit_aggregate/mod.rs:477 output_partitioning_follows_rechilded_input doesn't assert the node type, so the new fallback path would keep it green on a plain AggregateExec. No unit-level test at all for the InlineAggregateExec path this PR actually fixes.
3 Low inline_aggregate/mod.rs:238 De-sort fallback is a warn! for a condition whose consequence is another silent-wrong-answer (parent merge over unsorted streams). Consider erroring; at minimum make the message identify the query.
4 Low group_by_limit_aggregate/mod.rs:217 Sibling fallback drops the top-k trim with no log at all — asymmetric with the inline node, and a real perf cliff under "under merge".
5 Low group_by_limit_aggregate/mod.rs:107 The with_partitioning override and its staleness comment are now dead reasoning; the comment misleads about what with_new_children does.
6 Low partition_filter.rs:1656 no_false_negatives_three_columns is ~10⁸ Vec<i64> comparisons in a debug build. Worth measuring; it also guards a module this PR ruled out rather than fixed.

Things I checked and found clean

  • Retention. source holds a second Arc to the input subtree, but try_new_from_aggregate / try_new_from_partial rebuild source from the rebuilt aggregate, so old subtrees are dropped with the old node — no accumulation across optimizer passes.
  • limit handling. try_new_from_aggregate copies limit off the source (stale, since the limit is installed post-conversion by limit_above_first_aggregate), and the call site correctly overrides with self.limit. Dropping it on the hash fallback is right — AggregateExec reads limit as a cap on distinct groups, not "N complete groups", so carrying it over would truncate arbitrarily. The parent LocalLimitExec/GlobalLimitExec still bounds the result, so only the early exit is lost.
  • Node-type substitution. Returning an AggregateExec from with_new_children is unusual, but schema and child count are preserved, and the three downcast consumers (physical_plan_flags.rs:40, pretty_printers.rs:609, locate_partial_aggregate) all degrade gracefully rather than misbehave.
  • Error path. The downcast-failure Internal error is the right call over unwrap.
  • with_limit invariant. It's the only clone-and-mutate path on these structs and it doesn't touch input, so source.input == self.input holds.
  • Test arithmetic. single_value_equals_scans_every_partition expects 80 = 8 days × 10 hours for QC/PSE/202612. Correct, and probing the last month rather than the first is a deliberate and well-chosen detail.

Not verified

I couldn't read the vendored DataFusion fork in this checkout, so the claim that AggregateExec::with_new_children preserves the output schema and the limit while recomputing input_order_mode rests on the PR description plus upstream DF 46 behavior. The whole fix hinges on it — if the fork diverged there, try_new_from_aggregate would silently see a different input_order_mode than intended. Given the author reports the suite green, that's likely fine, but it's the one assumption I'd want a second pair of eyes on. I also did not build or run the test suite here.

· branch `cubestore-single-value-equals-returns-no-rows`

Comment thread rust/cubestore/cubestore/src/queryplanner/group_by_limit_aggregate/mod.rs Outdated
Comment thread rust/cubestore/cubestore/src/sql/mod.rs
Comment on lines +1650 to +1657
#[test]
fn no_false_negatives_two_columns() {
assert_no_false_negatives(2, 3);
}

#[test]
fn no_false_negatives_three_columns() {
assert_no_false_negatives(3, 3);

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.

Worth measuring the wall-clock of no_false_negatives_three_columns in a debug build before merging. Rough count: column_bounds(3, 3) is 13³ = 2197 bound combinations, rows is 27 so ~365 ordered (mn, mx) pairs survive the skip, and each pair runs three .any() scans over matching (up to 125 Vec<i64> elements). That's on the order of 10⁸ heap-allocated-vector comparisons — plausibly tens of seconds unoptimized, which is a lot for one --lib test given the module it guards isn't the one the PR fixes.

If it does turn out slow, assert_no_false_negatives(3, 2) cuts it by roughly an order of magnitude while still covering the multi-column carry logic, or the 3-column case could be #[ignore]d as an on-demand exhaustive check with the 2-column one running by default.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measured before merging, debug build:

test queryplanner::partition_filter::tests::no_false_negatives_two_columns ... ok
test queryplanner::partition_filter::tests::no_false_negatives_three_columns ... ok
test result: ok. 2 passed; finished in 1.15s

The 10^8 estimate overcounts: matching is filtered once per bound combination, not per (mn, mx) pair, most combinations leave only a handful of candidates, and each .any() short-circuits. Keeping the 3-column case as is rather than shrinking it to (3, 2) or marking it #[ignore].

Comment thread rust/cubestore/cubestore/src/queryplanner/inline_aggregate/mod.rs
Review follow-ups on the inline aggregate fix.

The end-to-end regression test asserted only on rows, so a planner change
that moved the query off the streaming aggregate, or collapsed the scan to
one partition, would have kept it green while covering nothing. Assert the
plan shape too, and add the unit-level counterpart the sibling node already
has: build the exec over a one-partition input, re-child it onto three, and
require both the reported partitioning and the node type to follow. It runs
in milliseconds and does not depend on compaction timing.

Both fallbacks -- to the hash aggregate here, out of the trimming path in
GroupByLimitAggregateExec -- now name the group by and the new input's
order mode, so a degradation is diagnosable from the log rather than
showing up only as wrong-looking timings. The group-by-limit test also
pins that re-childing keeps the trimming exec, which the new fallback
would otherwise let it pass without.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Claude finished @waralexrom's task in 2m 22s —— View job


Re-review after push: all but one prior finding addressed — 0 blocking, 1 nit. 4 threads resolved.

Full review
  • Diff the updated branch against origin/master
  • Check which prior review threads are addressed
  • Review the new code (warn branches, unit test, plan-shape assertion)
  • Resolve stale threads / post remaining findings

What changed since the last review

Prior finding Status
#1 sql regression test didn't pin the plan shape Fixedplan_query + pp_phys_plan now assert the worker plan contains InlinePartialAggregate. Verified that is the exact string pretty_printers.rs:614 emits for a Partial inline aggregate, so the assertion is live, not vacuous. Wait-loop kept, now with a comment that the 1200 iterations are a ceiling.
#2 output_partitioning_follows_rechilded_input didn't assert node type / no unit test for the inline node Fixedassert!(exec3.as_any().is::<GroupByLimitAggregateExec>()) added, and a new inline_aggregate::tests::output_partitioning_follows_rechilded_input builds over 1 partition, re-childs onto 3, and asserts both the node type and partition_count() == 3. That is the unit-level counterpart the fix was missing.
#3 inline de-sort fallback warning not actionable Fixed — the message now carries the group-by expression names, the new input_order_mode, and the new input's output_ordering. Kept as a warn! rather than an Internal error, which is a defensible call: the fallback is correct, just slower.
#4 group-by-limit fallback dropped the trim silently Fixed — symmetric warn! added naming the perf cliff and the group-by columns.
#5 stale with_partitioning comment Fixed — trimmed to "aggregate.cache() already says that; spell it out anyway", which no longer implies a cache-copying with_new_children.
#6 no_false_negatives_three_columns runtime Not addressed — thread left unresolved. Still worth a timing check in a debug build before merge.

New review of the added code

Checked and clean:

  • source staleness. source is set only in the two try_new_from_* constructors, always from the aggregate the node is derived from, and nothing in the codebase mutates an inline node's mode after construction (distributed_partial_aggregate.rs only downcasts and reads it), so source.mode() == self.mode holds. with_limit is the one clone-and-mutate path and it touches only limit, which the call site re-applies explicitly. No way for source to describe a different plan than the node.
  • Custom Debug impls. Skipping source is right — it holds a second Arc to the same input subtree, and a derived impl would double the printed tree per nested aggregate. finish_non_exhaustive() makes the omission visible.
  • Warn message construction. Both build the group-by list from group_by.expr() names, so the log identifies the query without dumping physical exprs. Neither is on a hot path (physical-optimizer, once per re-child).
  • Fallback semantics. Unchanged from the last review and still right: the inline node drops limit on the hash fallback (different meaning), the group-by-limit node returns the untrimmed rebuilt (correctness preserved by the router's own top-k).

One nit posted inline (inline_aggregate/mod.rs:461): the new test was inserted between the pre-existing doc comment and limit_emits_only_closed_groups, so that comment now heads the wrong test.

Not verified

I did not build or run the suite in this checkout (no cargo run here), and I still can't read the vendored DataFusion fork, so the claim that AggregateExec::with_new_children preserves the output schema while recomputing input_order_mode and partitioning rests on the author's green run plus upstream DF 46 behavior. The whole fix hinges on it.

· branch `cubestore-single-value-equals-returns-no-rows`

Comment on lines 461 to +465
/// A group continuing in the next input batch must not be emitted early with a partial sum.
/// A partial aggregate runs once per input partition, so its reported partitioning must
/// follow a re-childed input. A stale single-partition count makes the parent coalesce
/// execute only partition 0 and silently drop the rows of the rest -- the row loss this node
/// was fixed for. Built over a 1-partition input so its cache says 1, then re-childed onto 3.

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 new test was inserted between the pre-existing doc line and the test it documented, so limit_emits_only_closed_groups lost its comment and the new test now carries a first line about a different behavior:

Suggested change
/// A group continuing in the next input batch must not be emitted early with a partial sum.
/// A partial aggregate runs once per input partition, so its reported partitioning must
/// follow a re-childed input. A stale single-partition count makes the parent coalesce
/// execute only partition 0 and silently drop the rows of the rest -- the row loss this node
/// was fixed for. Built over a 1-partition input so its cache says 1, then re-childed onto 3.
/// A partial aggregate runs once per input partition, so its reported partitioning must
/// follow a re-childed input. A stale single-partition count makes the parent coalesce
/// execute only partition 0 and silently drop the rows of the rest -- the row loss this node
/// was fixed for. Built over a 1-partition input so its cache says 1, then re-childed onto 3.

and move /// A group continuing in the next input batch must not be emitted early with a partial sum. back down onto limit_emits_only_closed_groups (line ~496).

@waralexrom
waralexrom merged commit 6f3e66d into master Aug 25, 2026
79 of 81 checks passed
@waralexrom
waralexrom deleted the cubestore-single-value-equals-returns-no-rows branch August 25, 2026 10:03
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