2323use std:: cmp:: { Ordering , min} ;
2424use std:: collections:: VecDeque ;
2525use std:: pin:: Pin ;
26- use std:: sync:: Arc ;
26+ use std:: sync:: { Arc , Mutex } ;
2727use std:: task:: { Context , Poll } ;
2828
2929use super :: utils:: create_schema;
@@ -54,13 +54,14 @@ use datafusion_common::utils::{
5454 evaluate_partition_ranges, get_at_indices, get_row_at_idx,
5555} ;
5656use 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} ;
5960use datafusion_execution:: TaskContext ;
6061use datafusion_expr:: ColumnarValue ;
6162use datafusion_expr:: window_state:: { PartitionBatchState , WindowAggState } ;
6263use datafusion_physical_expr:: window:: {
63- PartitionBatches , PartitionKey , PartitionWindowAggStates , WindowState ,
64+ PartitionBatches , PartitionKey , PartitionWindowAggStates , WindowFn , WindowState ,
6465} ;
6566use datafusion_physical_expr_common:: physical_expr:: PhysicalExpr ;
6667use datafusion_physical_expr_common:: sort_expr:: {
@@ -75,6 +76,14 @@ use hashbrown::hash_table::HashTable;
7576use indexmap:: IndexMap ;
7677use 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 ) ]
8089pub 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
104123impl 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
10151083impl 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