feat(core): scaffold PrefixMergeExec for cross-partition windowed-aggregate state merge - #2211
Draft
avantgardnerio wants to merge 7 commits into
Draft
feat(core): scaffold PrefixMergeExec for cross-partition windowed-aggregate state merge#2211avantgardnerio wants to merge 7 commits into
avantgardnerio wants to merge 7 commits into
Conversation
…regate state merge Introduces PrefixMergeExec as the downstream half of the AQE range-shuffle prefix-scan pipeline: takes per-input-partition finalized window-aggregate state (from BoundedWindowAggExec::finalized_partition_state in apache/datafusion#24007) and, in follow-up work, merges it row-wise into the current partition's output so cross-partition running aggregates come out correct. This commit is scaffolding only. execute() forwards batches unchanged; the merge itself lands in a follow-up. The type signature and constructor are committed now so the surrounding plumbing (scheduler collection of per-task state via task-status transport, plan-rule placement, per-task state injection) can be built against a stable shape. FinalizedPartitionState is defined locally as a stand-in for datafusion::physical_plan::windows::FinalizedPartitionState until apache/datafusion#24007 lands; the doc comment marks the correspondence so the swap is unambiguous when the DF change is available. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The prefix-merge is inherently a scheduler-side computation (each partition's offset needs global visibility across tasks), so this operator receives *already-merged* state rather than raw per-partition states waiting to be combined. Documents that split explicitly, and notes that for scalar aggregates a plain ProjectionExec with the offset as a literal is equivalent — this operator earns its keep as the aggregate-agnostic escape hatch for cases where the apply isn't a per-row scalar expression. No functional change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lumn apply Extend PrefixMergeExec's constructor with a Vec<WindowApply> — one entry per output column that needs cross-partition correction, telling the operator *how* to rewrite that column. Two shapes: - Scalar: monoidal op (Add/Min/Max/Overwrite) between the row's existing value and a scheduler-provided per-partition scalar offset. Fits SUM, COUNT, MIN, MAX, row_number, first_value, last_value. - Aggregate: construct a fresh Accumulator seeded from the pre-merged state, feed args per row, overwrite the column with evaluate(). Fits AVG (without decomposition), sketch-backed windows (APPROX_DISTINCT, APPROX_QUANTILE), statistical aggregates (STDDEV/VAR/covar), and concat aggregates (ARRAY_AGG/STRING_AGG). Non-corrected functions (lead/lag/nth_value via halos in the shuffle layer; rank family via future segment-tree infrastructure) don't appear in the applies list and aren't PrefixMergeExec's concern. The scaffold still forwards batches unchanged — the row-wise fold lands in a follow-up. Two new tests cover Scalar-offset-length and output_column-range validation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eeded merge execute() now actually applies WindowApply::Aggregate entries: for each partition it builds a fresh Accumulator via AggregateExprBuilder, seeds it via merge_batch with the pre-merged offset state from per_partition_state, then per row evaluates the aggregate's args, feeds them to update_batch, and overwrites output_column with evaluate(). Assumes at most one PARTITION BY key per input partition — the AQE synthetic-PARTITION-BY case. If a state map carries more than one key, execute() errors rather than silently applying only one; multi-key support can grow when a workload needs it. The WindowApply::Scalar path is still a passthrough for now, flagged in the doc so it doesn't come as a surprise; batch-arithmetic apply via arrow kernels lands in a follow-up. New test approx_distinct_corrects_running_distinct_across_partitions runs the sketch case end-to-end: two mock BWAG outputs with local running distinct counts, partition 1's offset seeded from partition 0's terminal HLL state via approx_distinct_udaf's own Accumulator::state. Verifies partition 1's corrected running distinct matches what a single BWAG over the concatenated input would have produced (4, 5, 6 instead of the local 1, 2, 3). This is the "worst case" — an aggregate whose state can't be recovered from a running scalar output, so per-row scalar correction via ProjectionExec is provably insufficient and only the seeded-accumulator re-run gets the right answer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the APPROX_DISTINCT test on the aggregate whose state has been the reference point for "why can't we just add offsets": AVG stores (sum, count) but emits sum/count per row, so recovering (sum, count) from a running mean is impossible. A ProjectionExec that added a scalar offset to running_avg would give the wrong answer at every row. The seeded-Accumulator re-run handles it uniformly — same code path as APPROX_DISTINCT, no decomposition needed. Partition 1's mock local running means `[40.0, 45.0, 50.0]` become the correct global running means `[25.0, 30.0, 35.0]` after seeding with partition 0's terminal AVG state (sum=60, count=3). Uses Float64 throughout because DataFusion's AvgAccumulator is only implemented for Float64/decimal/duration inputs; Int64 would rely on a planner-inserted cast that isn't needed to demonstrate the mechanism. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… SUM demo Wires the Scalar fast path through arrow kernels: - ScalarOp::Add: arrow::compute::kernels::numeric::add - ScalarOp::Min: cmp::lt_eq + zip::zip (element-wise min) - ScalarOp::Max: cmp::gt_eq + zip::zip (element-wise max) - ScalarOp::Overwrite: constant-fill from the offset The stream now dispatches through a PreparedApply enum wrapping either a ScalarApply (batch-arithmetic, no Accumulator) or an AggregateApply (seeded Accumulator, per-row replay). One code path holds them both, each applied per batch in order. Replaces the scaffold-passthrough test with sum_corrects_running_sum_ across_partitions — the canonical fast-path demo. Partition 1's mock local running sums [4, 9, 15] become the correct global [10, 15, 21] after adding the scheduler-provided offset 6. Rounds out the trio: SUM via Scalar::Add, AVG via Aggregate re-run, APPROX_DISTINCT via Aggregate re-run. Same descriptor list; the operator picks the shape each entry needs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…plification Follows apache/datafusion#24007's shape simplification: the DF getter now returns Vec<Option<Vec<ScalarValue>>> directly rather than HashMap<PartitionKey, ...>, since the cross-DF-partition prefix-scan use case is by construction scoped to at most one PARTITION BY group per DF partition. The DF side handles the scoping (Empty/Single/Multi slot internally, error on multi). Ballista's local FinalizedPartitionState alias matches — dropping the outer HashMap layer means execute() no longer has to project it away or error on multi-key (both handled upstream), and the demo tests construct state directly as Vec<Option<Vec<ScalarValue>>> rather than via a HashMap keyed by an empty PartitionKey. No semantic change; the aggregate re-run tests still cover APPROX_DISTINCT and AVG, the scalar test still covers SUM, all 6 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Introduces
PrefixMergeExec— the downstream half of the AQE range-shuffle prefix-scan pipeline. Range-shuffle produces N ordered disjoint partitions; each executor task's local windowed aggregate is correct within its slice but not across slices. This operator corrects each partition by applying the scheduler-provided already-prefix-merged state from every earlier partition, aggregate-agnostic by construction.Division of labor (fixed at design time):
Accumulator::state()via task-status transport, computes the prefix-merge across all tasks. Requires cross-task visibility.What's here
WindowApplyenum describes how to correct each output column. Two shapes:Scalar { op, offset, output_column }— fast path via arrow kernels. NoAccumulatorreconstructed. Covers aggregates whose per-row output is a valid partial state:ScalarOp::Add— SUM, COUNT, row_number (viaarrow::compute::kernels::numeric::add)ScalarOp::Min/Max— MIN / MAX (viacmp::lt_eq/gt_eq+zip::zip)ScalarOp::Overwrite— first_value / last_value (constant-fill from the scheduler-picked global scalar)Aggregate { udf, args, output_column, window_expr_index }— seeded-Accumulator re-run. Constructs a freshAccumulatorviaAggregateExprBuilder, seeds it viamerge_batchwith the pre-merged offset state, then replays each row throughupdate_batch+evaluateto overwriteoutput_column. Covers aggregates whose per-row output isn't a valid partial state:(sum, count)state can't be recovered from a mean scalar)The operator picks the shape per-column from what the caller supplies — one uniform code path.
Explicitly out of scope (documented in the module header):
lead/lag/nth_value— solved by halo rows in the shuffle layer, not per-row correction.rank/dense_rank/percent_rank/cume_dist/ntile— need a segment-tree-plus-broadcast design; separate infrastructure.Assumes at most one PARTITION BY key per input partition (matches the AQE synthetic-PARTITION-BY pattern). Multi-key handling can grow when a workload needs it; today it errors explicitly rather than silently applying one key.
Relation to DataFusion
The upstream tasks' finalized state — which the scheduler prefix-merges before handing to this operator — is produced by
BoundedWindowAggExec::finalized_partition_state, added in apache/datafusion#24007. A localFinalizedPartitionStatetype alias in this crate mirrors the type from that PR so this crate compiles against stable DataFusion 54 until the DF change lands.Test plan
Six tests. Three exercise construction-time validation; three are end-to-end demos, one per aggregate class, showing the operator producing globally-correct output from mock BWAG output that is only locally correct.
try_new_rejects_state_length_mismatch—per_partition_state.len()must match input partition count.try_new_rejects_scalar_offset_length_mismatch—Scalarvariant'soffsetlength must match input partition count.try_new_rejects_output_column_out_of_range—output_columnpast the schema errors at construction.sum_corrects_running_sum_across_partitions— SUM viaScalar::Add. Local[4, 9, 15]corrected to global[10, 15, 21].avg_corrects_running_mean_across_partitions— AVG viaAggregate(no decomposition). Local[40.0, 45.0, 50.0]corrected to global[25.0, 30.0, 35.0]after seeding with(sum=60, count=3).approx_distinct_corrects_running_distinct_across_partitions— APPROX_DISTINCT viaAggregate. Local[1, 2, 3]corrected to global[4, 5, 6]after seeding with the HLL state of{1, 2, 3}. This is the case that provably can't be handled by a per-row scalarProjectionExec— recovering HLL registers from a running count is not tractable.All test setup mocks the upstream BWAG output so nothing here depends on
apache/datafusion#24007landing first. Once that PR merges, the localFinalizedPartitionStatealias can be swapped for the DataFusion type directly.What's not yet here (follow-ups)
collect_runtime_stats_reports) that gathers per-taskfinalized_partition_stateand computes the prefix-merge.PrefixMergeExecwith per-task offsets baked in.BoundedWindowAggExecwhere the upstream is sorted).🤖 Generated with Claude Code