Skip to content

Commit 65ac32e

Browse files
feat(physical-plan): expose finalized Accumulator state on BoundedWindowAggExec
Add per-output-partition slots that hold each aggregate window function's final Accumulator::state() at partition-close, plus a public getter that distributed executors can call once a task has drained to ship state via a side-channel for cross-partition prefix scans without a two-pass halo-row scheme. The stream snapshots state at the top of prune_state — the last moment `state.is_end` entries are live before either prune_out_columns or prune_partition_batches drops them — so both mid-stream partition close (as SQL PARTITION BY groups end) and EOS are covered by one write site. Non-aggregate window functions (row_number, rank, lead/lag, ...) occupy `None` at their slot in the per-partition-key Vec. No trait changes: this uses the existing Accumulator::state() surface that the group-by Partial/Final protocol already relies on. Behavior without a reader is unchanged — the slots simply hold state that no one reads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 62cfc0c commit 65ac32e

2 files changed

Lines changed: 215 additions & 3 deletions

File tree

datafusion/physical-expr/src/window/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,5 @@ pub use window_expr::PartitionBatches;
2929
pub use window_expr::PartitionKey;
3030
pub use window_expr::PartitionWindowAggStates;
3131
pub use window_expr::WindowExpr;
32+
pub use window_expr::WindowFn;
3233
pub use window_expr::WindowState;

datafusion/physical-plan/src/windows/bounded_window_agg_exec.rs

Lines changed: 214 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
use std::cmp::{Ordering, min};
2424
use std::collections::VecDeque;
2525
use std::pin::Pin;
26-
use std::sync::Arc;
26+
use std::sync::{Arc, Mutex};
2727
use std::task::{Context, Poll};
2828

2929
use super::utils::create_schema;
@@ -54,13 +54,14 @@ use datafusion_common::utils::{
5454
evaluate_partition_ranges, get_at_indices, get_row_at_idx,
5555
};
5656
use datafusion_common::{
57-
HashMap, Result, arrow_datafusion_err, exec_datafusion_err, exec_err,
57+
HashMap, Result, ScalarValue, arrow_datafusion_err, exec_datafusion_err, exec_err,
58+
internal_datafusion_err,
5859
};
5960
use datafusion_execution::TaskContext;
6061
use datafusion_expr::ColumnarValue;
6162
use datafusion_expr::window_state::{PartitionBatchState, WindowAggState};
6263
use datafusion_physical_expr::window::{
63-
PartitionBatches, PartitionKey, PartitionWindowAggStates, WindowState,
64+
PartitionBatches, PartitionKey, PartitionWindowAggStates, WindowFn, WindowState,
6465
};
6566
use datafusion_physical_expr_common::physical_expr::PhysicalExpr;
6667
use datafusion_physical_expr_common::sort_expr::{
@@ -75,6 +76,14 @@ use hashbrown::hash_table::HashTable;
7576
use indexmap::IndexMap;
7677
use log::debug;
7778

79+
/// One output partition's snapshot of finalized [`Accumulator::state`] values,
80+
/// keyed by PARTITION BY tuple. The inner `Vec` is indexed by
81+
/// [`BoundedWindowAggExec::window_expr`]; non-aggregate window functions
82+
/// (row_number, rank, lead/lag, ...) occupy `None`.
83+
///
84+
/// [`Accumulator::state`]: datafusion_expr::Accumulator::state
85+
pub type FinalizedPartitionState = HashMap<PartitionKey, Vec<Option<Vec<ScalarValue>>>>;
86+
7887
/// Window execution plan
7988
#[derive(Debug, Clone)]
8089
pub struct BoundedWindowAggExec {
@@ -99,6 +108,16 @@ pub struct BoundedWindowAggExec {
99108
cache: Arc<PlanProperties>,
100109
/// If `can_rerepartition` is false, partition_keys is always empty.
101110
can_repartition: bool,
111+
/// Per-output-partition slots holding the finalized `Accumulator::state()`
112+
/// for each aggregate window expression, keyed by PARTITION BY tuple.
113+
/// The stream writes into slot `p` at partition-close, immediately before
114+
/// internal state would otherwise be pruned. Distributed executors read
115+
/// via [`Self::finalized_partition_state`] once a task has drained, to
116+
/// ship state via a side-channel for cross-partition prefix scans without
117+
/// a two-pass halo scheme. Non-aggregate window functions (row_number,
118+
/// rank, lead/lag, ...) have no `Accumulator` and are stored as `None`
119+
/// at their index in the per-partition-key `Vec`.
120+
finalized_state: Arc<[Mutex<FinalizedPartitionState>]>,
102121
}
103122

104123
impl BoundedWindowAggExec {
@@ -130,6 +149,11 @@ impl BoundedWindowAggExec {
130149
}
131150
};
132151
let cache = Self::compute_properties(&input, &schema, &window_expr)?;
152+
let partition_count = cache.partitioning.partition_count();
153+
let finalized_state: Arc<[Mutex<HashMap<_, _>>]> = (0..partition_count)
154+
.map(|_| Mutex::new(HashMap::new()))
155+
.collect::<Vec<_>>()
156+
.into();
133157
Ok(Self {
134158
input,
135159
window_expr,
@@ -139,6 +163,7 @@ impl BoundedWindowAggExec {
139163
ordered_partition_by_indices,
140164
cache: Arc::new(cache),
141165
can_repartition,
166+
finalized_state,
142167
})
143168
}
144169

@@ -235,6 +260,40 @@ impl BoundedWindowAggExec {
235260
}
236261
}
237262

263+
/// Snapshot of the finalized [`Accumulator::state`] for every partition
264+
/// key seen on output partition `partition`, populated as partitions close
265+
/// during streaming and complete once the stream has drained.
266+
///
267+
/// The outer `HashMap` is keyed by PARTITION BY tuple; the inner `Vec` is
268+
/// indexed by [`Self::window_expr`], with `None` at any slot whose window
269+
/// function is not an aggregate (row_number, rank, lead/lag, ...) and
270+
/// therefore exposes no state.
271+
///
272+
/// Returns an empty map when no partitions have closed yet. Errors when
273+
/// `partition` is outside the exec's output partitioning or when the slot
274+
/// mutex is poisoned.
275+
///
276+
/// [`Accumulator::state`]: datafusion_expr::Accumulator::state
277+
pub fn finalized_partition_state(
278+
&self,
279+
partition: usize,
280+
) -> Result<FinalizedPartitionState> {
281+
let slot = self.finalized_state.get(partition).ok_or_else(|| {
282+
internal_datafusion_err!(
283+
"BoundedWindowAggExec: partition {} out of range (have {})",
284+
partition,
285+
self.finalized_state.len()
286+
)
287+
})?;
288+
let guard = slot.lock().map_err(|e| {
289+
internal_datafusion_err!(
290+
"BoundedWindowAggExec partition {}: finalized-state mutex poisoned: {e}",
291+
partition
292+
)
293+
})?;
294+
Ok(guard.clone())
295+
}
296+
238297
fn statistics_helper(&self, statistics: Statistics) -> Result<Statistics> {
239298
let win_cols = self.window_expr.len();
240299
let input_cols = self.input.schema().fields().len();
@@ -371,12 +430,15 @@ impl ExecutionPlan for BoundedWindowAggExec {
371430
) -> Result<SendableRecordBatchStream> {
372431
let input = self.input.execute(partition, context)?;
373432
let search_mode = self.get_search_algo()?;
433+
let finalized_state = Arc::clone(&self.finalized_state);
374434
let stream = Box::pin(BoundedWindowAggStream::new(
375435
Arc::clone(&self.schema),
376436
self.window_expr.clone(),
377437
input,
378438
BaselineMetrics::new(&self.metrics, partition),
379439
search_mode,
440+
finalized_state,
441+
partition,
380442
)?);
381443
Ok(stream)
382444
}
@@ -1010,6 +1072,12 @@ pub struct BoundedWindowAggStream {
10101072
/// Search mode for partition columns. This determines the algorithm with
10111073
/// which we group each partition.
10121074
search_mode: Box<dyn PartitionSearcher>,
1075+
/// Shared with [`BoundedWindowAggExec::finalized_state`]. The stream
1076+
/// writes into slot `partition` at partition-close, immediately before
1077+
/// the entry is pruned from `window_agg_states`.
1078+
finalized_state: Arc<[Mutex<FinalizedPartitionState>]>,
1079+
/// Which slot in [`Self::finalized_state`] this stream writes to.
1080+
partition: usize,
10131081
}
10141082

10151083
impl BoundedWindowAggStream {
@@ -1021,6 +1089,11 @@ impl BoundedWindowAggStream {
10211089
// For instance, if `n_out` number of rows are calculated, we can remove
10221090
// first `n_out` rows from `self.input_buffer`.
10231091
fn prune_state(&mut self, n_out: usize) -> Result<()> {
1092+
// Snapshot `Accumulator::state()` for every partition/window-expr pair
1093+
// whose entry is about to be dropped, before any of the retain
1094+
// calls below fire. Pruning happens both mid-stream (as SQL partitions
1095+
// close) and at EOS; this snapshot covers both.
1096+
self.publish_finalized_state()?;
10241097
// Prune `self.window_agg_states`:
10251098
self.prune_out_columns();
10261099
// Prune `self.partition_batches`:
@@ -1053,6 +1126,8 @@ impl BoundedWindowAggStream {
10531126
input: SendableRecordBatchStream,
10541127
baseline_metrics: BaselineMetrics,
10551128
search_mode: Box<dyn PartitionSearcher>,
1129+
finalized_state: Arc<[Mutex<FinalizedPartitionState>]>,
1130+
partition: usize,
10561131
) -> Result<Self> {
10571132
let state = window_expr.iter().map(|_| IndexMap::new()).collect();
10581133
let empty_batch = RecordBatch::new_empty(Arc::clone(&schema));
@@ -1066,6 +1141,8 @@ impl BoundedWindowAggStream {
10661141
window_expr,
10671142
baseline_metrics,
10681143
search_mode,
1144+
finalized_state,
1145+
partition,
10691146
})
10701147
}
10711148

@@ -1200,6 +1277,58 @@ impl BoundedWindowAggStream {
12001277
}
12011278
}
12021279

1280+
/// Snapshot [`Accumulator::state`] for every entry whose `state.is_end` is
1281+
/// set, i.e. every partition/window-expr pair about to be dropped by the
1282+
/// retain in [`Self::prune_partition_batches`]. The result is written into
1283+
/// this stream's slot in the exec's shared `finalized_state`.
1284+
///
1285+
/// [`Accumulator::state`]: datafusion_expr::Accumulator::state
1286+
fn publish_finalized_state(&mut self) -> Result<()> {
1287+
let n_exprs = self.window_expr.len();
1288+
let mut finalizing: HashMap<PartitionKey, Vec<Option<Vec<ScalarValue>>>> =
1289+
HashMap::new();
1290+
for (expr_idx, window_agg_state) in self.window_agg_states.iter_mut().enumerate()
1291+
{
1292+
for (partition_row, WindowState { state, window_fn }) in
1293+
window_agg_state.iter_mut()
1294+
{
1295+
if !state.is_end {
1296+
continue;
1297+
}
1298+
let acc_state = match window_fn {
1299+
WindowFn::Aggregate(acc) => Some(acc.state()?),
1300+
WindowFn::Builtin(_) => None,
1301+
};
1302+
let entry = finalizing
1303+
.entry(partition_row.clone())
1304+
.or_insert_with(|| vec![None; n_exprs]);
1305+
entry[expr_idx] = acc_state;
1306+
}
1307+
}
1308+
if finalizing.is_empty() {
1309+
return Ok(());
1310+
}
1311+
let slot = self.finalized_state.get(self.partition).ok_or_else(|| {
1312+
internal_datafusion_err!(
1313+
"BoundedWindowAggStream: partition {} out of range for \
1314+
finalized_state (have {})",
1315+
self.partition,
1316+
self.finalized_state.len()
1317+
)
1318+
})?;
1319+
let mut guard = slot.lock().map_err(|e| {
1320+
internal_datafusion_err!(
1321+
"BoundedWindowAggStream partition {}: finalized-state mutex \
1322+
poisoned: {e}",
1323+
self.partition
1324+
)
1325+
})?;
1326+
for (key, per_expr) in finalizing {
1327+
guard.insert(key, per_expr);
1328+
}
1329+
Ok(())
1330+
}
1331+
12031332
/// Prunes the section of the input batch whose aggregate results
12041333
/// are calculated and emitted.
12051334
fn prune_input_batch(&mut self, n_out: usize) -> Result<()> {
@@ -1908,6 +2037,88 @@ mod tests {
19082037
Ok(())
19092038
}
19102039

2040+
/// After the stream drains, `finalized_partition_state` returns one
2041+
/// `Accumulator::state()` per PARTITION BY key seen — the per-partition
2042+
/// final sum for a cumulative `SUM(v) OVER (PARTITION BY pk)`. Confirms
2043+
/// state survives both the mid-stream partition-close prune (when the
2044+
/// sort keys change between rows) and the EOS flush.
2045+
#[tokio::test]
2046+
async fn finalized_partition_state_captures_sum_per_partition_key() -> Result<()> {
2047+
use arrow::array::Int64Array;
2048+
use datafusion_functions_aggregate::sum::sum_udaf;
2049+
2050+
let schema = Arc::new(Schema::new(vec![
2051+
Field::new("pk", DataType::Int64, false),
2052+
Field::new("v", DataType::Int64, false),
2053+
]));
2054+
let batch = RecordBatch::try_new(
2055+
Arc::clone(&schema),
2056+
vec![
2057+
Arc::new(Int64Array::from(vec![1, 1, 1, 2, 2, 2])),
2058+
Arc::new(Int64Array::from(vec![10, 20, 30, 100, 200, 300])),
2059+
],
2060+
)?;
2061+
2062+
let sort_info: LexOrdering = [PhysicalSortExpr {
2063+
expr: col("pk", &schema)?,
2064+
options: SortOptions::default(),
2065+
}]
2066+
.into();
2067+
let input: Arc<dyn ExecutionPlan> = Arc::new(
2068+
TestMemoryExec::try_new(&[vec![batch]], Arc::clone(&schema), None)?
2069+
.try_with_sort_information(vec![sort_info])?,
2070+
);
2071+
2072+
let window_fn = WindowFunctionDefinition::AggregateUDF(sum_udaf());
2073+
let args = vec![col("v", &schema)?];
2074+
let partitionby = vec![col("pk", &schema)?];
2075+
let orderby: Vec<PhysicalSortExpr> = vec![];
2076+
let window_frame = Arc::new(WindowFrame::new(None));
2077+
let window_expr = create_window_expr(
2078+
&window_fn,
2079+
"sum(v)".to_string(),
2080+
&args,
2081+
&partitionby,
2082+
&orderby,
2083+
window_frame,
2084+
Arc::clone(&schema),
2085+
false,
2086+
false,
2087+
None,
2088+
)?;
2089+
2090+
let exec: Arc<BoundedWindowAggExec> = Arc::new(BoundedWindowAggExec::try_new(
2091+
vec![window_expr],
2092+
input,
2093+
InputOrderMode::Sorted,
2094+
false,
2095+
)?);
2096+
2097+
let ctx = Arc::new(TaskContext::default());
2098+
let stream = ExecutionPlan::execute(exec.as_ref(), 0, ctx)?;
2099+
let _batches = collect(stream).await?;
2100+
2101+
let state = exec.finalized_partition_state(0)?;
2102+
assert_eq!(state.len(), 2, "one entry per PARTITION BY key");
2103+
let sum_of = |key: i64| -> ScalarValue {
2104+
let states = state
2105+
.get(&vec![ScalarValue::Int64(Some(key))])
2106+
.unwrap_or_else(|| panic!("partition key {key} missing from state"));
2107+
assert_eq!(states.len(), 1, "one window expr");
2108+
let inner = states[0]
2109+
.as_ref()
2110+
.expect("SUM is an aggregate — Accumulator state should be Some");
2111+
assert_eq!(inner.len(), 1, "SumAccumulator::state() is a single scalar");
2112+
inner[0].clone()
2113+
};
2114+
assert_eq!(sum_of(1), ScalarValue::Int64(Some(60)));
2115+
assert_eq!(sum_of(2), ScalarValue::Int64(Some(600)));
2116+
2117+
// A partition slot the exec doesn't own errors, not panics.
2118+
assert!(exec.finalized_partition_state(99).is_err());
2119+
Ok(())
2120+
}
2121+
19112122
#[test]
19122123
fn test_bounded_window_agg_cardinality_effect() -> Result<()> {
19132124
let schema = test_schema();

0 commit comments

Comments
 (0)