Skip to content

Commit d6a55a5

Browse files
refactor(core): FinalizedPartitionState as a newtype
Vec<Option<Vec<ScalarValue>>> appeared in this operator's public signatures, where it is neither readable nor searchable, and it spelled "no state for this window expression" two ways: a missing index, and a None at a present index. Every caller handled both. slot(window_expr_index) collapses them into one answer, and a later change to the representation now stays internal. Also removes two comments claiming DataFusion guarantees at most one PARTITION BY group per partition. It does not — apache/datafusion#24035 shipped a callback keyed by group, so that invariant is ours, and the scheduler enforces it by rejecting any report carrying a key. A window that does have a PARTITION BY needs nothing from this operator anyway: BoundedWindowAggExec asks for KeyPartitioned input, so each partition's window is already independent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e4e1bb0 commit d6a55a5

3 files changed

Lines changed: 87 additions & 33 deletions

File tree

ballista/core/src/execution_plans/prefix_merge.rs

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,15 @@
6868
//! the *pre-merged* [`Accumulator::state`] for each aggregate window
6969
//! expression (indexed by position in `BoundedWindowAggExec::window_expr()`).
7070
//! Only consumed by [`WindowApply::Aggregate`] entries;
71-
//! [`WindowApply::Scalar`] carries its own offsets inline. The DF-side getter
72-
//! already commits to at-most-one PARTITION BY group per DF partition (see
73-
//! `apache/datafusion#24007`), so no per-key dimension is exposed here. The
74-
//! scheduler bakes the state when it constructs the downstream stage after
75-
//! the upstream stage's tasks complete.
71+
//! [`WindowApply::Scalar`] carries its own offsets inline. No PARTITION BY
72+
//! dimension is exposed: DataFusion publishes state per closed group, but the
73+
//! rewrite that plants this operator only fires on windows without a
74+
//! PARTITION BY, and the scheduler rejects any report carrying a key rather
75+
//! than flattening two groups together. A window that does have a PARTITION
76+
//! BY needs no help from this operator, since `BoundedWindowAggExec` asks for
77+
//! `KeyPartitioned` input and each partition's window is already independent.
78+
//! The scheduler bakes the state when it constructs the downstream stage
79+
//! after the upstream stage's tasks complete.
7680
//!
7781
//! **Status.** Both apply paths are implemented.
7882
//!
@@ -148,12 +152,48 @@ use crate::execution_plans::plan_algebra::{
148152
/// value per window expression — see the `prefix_merge` module docs for the
149153
/// division of labor. This operator applies it; it does not compute it.
150154
///
151-
/// The type mirrors `datafusion::physical_plan::windows::FinalizedPartitionState`
152-
/// from [apache/datafusion#24007]; the alias here is a local stand-in so
153-
/// this crate compiles against stable DataFusion 54 until that PR lands.
154-
///
155-
/// [apache/datafusion#24007]: https://github.com/apache/datafusion/pull/24007
156-
pub type FinalizedPartitionState = Vec<Option<Vec<ScalarValue>>>;
155+
/// A newtype rather than an alias for `Vec<Option<Vec<ScalarValue>>>`. That
156+
/// type appears in this operator's public signatures, where it is neither
157+
/// readable nor searchable, and it spells "no state for this window
158+
/// expression" two ways — a missing index and a `None` — which every caller
159+
/// then has to handle. [`Self::slot`] collapses both into one answer. Any
160+
/// later change to the representation also stays internal rather than
161+
/// breaking whoever wrote the concrete type.
162+
#[derive(Debug, Clone, Default, PartialEq)]
163+
pub struct FinalizedPartitionState {
164+
/// Indexed by position in the upstream operator's `window_expr()` list.
165+
per_window_expr: Vec<Option<Vec<ScalarValue>>>,
166+
}
167+
168+
impl FinalizedPartitionState {
169+
/// Build from one slot per window expression, in `window_expr()` order.
170+
pub fn new(per_window_expr: Vec<Option<Vec<ScalarValue>>>) -> Self {
171+
Self { per_window_expr }
172+
}
173+
174+
/// Merged state for `window_expr_index`, or `None` when that expression
175+
/// published none or the index is past the end.
176+
pub fn slot(&self, window_expr_index: usize) -> Option<&Vec<ScalarValue>> {
177+
self.per_window_expr
178+
.get(window_expr_index)
179+
.and_then(|slot| slot.as_ref())
180+
}
181+
182+
/// Every slot in window-expression order, for wire encoding.
183+
pub fn slots(&self) -> &[Option<Vec<ScalarValue>>] {
184+
&self.per_window_expr
185+
}
186+
187+
/// Number of window expressions this state covers.
188+
pub fn len(&self) -> usize {
189+
self.per_window_expr.len()
190+
}
191+
192+
/// True when no window expression published state.
193+
pub fn is_empty(&self) -> bool {
194+
self.per_window_expr.is_empty()
195+
}
196+
}
157197

158198
/// How to combine each row's existing value in an output column with a
159199
/// scheduler-provided scalar offset. The result overwrites the column.
@@ -624,10 +664,10 @@ impl ExecutionPlan for PrefixMergeExec {
624664
let input_schema = self.input.schema();
625665
let output_schema = self.schema();
626666

627-
// The DF-side getter (BoundedWindowAggExec::finalized_partition_state)
628-
// already commits to at-most-one PARTITION BY group per DF partition,
629-
// so this operator receives the state indexed only by window
630-
// expression — no PARTITION BY dimension to project away.
667+
// Indexed by window expression only. The scheduler rejects any
668+
// report carrying a PARTITION BY key, so at most one group per
669+
// partition reaches here and there is no key dimension to project
670+
// away.
631671
let key_state = &resolved[partition];
632672

633673
let mut appliers: Vec<PreparedApply> = Vec::with_capacity(self.applies.len());
@@ -639,9 +679,7 @@ impl ExecutionPlan for PrefixMergeExec {
639679
output_column,
640680
window_expr_index,
641681
} => {
642-
let offset_state: Option<&Vec<ScalarValue>> = key_state
643-
.get(*window_expr_index)
644-
.and_then(|slot| slot.as_ref());
682+
let offset_state = key_state.slot(*window_expr_index);
645683
appliers.push(PreparedApply::Aggregate(AggregateApply::new(
646684
i,
647685
udf,
@@ -1042,16 +1080,22 @@ mod tests {
10421080
}
10431081

10441082
fn empty_state(partitions: usize) -> Vec<FinalizedPartitionState> {
1045-
(0..partitions).map(|_| Vec::new()).collect()
1083+
(0..partitions)
1084+
.map(|_| FinalizedPartitionState::default())
1085+
.collect()
10461086
}
10471087

10481088
/// Mismatched state length surfaces as an error rather than a panic at
10491089
/// runtime.
10501090
#[test]
10511091
fn try_new_rejects_state_length_mismatch() {
10521092
let input = partitioned_source(2, 3);
1053-
let err = PrefixMergeExec::try_new_resolved(input, vec![], vec![Vec::new()])
1054-
.expect_err("length mismatch must surface as an error");
1093+
let err = PrefixMergeExec::try_new_resolved(
1094+
input,
1095+
vec![],
1096+
vec![FinalizedPartitionState::default()],
1097+
)
1098+
.expect_err("length mismatch must surface as an error");
10551099
assert!(
10561100
err.to_string()
10571101
.contains("does not match input partition count"),
@@ -1176,8 +1220,10 @@ mod tests {
11761220
// Partition 1 gets partition 0's HLL state at the one aggregate slot
11771221
// (window_expr_index 0). Partition 0 gets an empty state — nothing
11781222
// to merge in.
1179-
let per_partition_state: Vec<FinalizedPartitionState> =
1180-
vec![vec![], vec![Some(p0_state)]];
1223+
let per_partition_state: Vec<FinalizedPartitionState> = vec![
1224+
FinalizedPartitionState::default(),
1225+
FinalizedPartitionState::new(vec![Some(p0_state)]),
1226+
];
11811227

11821228
let apply = WindowApply::Aggregate {
11831229
udf: Arc::clone(&udf),
@@ -1300,8 +1346,10 @@ mod tests {
13001346
};
13011347
// Partition 1 gets partition 0's (sum, count) state at the one
13021348
// aggregate slot; partition 0 has nothing to merge in.
1303-
let per_partition_state: Vec<FinalizedPartitionState> =
1304-
vec![vec![], vec![Some(p0_state)]];
1349+
let per_partition_state: Vec<FinalizedPartitionState> = vec![
1350+
FinalizedPartitionState::default(),
1351+
FinalizedPartitionState::new(vec![Some(p0_state)]),
1352+
];
13051353

13061354
let apply = WindowApply::Aggregate {
13071355
udf: Arc::clone(&udf),

ballista/core/src/execution_plans/window_state.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -294,8 +294,11 @@ pub fn prefix_merge_window_state(
294294
window_state_from_proto(&tagged.report)?;
295295
// `FinalizedPartitionState` carries no PARTITION BY dimension, so a
296296
// second group in one partition has nowhere to go. The prefix rule
297-
// only plants no-PARTITION-BY windows today; this is the assertion
298-
// that keeps that gate honest rather than silently merging groups.
297+
// only plants no-PARTITION-BY windows, and a window that has one
298+
// needs no prefix scan anyway — `BoundedWindowAggExec` asks for
299+
// `KeyPartitioned` input, so each partition's window is already
300+
// independent. This assertion keeps that gate honest rather than
301+
// silently merging two groups.
299302
if !partition_key.is_empty() {
300303
return internal_err!(
301304
"prefix merge: window state for partition {partition} carries a \
@@ -336,7 +339,7 @@ pub fn prefix_merge_window_state(
336339
// function publishes nothing.
337340
let carried = partition
338341
.checked_sub(1)
339-
.and_then(|prior| prefixes[prior][expr_index].as_ref());
342+
.and_then(|prior| prefixes[prior].slot(expr_index));
340343
let preceding = partition
341344
.checked_sub(1)
342345
.and_then(|prior| states.get(&(prior, expr_index)));
@@ -362,7 +365,7 @@ pub fn prefix_merge_window_state(
362365
}
363366
per_expr.push(Some(accumulator.state()?));
364367
}
365-
prefixes.push(per_expr);
368+
prefixes.push(FinalizedPartitionState::new(per_expr));
366369
}
367370
Ok(prefixes)
368371
}
@@ -427,7 +430,7 @@ mod tests {
427430
fn sums(prefixes: &[FinalizedPartitionState]) -> Vec<Option<f64>> {
428431
prefixes
429432
.iter()
430-
.map(|per_expr| match &per_expr[0] {
433+
.map(|per_expr| match per_expr.slot(0) {
431434
None => None,
432435
Some(state) => match &state[0] {
433436
ScalarValue::Float64(v) => *v,

ballista/core/src/serde/mod.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,7 @@ fn encode_prefix_state(
410410
let mut partitions = Vec::with_capacity(state.len());
411411
for partition in state {
412412
let mut slots = Vec::with_capacity(partition.len());
413-
for slot in partition {
413+
for slot in partition.slots() {
414414
let state = match slot {
415415
Some(values) => Some(protobuf::AggregateStateNode {
416416
values: encode_scalars(values, "prefix state")?,
@@ -438,7 +438,7 @@ fn decode_prefix_state(
438438
};
439439
slots.push(state);
440440
}
441-
partitions.push(slots);
441+
partitions.push(FinalizedPartitionState::new(slots));
442442
}
443443
Ok(partitions)
444444
}
@@ -1663,7 +1663,10 @@ mod test {
16631663
];
16641664
// One partition; slot 0 carries state, slot 1 is a window function
16651665
// that published none. `None` and `Some(vec![])` must stay distinct.
1666-
let state = vec![vec![Some(vec![ScalarValue::Float64(Some(42.0))]), None]];
1666+
let state = vec![FinalizedPartitionState::new(vec![
1667+
Some(vec![ScalarValue::Float64(Some(42.0))]),
1668+
None,
1669+
])];
16671670
let original =
16681671
PrefixMergeExec::try_new_resolved(input, applies, state.clone()).unwrap();
16691672

0 commit comments

Comments
 (0)