use std::mem::size_of;
use std::ops::ControlFlow;
use std::sync::Arc;
use std::task::{Context, Poll};
use arrow::datatypes::SchemaRef;
use arrow::record_batch::RecordBatch;
use datafusion_common::{DataFusionError, Result, internal_datafusion_err, internal_err};
use datafusion_execution::TaskContext;
use datafusion_execution::memory_pool::{MemoryConsumer, MemoryReservation};
use datafusion_physical_expr::PhysicalSortExpr;
use datafusion_physical_expr::expressions::Column;
use datafusion_physical_expr_common::sort_expr::LexOrdering;
use futures::stream::{Stream, StreamExt};
use super::AggregateExec;
use super::aggregate_hash_table::{
AggregateHashTable, FinalMarker, PartialMarker, PartialSkipMarker,
};
use super::group_values::GroupByMetrics;
use super::ordered_final_stream::OrderedFinalAggregateStream;
use super::skip_partial::SkipAggregationProbe;
use crate::metrics::{
BaselineMetrics, MetricBuilder, MetricCategory, RecordOutput, SpillMetrics,
};
use crate::sorts::IncrementalSortIterator;
use crate::sorts::streaming_merge::{SortedSpillFile, StreamingMergeBuilder};
use crate::spill::spill_manager::SpillManager;
use crate::stream::EmptyRecordBatchStream;
use crate::{InputOrderMode, RecordBatchStream, SendableRecordBatchStream, metrics};
pub(crate) struct PartialHashAggregateStream {
schema: SchemaRef,
input: SendableRecordBatchStream,
batch_size: usize,
reservation: MemoryReservation,
baseline_metrics: BaselineMetrics,
reduction_factor: metrics::RatioMetrics,
skip_aggregation_probe: Option<SkipAggregationProbe>,
group_values_soft_limit: Option<usize>,
state: Option<PartialHashAggregateState>,
}
enum PartialHashAggregateState {
ReadingInput {
hash_table: AggregateHashTable<PartialMarker>,
},
EmittingOnMemoryPressure {
hash_table: AggregateHashTable<PartialMarker>,
remaining_groups: RecordBatch,
},
ProducingOutput {
hash_table: AggregateHashTable<PartialMarker>,
skip_hash_table: Option<AggregateHashTable<PartialSkipMarker>>,
},
SkippingAggregation {
hash_table: AggregateHashTable<PartialSkipMarker>,
},
Done,
Error,
}
type PartialHashAggregatePoll = Poll<Option<Result<RecordBatch>>>;
type PartialHashAggregateStateTransition = ControlFlow<
(PartialHashAggregatePoll, PartialHashAggregateState),
PartialHashAggregateState,
>;
struct FinalSpillContext {
final_agg: AggregateExec,
context: Arc<TaskContext>,
partition: usize,
batch_size: usize,
spill_expr: LexOrdering,
spill_manager: SpillManager,
spills: Vec<SortedSpillFile>,
}
pub(crate) struct FinalHashAggregateStream {
schema: SchemaRef,
input: SendableRecordBatchStream,
baseline_metrics: BaselineMetrics,
reservation: MemoryReservation,
group_values_soft_limit: Option<usize>,
state: Option<FinalHashAggregateState>,
}
enum FinalHashAggregateState {
ReadingInput {
hash_table: AggregateHashTable<FinalMarker>,
spill_context: Option<Box<FinalSpillContext>>,
},
Spilling {
hash_table: AggregateHashTable<FinalMarker>,
spill_context: Box<FinalSpillContext>,
},
ProducingOutput {
hash_table: AggregateHashTable<FinalMarker>,
},
PreparingMergeInput {
hash_table: AggregateHashTable<FinalMarker>,
spill_context: Box<FinalSpillContext>,
},
MergingSpills {
stream: SendableRecordBatchStream,
},
Done,
Error,
}
type FinalHashAggregatePoll = Poll<Option<Result<RecordBatch>>>;
type FinalHashAggregateStateTransition = ControlFlow<
(FinalHashAggregatePoll, FinalHashAggregateState),
FinalHashAggregateState,
>;
impl FinalSpillContext {
fn new(
agg: &AggregateExec,
context: &Arc<TaskContext>,
partition: usize,
batch_size: usize,
spill_schema: &SchemaRef,
spill_metrics: SpillMetrics,
) -> Result<Self> {
let group_schema = agg.group_by.group_schema(&agg.input().schema())?;
let output_ordering = agg.cache.output_ordering();
let spill_sort_exprs =
group_schema
.fields()
.iter()
.enumerate()
.map(|(idx, field)| {
let output_expr = Column::new(field.name(), idx);
let sort_options = output_ordering
.and_then(|ordering| ordering.get_sort_options(&output_expr))
.unwrap_or_default();
PhysicalSortExpr::new(Arc::new(output_expr), sort_options)
});
let Some(spill_expr) = LexOrdering::new(spill_sort_exprs) else {
return internal_err!("Final hash aggregate spill expression is empty");
};
let spill_manager = SpillManager::new(
context.runtime_env(),
spill_metrics,
Arc::clone(spill_schema),
)
.with_compression_type(context.session_config().spill_compression());
let mut final_agg = agg.clone();
final_agg.input_order_mode = InputOrderMode::Sorted;
Ok(Self {
final_agg,
context: Arc::clone(context),
partition,
batch_size,
spill_expr,
spill_manager,
spills: vec![],
})
}
fn has_spills(&self) -> bool {
!self.spills.is_empty()
}
fn spill_table(
&mut self,
hash_table: &mut AggregateHashTable<FinalMarker>,
) -> Result<()> {
let Some(batch) = hash_table.take_state_batch()? else {
return Ok(());
};
let sorted_iter =
IncrementalSortIterator::new(batch, self.spill_expr.clone(), self.batch_size);
let spill_file = self
.spill_manager
.spill_record_batch_iter_and_return_max_batch_memory(
sorted_iter,
"FinalHashAggregateSpill",
)?;
let Some((file, max_record_batch_memory)) = spill_file else {
return internal_err!("Final hash aggregation produced an empty spill");
};
self.spills.push(SortedSpillFile {
file,
max_record_batch_memory,
});
Ok(())
}
fn into_replay_stream(
self,
baseline_metrics: &BaselineMetrics,
group_by_metrics: GroupByMetrics,
reservation: MemoryReservation,
) -> Result<SendableRecordBatchStream> {
let Self {
final_agg,
context,
partition,
batch_size,
spill_expr,
spill_manager,
spills,
} = self;
let spill_schema = Arc::clone(spill_manager.schema());
let merge_reservation = reservation.new_empty();
let merged = StreamingMergeBuilder::new()
.with_schema(spill_schema)
.with_spill_manager(spill_manager)
.with_sorted_spill_files(spills)
.with_expressions(&spill_expr)
.with_metrics(baseline_metrics.intermediate())
.with_batch_size(batch_size)
.with_reservation(merge_reservation)
.build()?;
let replay = OrderedFinalAggregateStream::new_with_input_and_metrics(
&final_agg,
&context,
partition,
merged,
&InputOrderMode::Sorted,
baseline_metrics.clone(),
group_by_metrics,
None,
reservation,
)?;
Ok(Box::pin(replay))
}
}
impl PartialHashAggregateStream {
pub fn new(
agg: &AggregateExec,
context: &Arc<TaskContext>,
partition: usize,
) -> Result<Self> {
debug_assert_eq!(agg.mode, super::AggregateMode::Partial);
debug_assert_eq!(agg.input_order_mode, InputOrderMode::Linear);
let schema = Arc::clone(&agg.schema);
let input = agg.input.execute(partition, Arc::clone(context))?;
let batch_size = context.session_config().batch_size();
let baseline_metrics = BaselineMetrics::new(&agg.metrics, partition);
let _spill_metrics = SpillMetrics::new(&agg.metrics, partition);
let reduction_factor = MetricBuilder::new(&agg.metrics)
.with_type(metrics::MetricType::Summary)
.ratio_metrics("reduction_factor", partition);
let hash_table = AggregateHashTable::<PartialMarker>::new(
agg,
partition,
Arc::clone(&schema),
batch_size,
)?;
let skip_aggregation_probe = if agg.group_by.is_single() {
let options = &context.session_config().options().execution;
let probe_ratio_threshold =
options.skip_partial_aggregation_probe_ratio_threshold;
if probe_ratio_threshold >= 1.0 {
None
} else {
let skipped_aggregation_rows = MetricBuilder::new(&agg.metrics)
.with_category(MetricCategory::Rows)
.counter("skipped_aggregation_rows", partition);
Some(SkipAggregationProbe::new(
options.skip_partial_aggregation_probe_rows_threshold,
probe_ratio_threshold,
skipped_aggregation_rows,
))
}
} else {
None
};
let reservation =
MemoryConsumer::new(format!("PartialHashAggregateStream[{partition}]"))
.with_can_spill(true)
.register(context.memory_pool());
Ok(Self {
schema,
input,
batch_size,
baseline_metrics,
reservation,
reduction_factor,
skip_aggregation_probe,
group_values_soft_limit: agg.limit_options().map(|config| config.limit()),
state: Some(PartialHashAggregateState::ReadingInput { hash_table }),
})
}
fn close_input(&mut self) {
let input_schema = self.input.schema();
self.input = Box::pin(EmptyRecordBatchStream::new(input_schema));
}
fn break_with_err(error: DataFusionError) -> PartialHashAggregateStateTransition {
ControlFlow::Break((
Poll::Ready(Some(Err(error))),
PartialHashAggregateState::Error,
))
}
fn break_with_internal_err(
message: impl std::fmt::Display,
) -> PartialHashAggregateStateTransition {
Self::break_with_err(internal_datafusion_err!("{message}"))
}
fn hit_soft_group_limit(
&self,
hash_table: &AggregateHashTable<PartialMarker>,
) -> bool {
self.group_values_soft_limit
.is_some_and(|limit| limit <= hash_table.building_group_count())
}
fn update_skip_aggregation_probe(&mut self, input_rows: usize, num_groups: usize) {
if let Some(probe) = self.skip_aggregation_probe.as_mut() {
probe.update_state(input_rows, num_groups);
}
}
fn should_skip_aggregation(&self) -> bool {
self.skip_aggregation_probe
.as_ref()
.is_some_and(|probe| probe.should_skip())
}
fn start_output(
&mut self,
hash_table: &mut AggregateHashTable<PartialMarker>,
close_input: bool,
) -> Result<()> {
if close_input {
let input_schema = self.input.schema();
self.input = Box::pin(EmptyRecordBatchStream::new(input_schema));
}
hash_table.start_output()
}
fn handle_reading_input(
&mut self,
cx: &mut Context<'_>,
original_state: PartialHashAggregateState,
) -> PartialHashAggregateStateTransition {
let PartialHashAggregateState::ReadingInput { mut hash_table } = original_state
else {
return Self::break_with_internal_err(
"Partial hash aggregate stream expected ReadingInput state",
);
};
debug_assert!(hash_table.is_building());
match self.input.poll_next_unpin(cx) {
Poll::Pending => ControlFlow::Break((
Poll::Pending,
PartialHashAggregateState::ReadingInput { hash_table },
)),
Poll::Ready(Some(Ok(batch))) => {
let elapsed_compute = self.baseline_metrics.elapsed_compute().clone();
let timer = elapsed_compute.timer();
let input_rows = batch.num_rows();
self.reduction_factor.add_total(input_rows);
let result = hash_table.aggregate_batch(&batch);
timer.done();
if let Err(e) = result {
return Self::break_with_err(e);
}
if self.hit_soft_group_limit(&hash_table) {
let timer = elapsed_compute.timer();
let result = self.start_output(&mut hash_table, true);
timer.done();
if let Err(e) = result {
return Self::break_with_err(e);
}
return ControlFlow::Continue(
PartialHashAggregateState::ProducingOutput {
hash_table,
skip_hash_table: None,
},
);
}
self.update_skip_aggregation_probe(
input_rows,
hash_table.building_group_count(),
);
if self.should_skip_aggregation() {
let timer = elapsed_compute.timer();
let result = match hash_table.partial_skip_table() {
Ok(skip_hash_table) => self
.start_output(&mut hash_table, false)
.map(|()| skip_hash_table),
Err(e) => Err(e),
};
timer.done();
match result {
Ok(skip_hash_table) => {
return ControlFlow::Continue(
PartialHashAggregateState::ProducingOutput {
hash_table,
skip_hash_table: Some(skip_hash_table),
},
);
}
Err(e) => return Self::break_with_err(e),
}
}
let timer = elapsed_compute.timer();
let resize_result = self.reservation.try_resize(hash_table.memory_size());
timer.done();
match resize_result {
Ok(()) => {}
Err(DataFusionError::ResourcesExhausted(_)) => {
let elapsed_compute =
self.baseline_metrics.elapsed_compute().clone();
let _timer = elapsed_compute.timer();
let state_batch_result = hash_table.take_state_batch();
let resize_result =
self.reservation.try_resize(hash_table.memory_size());
if let Err(e) = resize_result {
return Self::break_with_err(e);
}
let materialized_group_states = match state_batch_result {
Ok(Some(batch)) => batch,
Ok(None) => {
return Self::break_with_err(internal_datafusion_err!(
"Partial hash aggregate ran out of memory with no aggregated groups"
));
}
Err(e) => return Self::break_with_err(e),
};
return ControlFlow::Continue(
PartialHashAggregateState::EmittingOnMemoryPressure {
hash_table,
remaining_groups: materialized_group_states,
},
);
}
Err(e) => return Self::break_with_err(e),
}
ControlFlow::Continue(PartialHashAggregateState::ReadingInput {
hash_table,
})
}
Poll::Ready(Some(Err(e))) => Self::break_with_err(e),
Poll::Ready(None) => {
let elapsed_compute = self.baseline_metrics.elapsed_compute().clone();
let timer = elapsed_compute.timer();
let result = self.start_output(&mut hash_table, true);
timer.done();
match result {
Ok(()) => ControlFlow::Continue(
PartialHashAggregateState::ProducingOutput {
hash_table,
skip_hash_table: None,
},
),
Err(e) => Self::break_with_err(e),
}
}
}
}
fn handle_emitting_on_memory_pressure(
&mut self,
original_state: PartialHashAggregateState,
) -> PartialHashAggregateStateTransition {
let PartialHashAggregateState::EmittingOnMemoryPressure {
hash_table,
remaining_groups: batch,
} = original_state
else {
return Self::break_with_internal_err(
"Partial hash aggregate stream expected EmittingOnMemoryPressure state",
);
};
let (output_batch, next_state) = if batch.num_rows() <= self.batch_size {
(
batch,
PartialHashAggregateState::ReadingInput { hash_table },
)
} else {
let remaining =
batch.slice(self.batch_size, batch.num_rows() - self.batch_size);
let output = batch.slice(0, self.batch_size);
(
output,
PartialHashAggregateState::EmittingOnMemoryPressure {
hash_table,
remaining_groups: remaining,
},
)
};
self.reduction_factor.add_part(output_batch.num_rows());
debug_assert!(output_batch.num_rows() > 0);
ControlFlow::Break((
Poll::Ready(Some(Ok(output_batch.record_output(&self.baseline_metrics)))),
next_state,
))
}
fn handle_producing_output(
&mut self,
original_state: PartialHashAggregateState,
) -> PartialHashAggregateStateTransition {
let PartialHashAggregateState::ProducingOutput {
mut hash_table,
skip_hash_table,
} = original_state
else {
return Self::break_with_internal_err(
"Partial hash aggregate stream expected ProducingOutput state",
);
};
debug_assert!(!hash_table.is_building());
let elapsed_compute = self.baseline_metrics.elapsed_compute().clone();
let timer = elapsed_compute.timer();
let result = hash_table.next_output_batch();
timer.done();
match result {
Ok(Some(batch)) => {
let _ = self.reservation.try_resize(hash_table.memory_size());
self.reduction_factor.add_part(batch.num_rows());
debug_assert!(batch.num_rows() > 0);
let next_state = if hash_table.is_done() {
match skip_hash_table {
Some(hash_table) => {
PartialHashAggregateState::SkippingAggregation { hash_table }
}
None => PartialHashAggregateState::Done,
}
} else {
PartialHashAggregateState::ProducingOutput {
hash_table,
skip_hash_table,
}
};
ControlFlow::Break((
Poll::Ready(Some(Ok(batch.record_output(&self.baseline_metrics)))),
next_state,
))
}
Ok(None) => {
let _ = self.reservation.try_resize(0);
let next_state = match skip_hash_table {
Some(hash_table) => {
PartialHashAggregateState::SkippingAggregation { hash_table }
}
None => PartialHashAggregateState::Done,
};
ControlFlow::Continue(next_state)
}
Err(e) => Self::break_with_err(e),
}
}
fn handle_skipping_aggregation(
&mut self,
cx: &mut Context<'_>,
original_state: PartialHashAggregateState,
) -> PartialHashAggregateStateTransition {
let PartialHashAggregateState::SkippingAggregation { mut hash_table } =
original_state
else {
return Self::break_with_internal_err(
"Partial hash aggregate stream expected SkippingAggregation state",
);
};
match self.input.poll_next_unpin(cx) {
Poll::Pending => ControlFlow::Break((
Poll::Pending,
PartialHashAggregateState::SkippingAggregation { hash_table },
)),
Poll::Ready(Some(Ok(batch))) => {
if let Some(probe) = self.skip_aggregation_probe.as_mut() {
probe.record_skipped(&batch);
}
let elapsed_compute = self.baseline_metrics.elapsed_compute().clone();
let timer = elapsed_compute.timer();
let result = hash_table.convert_batch_to_state(&batch);
timer.done();
match result {
Ok(batch) => ControlFlow::Break((
Poll::Ready(Some(
Ok(batch.record_output(&self.baseline_metrics)),
)),
PartialHashAggregateState::SkippingAggregation { hash_table },
)),
Err(e) => Self::break_with_err(e),
}
}
Poll::Ready(Some(Err(e))) => Self::break_with_err(e),
Poll::Ready(None) => {
let input_schema = self.input.schema();
self.input = Box::pin(EmptyRecordBatchStream::new(input_schema));
ControlFlow::Continue(PartialHashAggregateState::Done)
}
}
}
}
impl Stream for PartialHashAggregateStream {
type Item = Result<RecordBatch>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Self::Item>> {
loop {
let cur_state = self
.state
.take()
.expect("PartialHashAggregateStream state should not be None");
let next_state = match cur_state {
state @ PartialHashAggregateState::ReadingInput { .. } => {
self.handle_reading_input(cx, state)
}
state @ PartialHashAggregateState::EmittingOnMemoryPressure { .. } => {
self.handle_emitting_on_memory_pressure(state)
}
state @ PartialHashAggregateState::ProducingOutput { .. } => {
self.handle_producing_output(state)
}
state @ PartialHashAggregateState::SkippingAggregation { .. } => {
self.handle_skipping_aggregation(cx, state)
}
state @ PartialHashAggregateState::Error => {
self.close_input();
self.reservation.free();
self.state = Some(state);
return Poll::Ready(None);
}
state @ PartialHashAggregateState::Done => {
let _ = self.reservation.try_resize(0);
self.state = Some(state);
return Poll::Ready(None);
}
};
match next_state {
ControlFlow::Continue(next_state) => {
self.state = Some(next_state);
continue;
}
ControlFlow::Break((Poll::Ready(Some(Err(e))), next_state)) => {
debug_assert!(matches!(next_state, PartialHashAggregateState::Error));
self.close_input();
self.reservation.free();
self.state = Some(PartialHashAggregateState::Error);
return Poll::Ready(Some(Err(e)));
}
ControlFlow::Break((poll, next_state)) => {
self.state = Some(next_state);
return poll;
}
}
}
}
}
impl RecordBatchStream for PartialHashAggregateStream {
fn schema(&self) -> SchemaRef {
Arc::clone(&self.schema)
}
}
impl FinalHashAggregateStream {
pub fn new(
agg: &AggregateExec,
context: &Arc<TaskContext>,
partition: usize,
) -> Result<Self> {
debug_assert!(matches!(
agg.mode,
super::AggregateMode::Final | super::AggregateMode::FinalPartitioned
));
debug_assert_eq!(agg.input_order_mode, InputOrderMode::Linear);
let schema = Arc::clone(&agg.schema);
let input = agg.input.execute(partition, Arc::clone(context))?;
let input_schema = input.schema();
let batch_size = context.session_config().batch_size();
let baseline_metrics = BaselineMetrics::new(&agg.metrics, partition);
let spill_metrics = SpillMetrics::new(&agg.metrics, partition);
let hash_table = AggregateHashTable::<FinalMarker>::new(
agg,
partition,
Arc::clone(&schema),
batch_size,
)?;
let can_spill = context.runtime_env().disk_manager.tmp_files_enabled();
let spill_context = if can_spill {
Some(Box::new(FinalSpillContext::new(
agg,
context,
partition,
batch_size,
&input_schema,
spill_metrics,
)?))
} else {
None
};
let reservation =
MemoryConsumer::new(format!("FinalHashAggregateStream[{partition}]"))
.with_can_spill(can_spill)
.register(context.memory_pool());
Ok(Self {
schema,
input,
baseline_metrics,
reservation,
group_values_soft_limit: agg.limit_options().map(|config| config.limit()),
state: Some(FinalHashAggregateState::ReadingInput {
hash_table,
spill_context,
}),
})
}
fn close_input(&mut self) {
let input_schema = self.input.schema();
self.input = Box::pin(EmptyRecordBatchStream::new(input_schema));
}
fn break_with_err(error: DataFusionError) -> FinalHashAggregateStateTransition {
ControlFlow::Break((
Poll::Ready(Some(Err(error))),
FinalHashAggregateState::Error,
))
}
fn break_with_internal_err(
message: impl std::fmt::Display,
) -> FinalHashAggregateStateTransition {
Self::break_with_err(internal_datafusion_err!("{message}"))
}
fn hit_soft_group_limit(&self, hash_table: &AggregateHashTable<FinalMarker>) -> bool {
self.group_values_soft_limit
.is_some_and(|limit| limit <= hash_table.building_group_count())
}
fn start_output(
&mut self,
hash_table: &mut AggregateHashTable<FinalMarker>,
) -> Result<()> {
self.close_input();
hash_table.start_output()
}
fn reservation_size_for_table(
hash_table: &AggregateHashTable<FinalMarker>,
spill_context: Option<&FinalSpillContext>,
) -> usize {
let table_size = hash_table.memory_size();
if spill_context.is_some() {
table_size.saturating_add(
hash_table
.building_group_count()
.saturating_mul(size_of::<u32>()),
)
} else {
table_size
}
}
fn handle_reading_input(
&mut self,
cx: &mut Context<'_>,
original_state: FinalHashAggregateState,
) -> FinalHashAggregateStateTransition {
let FinalHashAggregateState::ReadingInput {
mut hash_table,
spill_context,
} = original_state
else {
return Self::break_with_internal_err(
"Final hash aggregate stream expected ReadingInput state",
);
};
match self.input.poll_next_unpin(cx) {
Poll::Pending => ControlFlow::Break((
Poll::Pending,
FinalHashAggregateState::ReadingInput {
hash_table,
spill_context,
},
)),
Poll::Ready(Some(Ok(batch))) => {
let elapsed_compute = self.baseline_metrics.elapsed_compute().clone();
let timer = elapsed_compute.timer();
let result = hash_table.aggregate_batch(&batch);
timer.done();
if let Err(e) = result {
return Self::break_with_err(e);
}
let spilled = spill_context
.as_ref()
.is_some_and(|context| context.has_spills());
if self.hit_soft_group_limit(&hash_table) && !spilled {
let timer = elapsed_compute.timer();
let result = self.start_output(&mut hash_table);
timer.done();
return match result {
Ok(()) => ControlFlow::Continue(
FinalHashAggregateState::ProducingOutput { hash_table },
),
Err(e) => Self::break_with_err(e),
};
}
let timer = elapsed_compute.timer();
let resize_result =
self.reservation
.try_resize(Self::reservation_size_for_table(
&hash_table,
spill_context.as_deref(),
));
timer.done();
match resize_result {
Ok(()) => {}
Err(e @ DataFusionError::ResourcesExhausted(_)) => {
let Some(spill_context) = spill_context else {
return Self::break_with_err(e.context(
"Final hash aggregate cannot spill because temporary files are not enabled in the DiskManager",
));
};
if hash_table.building_group_count() == 0 {
return Self::break_with_internal_err(
"Final hash aggregate ran out of memory with no aggregated groups",
);
}
return ControlFlow::Continue(
FinalHashAggregateState::Spilling {
hash_table,
spill_context,
},
);
}
Err(e) => return Self::break_with_err(e),
}
ControlFlow::Continue(FinalHashAggregateState::ReadingInput {
hash_table,
spill_context,
})
}
Poll::Ready(Some(Err(e))) => Self::break_with_err(e),
Poll::Ready(None) => {
self.close_input();
match spill_context {
Some(spill_context) if spill_context.has_spills() => {
ControlFlow::Continue(
FinalHashAggregateState::PreparingMergeInput {
hash_table,
spill_context,
},
)
}
_ => {
let elapsed_compute =
self.baseline_metrics.elapsed_compute().clone();
let timer = elapsed_compute.timer();
let result = hash_table.start_output();
timer.done();
match result {
Ok(()) => ControlFlow::Continue(
FinalHashAggregateState::ProducingOutput { hash_table },
),
Err(e) => Self::break_with_err(e),
}
}
}
}
}
}
fn handle_spilling(
&mut self,
original_state: FinalHashAggregateState,
) -> FinalHashAggregateStateTransition {
let FinalHashAggregateState::Spilling {
mut hash_table,
mut spill_context,
} = original_state
else {
return Self::break_with_internal_err(
"Final hash aggregate stream expected Spilling state",
);
};
if hash_table.building_group_count() == 0 {
return Self::break_with_internal_err(
"Final hash aggregation entered Spilling with an empty table",
);
}
let elapsed_compute = self.baseline_metrics.elapsed_compute().clone();
let timer = elapsed_compute.timer();
let mut result = spill_context.spill_table(&mut hash_table);
if let Err(e) = self.reservation.try_resize(hash_table.memory_size()) {
result =
Err(e.context("Decreasing allocation after spilling should succeed"));
}
timer.done();
match result {
Ok(()) => ControlFlow::Continue(FinalHashAggregateState::ReadingInput {
hash_table,
spill_context: Some(spill_context),
}),
Err(e) => Self::break_with_err(e),
}
}
fn handle_preparing_merge_input(
&mut self,
original_state: FinalHashAggregateState,
) -> FinalHashAggregateStateTransition {
let FinalHashAggregateState::PreparingMergeInput {
mut hash_table,
mut spill_context,
} = original_state
else {
return Self::break_with_internal_err(
"Final hash aggregate stream expected PreparingMergeInput state",
);
};
let elapsed_compute = self.baseline_metrics.elapsed_compute().clone();
let timer = elapsed_compute.timer();
let replay = match spill_context.spill_table(&mut hash_table) {
Ok(()) => {
let group_by_metrics = hash_table.group_by_metrics().clone();
drop(hash_table);
match self.reservation.try_resize(0) {
Ok(()) => (*spill_context).into_replay_stream(
&self.baseline_metrics,
group_by_metrics,
self.reservation.new_empty(),
),
Err(e) => Err(e),
}
}
Err(e) => Err(e),
};
timer.done();
match replay {
Ok(stream) => {
ControlFlow::Continue(FinalHashAggregateState::MergingSpills { stream })
}
Err(e) => Self::break_with_err(e),
}
}
fn handle_merging_spills(
&mut self,
cx: &mut Context<'_>,
original_state: FinalHashAggregateState,
) -> FinalHashAggregateStateTransition {
let FinalHashAggregateState::MergingSpills { mut stream } = original_state else {
return Self::break_with_internal_err(
"Final hash aggregate stream expected MergingSpills state",
);
};
match stream.poll_next_unpin(cx) {
Poll::Pending => ControlFlow::Break((
Poll::Pending,
FinalHashAggregateState::MergingSpills { stream },
)),
Poll::Ready(Some(Ok(batch))) => ControlFlow::Break((
Poll::Ready(Some(Ok(batch))),
FinalHashAggregateState::MergingSpills { stream },
)),
Poll::Ready(Some(Err(e))) => Self::break_with_err(e),
Poll::Ready(None) => ControlFlow::Continue(FinalHashAggregateState::Done),
}
}
fn handle_producing_output(
&mut self,
original_state: FinalHashAggregateState,
) -> FinalHashAggregateStateTransition {
let FinalHashAggregateState::ProducingOutput { mut hash_table } = original_state
else {
return Self::break_with_internal_err(
"Final hash aggregate stream expected ProducingOutput state",
);
};
let elapsed_compute = self.baseline_metrics.elapsed_compute().clone();
let timer = elapsed_compute.timer();
let result = hash_table.next_output_batch();
timer.done();
match result {
Ok(Some(batch)) => {
let next_state = if hash_table.is_done() {
drop(hash_table);
if let Err(e) = self.reservation.try_resize(0) {
return Self::break_with_err(e);
}
FinalHashAggregateState::Done
} else {
if let Err(e) = self.reservation.try_resize(hash_table.memory_size())
{
return Self::break_with_err(e);
}
FinalHashAggregateState::ProducingOutput { hash_table }
};
ControlFlow::Break((
Poll::Ready(Some(Ok(batch.record_output(&self.baseline_metrics)))),
next_state,
))
}
Err(e) => Self::break_with_err(e),
Ok(None) => {
drop(hash_table);
let next_state = FinalHashAggregateState::Done;
if let Err(e) = self.reservation.try_resize(0) {
return Self::break_with_err(e);
}
ControlFlow::Continue(next_state)
}
}
}
}
impl Stream for FinalHashAggregateStream {
type Item = Result<RecordBatch>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Self::Item>> {
loop {
let cur_state = self
.state
.take()
.expect("FinalHashAggregateStream state should not be None");
let next_state = match cur_state {
state @ FinalHashAggregateState::ReadingInput { .. } => {
self.handle_reading_input(cx, state)
}
state @ FinalHashAggregateState::Spilling { .. } => {
self.handle_spilling(state)
}
state @ FinalHashAggregateState::PreparingMergeInput { .. } => {
self.handle_preparing_merge_input(state)
}
state @ FinalHashAggregateState::MergingSpills { .. } => {
self.handle_merging_spills(cx, state)
}
state @ FinalHashAggregateState::ProducingOutput { .. } => {
self.handle_producing_output(state)
}
state @ FinalHashAggregateState::Error => {
self.close_input();
self.reservation.free();
self.state = Some(state);
return Poll::Ready(None);
}
state @ FinalHashAggregateState::Done => {
let _ = self.reservation.try_resize(0);
self.state = Some(state);
return Poll::Ready(None);
}
};
match next_state {
ControlFlow::Continue(next_state) => {
self.state = Some(next_state);
continue;
}
ControlFlow::Break((Poll::Ready(Some(Err(e))), next_state)) => {
debug_assert!(matches!(next_state, FinalHashAggregateState::Error));
self.close_input();
self.reservation.free();
self.state = Some(FinalHashAggregateState::Error);
return Poll::Ready(Some(Err(e)));
}
ControlFlow::Break((poll, next_state)) => {
self.state = Some(next_state);
return poll;
}
}
}
}
}
impl RecordBatchStream for FinalHashAggregateStream {
fn schema(&self) -> SchemaRef {
Arc::clone(&self.schema)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use crate::aggregates::{AggregateMode, PhysicalGroupBy};
use crate::execution_plan::ExecutionPlan;
use crate::test::TestMemoryExec;
use arrow::array::{Int32Array, Int64Array};
use arrow::datatypes::{DataType, Field, Schema};
use datafusion_common::Result;
use datafusion_execution::runtime_env::RuntimeEnvBuilder;
use datafusion_functions_aggregate::count::count_udaf;
use datafusion_physical_expr::aggregate::AggregateExprBuilder;
use datafusion_physical_expr::expressions::col;
use futures::StreamExt;
#[tokio::test]
async fn test_partial_hash_stream_double_emission_race_condition_bug() -> Result<()> {
let schema = Arc::new(Schema::new(vec![
Field::new("group_col", DataType::Int32, false),
Field::new("value_col", DataType::Int64, false),
]));
let batch_size = 1024; let num_groups = batch_size + 100;
let group_ids: Vec<i32> = (0..num_groups as i32).collect();
let values: Vec<i64> = vec![1; num_groups];
let batch = RecordBatch::try_new(
Arc::clone(&schema),
vec![
Arc::new(Int32Array::from(group_ids)),
Arc::new(Int64Array::from(values)),
],
)?;
let input_partitions = vec![vec![batch]];
let runtime = RuntimeEnvBuilder::default()
.with_memory_limit(1024, 1.0) .build_arc()?;
let mut task_ctx = TaskContext::default().with_runtime(runtime);
let mut session_config = task_ctx.session_config().clone();
session_config = session_config.set(
"datafusion.execution.batch_size",
&datafusion_common::ScalarValue::UInt64(Some(1024)),
);
session_config = session_config.set(
"datafusion.execution.skip_partial_aggregation_probe_rows_threshold",
&datafusion_common::ScalarValue::UInt64(Some(50)),
);
session_config = session_config.set(
"datafusion.execution.skip_partial_aggregation_probe_ratio_threshold",
&datafusion_common::ScalarValue::Float64(Some(0.8)),
);
task_ctx = task_ctx.with_session_config(session_config);
let task_ctx = Arc::new(task_ctx);
let group_expr = vec![(col("group_col", &schema)?, "group_col".to_string())];
let aggr_expr = vec![Arc::new(
AggregateExprBuilder::new(count_udaf(), vec![col("value_col", &schema)?])
.schema(Arc::clone(&schema))
.alias("count_value")
.build()?,
)];
let exec = TestMemoryExec::try_new(&input_partitions, Arc::clone(&schema), None)?;
let exec = Arc::new(TestMemoryExec::update_cache(&Arc::new(exec)));
let aggregate_exec = AggregateExec::try_new(
AggregateMode::Partial,
PhysicalGroupBy::new_single(group_expr),
aggr_expr,
vec![None],
exec,
Arc::clone(&schema),
)?;
let mut stream =
PartialHashAggregateStream::new(&aggregate_exec, &Arc::clone(&task_ctx), 0)?;
let mut results = Vec::new();
while let Some(result) = stream.next().await {
let batch = result?;
results.push(batch);
}
let mut total_output_groups = 0;
for batch in &results {
total_output_groups += batch.num_rows();
}
assert_eq!(
total_output_groups, num_groups,
"Unexpected number of groups",
);
Ok(())
}
#[tokio::test]
async fn test_partial_hash_stream_skip_aggregation_probe_not_locked_until_skip()
-> Result<()> {
let schema = Arc::new(Schema::new(vec![
Field::new("group_col", DataType::Int32, false),
Field::new("value_col", DataType::Int32, false),
]));
let probe_rows_threshold = 100;
let probe_ratio_threshold = 0.8;
let batch1_rows = 100;
let batch1_groups = 10;
let mut group_ids_batch1 = Vec::new();
for i in 0..batch1_rows {
group_ids_batch1.push((i % batch1_groups) as i32);
}
let values_batch1: Vec<i32> = vec![1; batch1_rows];
let batch1 = RecordBatch::try_new(
Arc::clone(&schema),
vec![
Arc::new(Int32Array::from(group_ids_batch1)),
Arc::new(Int32Array::from(values_batch1)),
],
)?;
let batch2_rows = 360;
let batch2_groups = 360;
let group_ids_batch2: Vec<i32> = (batch1_groups..(batch1_groups + batch2_groups))
.map(|x| x as i32)
.collect();
let values_batch2: Vec<i32> = vec![1; batch2_rows];
let batch2 = RecordBatch::try_new(
Arc::clone(&schema),
vec![
Arc::new(Int32Array::from(group_ids_batch2)),
Arc::new(Int32Array::from(values_batch2)),
],
)?;
let batch3_rows = 100;
let batch3_groups = 100;
let batch3_start_group = batch1_groups + batch2_groups;
let group_ids_batch3: Vec<i32> = (batch3_start_group
..(batch3_start_group + batch3_groups))
.map(|x| x as i32)
.collect();
let values_batch3: Vec<i32> = vec![1; batch3_rows];
let batch3 = RecordBatch::try_new(
Arc::clone(&schema),
vec![
Arc::new(Int32Array::from(group_ids_batch3)),
Arc::new(Int32Array::from(values_batch3)),
],
)?;
let input_partitions = vec![vec![batch1, batch2, batch3]];
let runtime = RuntimeEnvBuilder::default().build_arc()?;
let mut task_ctx = TaskContext::default().with_runtime(runtime);
let mut session_config = task_ctx.session_config().clone();
session_config = session_config.set(
"datafusion.execution.skip_partial_aggregation_probe_rows_threshold",
&datafusion_common::ScalarValue::UInt64(Some(probe_rows_threshold)),
);
session_config = session_config.set(
"datafusion.execution.skip_partial_aggregation_probe_ratio_threshold",
&datafusion_common::ScalarValue::Float64(Some(probe_ratio_threshold)),
);
task_ctx = task_ctx.with_session_config(session_config);
let task_ctx = Arc::new(task_ctx);
let group_expr = vec![(col("group_col", &schema)?, "group_col".to_string())];
let aggr_expr = vec![Arc::new(
AggregateExprBuilder::new(count_udaf(), vec![col("value_col", &schema)?])
.schema(Arc::clone(&schema))
.alias("count_value")
.build()?,
)];
let exec = TestMemoryExec::try_new(&input_partitions, Arc::clone(&schema), None)?;
let exec = Arc::new(TestMemoryExec::update_cache(&Arc::new(exec)));
let aggregate_exec = AggregateExec::try_new(
AggregateMode::Partial,
PhysicalGroupBy::new_single(group_expr),
aggr_expr,
vec![None],
exec,
Arc::clone(&schema),
)?;
let mut stream =
PartialHashAggregateStream::new(&aggregate_exec, &Arc::clone(&task_ctx), 0)?;
let mut results = Vec::new();
while let Some(result) = stream.next().await {
let batch = result?;
results.push(batch);
}
let metrics = aggregate_exec.metrics().unwrap();
let skipped_rows = metrics
.sum_by_name("skipped_aggregation_rows")
.map(|m| m.as_usize())
.unwrap_or(0);
assert_eq!(
skipped_rows, batch3_rows,
"Expected batch 3's rows ({batch3_rows}) to be skipped",
);
Ok(())
}
}