Skip to content

feat(core): scaffold PrefixMergeExec for cross-partition windowed-aggregate state merge - #2211

Draft
avantgardnerio wants to merge 7 commits into
apache:mainfrom
avantgardnerio:brent/prefix-merge-scaffold
Draft

feat(core): scaffold PrefixMergeExec for cross-partition windowed-aggregate state merge#2211
avantgardnerio wants to merge 7 commits into
apache:mainfrom
avantgardnerio:brent/prefix-merge-scaffold

Conversation

@avantgardnerio

@avantgardnerio avantgardnerio commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

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):

  • Scheduler (global step): collects each upstream task's finalized Accumulator::state() via task-status transport, computes the prefix-merge across all tasks. Requires cross-task visibility.
  • Executor (this operator): receives the pre-merged state for its partition and applies it row-wise to correct the window-aggregate columns.

What's here

WindowApply enum describes how to correct each output column. Two shapes:

  • Scalar { op, offset, output_column } — fast path via arrow kernels. No Accumulator reconstructed. Covers aggregates whose per-row output is a valid partial state:
    • ScalarOp::Add — SUM, COUNT, row_number (via arrow::compute::kernels::numeric::add)
    • ScalarOp::Min / Max — MIN / MAX (via cmp::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 fresh Accumulator via AggregateExprBuilder, seeds it via merge_batch with the pre-merged offset state, then replays each row through update_batch + evaluate to overwrite output_column. Covers aggregates whose per-row output isn't a valid partial state:
    • AVG (without decomposition — (sum, count) state can't be recovered from a mean scalar)
    • Sketch-backed windows: APPROX_DISTINCT, APPROX_QUANTILE, etc.
    • Statistical aggregates: STDDEV, VAR, correlation family
    • Concat aggregates: ARRAY_AGG, STRING_AGG

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 local FinalizedPartitionState type 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_mismatchper_partition_state.len() must match input partition count.
  • try_new_rejects_scalar_offset_length_mismatchScalar variant's offset length must match input partition count.
  • try_new_rejects_output_column_out_of_rangeoutput_column past the schema errors at construction.
  • sum_corrects_running_sum_across_partitions — SUM via Scalar::Add. Local [4, 9, 15] corrected to global [10, 15, 21].
  • avg_corrects_running_mean_across_partitions — AVG via Aggregate (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 via Aggregate. 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 scalar ProjectionExec — 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#24007 landing first. Once that PR merges, the local FinalizedPartitionState alias can be swapped for the DataFusion type directly.

What's not yet here (follow-ups)

  • Scheduler-side state collection (analog of collect_runtime_stats_reports) that gathers per-task finalized_partition_state and computes the prefix-merge.
  • Downstream stage generation that injects PrefixMergeExec with per-task offsets baked in.
  • Plan rule that recognizes AQE range-shuffled windowed aggregates and routes them through this operator (with canonicalization to BoundedWindowAggExec where the upstream is sorted).

🤖 Generated with Claude Code

avantgardnerio and others added 6 commits July 30, 2026 10:44
…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>
@avantgardnerio
avantgardnerio requested a review from Dandandan July 30, 2026 18:05
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant