Skip to content

Commit 6450a99

Browse files
msafonovclaude
andcommitted
fix: take a hash aggregate's groups out before their keys overflow 32-bit offsets
A hash aggregate interns the bytes of every distinct Utf8 or Binary group key into one buffer addressed by i32 offsets, and only empties it when the memory pool refuses the table. With a large enough pool a task with more than 2 GiB of distinct key bytes fails with "offset overflow, buffer size > 2147483647" (multi-column keys) or panics in ArrowBytesMap (one key column). Stage 308 of ads_manual_product_rank_cohorts (SELECT DISTINCT date, product_id, country over 5.8 billion feed rows hash-partitioned by date into 16 tasks, ~167 million groups each) failed this way 24 times on 2026-10-05; a retry passed only when the pool spilled first. DataFusion 55.1.0 and its main branch do the same (apache/datafusion#23694, #24704). Each group values implementation now tells whether a batch's keys still fit the 32-bit offsets of what it holds: the byte builders by their buffer length plus the batch's value bytes, the single-column map by the bytes it interned, and the row-format ones, whose decoded offsets never exceed their encoded size, by that size plus the batch's largest offset extent at any depth. When a batch would not fit, the table takes out every group so far before interning it: a partial aggregate emits them as it does under memory pressure, a final or single aggregate sorts and spills them for the merge replay, and a partial-reduce aggregate emits them. Tests: SELECT DISTINCT over 3 GiB of distinct 1 MiB keys in a partial and a final aggregate on (Int32, Utf8) and a partial on Utf8 alone failed with the production error (and the ArrowBytesMap panic) and now return every group. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1 parent d783931 commit 6450a99

15 files changed

Lines changed: 370 additions & 25 deletions

File tree

‎native/core/src/execution/jni_api.rs‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4114,3 +4114,114 @@ mod native_sort_spill_tests {
41144114
}
41154115
}
41164116
}
4117+
4118+
#[cfg(test)]
4119+
mod aggregate_offset_overflow_tests {
4120+
use arrow::array::{Int32Array, StringArray};
4121+
use arrow::buffer::{Buffer, OffsetBuffer};
4122+
use arrow::datatypes::{DataType, Field, Schema, SchemaRef};
4123+
use arrow::record_batch::RecordBatch;
4124+
use datafusion::datasource::memory::MemorySourceConfig;
4125+
use datafusion::execution::TaskContext;
4126+
use datafusion::physical_expr::expressions::col;
4127+
use datafusion::physical_plan::aggregates::{AggregateExec, AggregateMode, PhysicalGroupBy};
4128+
use datafusion::physical_plan::ExecutionPlan;
4129+
use datafusion::prelude::SessionConfig;
4130+
use futures::StreamExt;
4131+
use std::sync::Arc;
4132+
4133+
const KEY_BYTES: usize = 1 << 20;
4134+
const ROWS: usize = 1024;
4135+
const BATCHES: usize = 3;
4136+
4137+
/// `(day, product_id)` batches of distinct 1 MiB keys, 1 GiB per batch and 3 GiB in all,
4138+
/// sharing one buffer of pseudo-random letters so that the input itself stays at 1 GiB.
4139+
fn wide_keys(schema: &SchemaRef) -> Vec<RecordBatch> {
4140+
let mut seed = 0x2545_f491_4f6c_dd1du64;
4141+
let letters: Vec<u8> = (0..ROWS * KEY_BYTES + BATCHES)
4142+
.map(|_| {
4143+
seed ^= seed << 13;
4144+
seed ^= seed >> 7;
4145+
seed ^= seed << 17;
4146+
b'a' + (seed % 26) as u8
4147+
})
4148+
.collect();
4149+
let values = Buffer::from_vec(letters);
4150+
(0..BATCHES)
4151+
.map(|batch| {
4152+
let offsets = OffsetBuffer::new(
4153+
(0..=ROWS)
4154+
.map(|row| (batch + row * KEY_BYTES) as i32)
4155+
.collect(),
4156+
);
4157+
RecordBatch::try_new(
4158+
Arc::clone(schema),
4159+
vec![
4160+
Arc::new(Int32Array::from(vec![20_000; ROWS])),
4161+
Arc::new(StringArray::try_new(offsets, values.clone(), None).unwrap()),
4162+
],
4163+
)
4164+
.unwrap()
4165+
})
4166+
.collect()
4167+
}
4168+
4169+
/// `SELECT DISTINCT` of `columns` in one aggregate of `mode`, with nothing but the
4170+
/// 32-bit offsets of its group keys to stop it from holding every group.
4171+
async fn distinct_wide_keys(
4172+
mode: AggregateMode,
4173+
columns: &[&str],
4174+
) -> datafusion::common::Result<usize> {
4175+
let schema = Arc::new(Schema::new(vec![
4176+
Field::new("day", DataType::Int32, false),
4177+
Field::new("product_id", DataType::Utf8, false),
4178+
]));
4179+
let input =
4180+
MemorySourceConfig::try_new_exec(&[wide_keys(&schema)], Arc::clone(&schema), None)?;
4181+
let group_by = PhysicalGroupBy::new_single(
4182+
columns
4183+
.iter()
4184+
.map(|name| Ok((col(name, &schema)?, name.to_string())))
4185+
.collect::<datafusion::common::Result<_>>()?,
4186+
);
4187+
let aggregate = AggregateExec::try_new(mode, group_by, vec![], vec![], input, schema)?;
4188+
let ctx =
4189+
TaskContext::default().with_session_config(SessionConfig::new().with_batch_size(64));
4190+
let mut stream = aggregate.execute(0, Arc::new(ctx))?;
4191+
let mut rows = 0;
4192+
while let Some(batch) = stream.next().await {
4193+
rows += batch?.num_rows();
4194+
}
4195+
Ok(rows)
4196+
}
4197+
4198+
#[tokio::test]
4199+
async fn partial_aggregate_emits_before_group_keys_overflow_32_bit_offsets() {
4200+
assert_eq!(
4201+
distinct_wide_keys(AggregateMode::Partial, &["day", "product_id"])
4202+
.await
4203+
.unwrap(),
4204+
ROWS * BATCHES
4205+
);
4206+
}
4207+
4208+
#[tokio::test]
4209+
async fn final_aggregate_spills_before_group_keys_overflow_32_bit_offsets() {
4210+
assert_eq!(
4211+
distinct_wide_keys(AggregateMode::Final, &["day", "product_id"])
4212+
.await
4213+
.unwrap(),
4214+
ROWS * BATCHES
4215+
);
4216+
}
4217+
4218+
#[tokio::test]
4219+
async fn single_key_aggregate_emits_before_group_keys_overflow_32_bit_offsets() {
4220+
assert_eq!(
4221+
distinct_wide_keys(AggregateMode::Partial, &["product_id"])
4222+
.await
4223+
.unwrap(),
4224+
ROWS * BATCHES
4225+
);
4226+
}
4227+
}

‎native/vendor/datafusion-physical-plan/src/aggregates/aggregate_hash_table/common.rs‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,27 @@ impl<AggrMode> AggregateHashTable<AggrMode> {
185185
/// Each aggregation mode chooses a different `aggregate_fn` according to its
186186
/// semantics. For example, partial aggregation takes raw inputs, and update them
187187
/// into stored partial states, so [`GroupsAccumulator::update_batch`] is used.
188+
///
189+
/// If the batch's group keys could overflow the 32-bit offsets of the stored group
190+
/// values, every group accumulated so far is first taken out as by
191+
/// [`Self::take_state_batch`] and returned, for the caller to emit or spill.
188192
pub(super) fn aggregate_batch_inner(
189193
&mut self,
190194
batch: &RecordBatch,
191195
aggregate_fn: AggregateBatchFn,
192-
) -> Result<()> {
196+
) -> Result<Option<RecordBatch>> {
193197
let evaluated_batch = self.evaluate_batch(batch)?;
198+
let state = self.state.building();
199+
let taken = if !state.group_values.is_empty()
200+
&& !evaluated_batch
201+
.grouping_set_args
202+
.iter()
203+
.all(|group_values| state.group_values.has_offset_room(group_values))
204+
{
205+
self.take_state_batch()?
206+
} else {
207+
None
208+
};
194209
let state = self.state.building_mut();
195210

196211
let _timer = self.group_by_metrics.aggregation_time.timer();
@@ -210,7 +225,7 @@ impl<AggrMode> AggregateHashTable<AggrMode> {
210225
}
211226
}
212227

213-
Ok(())
228+
Ok(taken)
214229
}
215230

216231
/// Materializes the full output once, then returns it downstream incrementally

‎native/vendor/datafusion-physical-plan/src/aggregates/aggregate_hash_table/final_table.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ impl AggregateHashTable<FinalMarker> {
6666
pub(in crate::aggregates) fn aggregate_batch(
6767
&mut self,
6868
batch: &RecordBatch,
69-
) -> Result<()> {
69+
) -> Result<Option<RecordBatch>> {
7070
self.aggregate_batch_inner(batch, HashAggregateAccumulator::merge_batch)
7171
}
7272

‎native/vendor/datafusion-physical-plan/src/aggregates/aggregate_hash_table/partial_reduce_table.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ impl AggregateHashTable<PartialReduceMarker> {
6060
pub(in crate::aggregates) fn aggregate_batch(
6161
&mut self,
6262
batch: &RecordBatch,
63-
) -> Result<()> {
63+
) -> Result<Option<RecordBatch>> {
6464
self.aggregate_batch_inner(batch, HashAggregateAccumulator::merge_batch)
6565
}
6666

‎native/vendor/datafusion-physical-plan/src/aggregates/aggregate_hash_table/partial_table.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ impl AggregateHashTable<PartialMarker> {
105105
pub(in crate::aggregates) fn aggregate_batch(
106106
&mut self,
107107
batch: &RecordBatch,
108-
) -> Result<()> {
108+
) -> Result<Option<RecordBatch>> {
109109
self.aggregate_batch_inner(batch, HashAggregateAccumulator::update_batch)
110110
}
111111

‎native/vendor/datafusion-physical-plan/src/aggregates/aggregate_hash_table/single_table.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ impl AggregateHashTable<SingleMarker> {
6565
pub(in crate::aggregates) fn aggregate_batch(
6666
&mut self,
6767
batch: &RecordBatch,
68-
) -> Result<()> {
68+
) -> Result<Option<RecordBatch>> {
6969
self.aggregate_batch_inner(batch, HashAggregateAccumulator::update_batch)
7070
}
7171

‎native/vendor/datafusion-physical-plan/src/aggregates/group_values/mod.rs‎

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use arrow::array::types::{
2222
Time64MicrosecondType, Time64NanosecondType, TimestampMicrosecondType,
2323
TimestampMillisecondType, TimestampNanosecondType, TimestampSecondType,
2424
};
25-
use arrow::array::{ArrayRef, downcast_primitive};
25+
use arrow::array::{Array, ArrayRef, AsArray, downcast_primitive};
2626
use arrow::datatypes::{DataType, SchemaRef, TimeUnit};
2727
use datafusion_common::Result;
2828

@@ -115,6 +115,106 @@ pub trait GroupValues: Send {
115115

116116
/// Clear the contents and shrink the capacity to the size of the batch (free up memory usage)
117117
fn clear_shrink(&mut self, num_rows: usize);
118+
119+
/// Returns false if interning `cols` could take a 32-bit offset buffer of the stored
120+
/// group values, or of the arrays they are emitted as, past `i32::MAX`.
121+
fn has_offset_room(&self, _cols: &[ArrayRef]) -> bool {
122+
true
123+
}
124+
}
125+
126+
/// The largest extent `array` addresses in any of its 32-bit offset buffers (`Utf8`,
127+
/// `Binary`, `List` and `Map` at any depth): bytes for the former, entries for the latter.
128+
pub(crate) fn max_offset_extent(array: &dyn Array) -> usize {
129+
max_extent(array, 0, array.len())
130+
}
131+
132+
fn max_extent(array: &dyn Array, start: usize, end: usize) -> usize {
133+
match array.data_type() {
134+
DataType::Utf8 => {
135+
let offsets = array.as_string::<i32>().value_offsets();
136+
(offsets[end] - offsets[start]) as usize
137+
}
138+
DataType::Binary => {
139+
let offsets = array.as_binary::<i32>().value_offsets();
140+
(offsets[end] - offsets[start]) as usize
141+
}
142+
DataType::List(_) => {
143+
let list = array.as_list::<i32>();
144+
let offsets = list.value_offsets();
145+
let (start, end) = (offsets[start] as usize, offsets[end] as usize);
146+
(end - start).max(max_extent(list.values().as_ref(), start, end))
147+
}
148+
DataType::Map(_, _) => {
149+
let map = array.as_map();
150+
let offsets = map.value_offsets();
151+
let (start, end) = (offsets[start] as usize, offsets[end] as usize);
152+
(end - start).max(max_extent(map.entries(), start, end))
153+
}
154+
DataType::LargeList(_) => {
155+
let list = array.as_list::<i64>();
156+
let offsets = list.value_offsets();
157+
max_extent(
158+
list.values().as_ref(),
159+
offsets[start] as usize,
160+
offsets[end] as usize,
161+
)
162+
}
163+
DataType::FixedSizeList(_, size) => {
164+
let size = *size as usize;
165+
max_extent(
166+
array.as_fixed_size_list().values().as_ref(),
167+
start * size,
168+
end * size,
169+
)
170+
}
171+
DataType::Struct(_) => array
172+
.as_struct()
173+
.columns()
174+
.iter()
175+
.map(|column| max_extent(column.as_ref(), start, end))
176+
.max()
177+
.unwrap_or(0),
178+
DataType::Dictionary(_, _) => {
179+
let values = array.as_any_dictionary().values();
180+
max_extent(values.as_ref(), 0, values.len()).saturating_mul(end - start)
181+
}
182+
_ => 0,
183+
}
184+
}
185+
186+
/// True if `data_type` has a 32-bit offset buffer at any depth.
187+
pub(crate) fn has_offset_buffer(data_type: &DataType) -> bool {
188+
match data_type {
189+
DataType::Utf8 | DataType::Binary | DataType::List(_) | DataType::Map(_, _) => {
190+
true
191+
}
192+
DataType::LargeList(field)
193+
| DataType::FixedSizeList(field, _)
194+
| DataType::ListView(field)
195+
| DataType::LargeListView(field) => has_offset_buffer(field.data_type()),
196+
DataType::Struct(fields) => fields
197+
.iter()
198+
.any(|field| has_offset_buffer(field.data_type())),
199+
DataType::Dictionary(_, values) => has_offset_buffer(values),
200+
DataType::RunEndEncoded(_, values) => has_offset_buffer(values.data_type()),
201+
DataType::Union(fields, _) => fields
202+
.iter()
203+
.any(|(_, field)| has_offset_buffer(field.data_type())),
204+
_ => false,
205+
}
206+
}
207+
208+
/// Whether encoded group rows of `held_bytes`, after appending rows decoded from `cols`,
209+
/// still decode into arrays whose 32-bit offsets fit. Every row-format encoding takes at least
210+
/// one byte per value byte and per list entry, so the decoded extent of each offset buffer is
211+
/// at most the encoded size.
212+
pub(crate) fn rows_have_offset_room(held_bytes: usize, cols: &[ArrayRef]) -> bool {
213+
let incoming = cols
214+
.iter()
215+
.map(|col| max_offset_extent(col.as_ref()))
216+
.fold(0usize, usize::saturating_add);
217+
held_bytes.saturating_add(incoming) <= i32::MAX as usize
118218
}
119219

120220
/// Return a specialized implementation of [`GroupValues`] for the given schema.

‎native/vendor/datafusion-physical-plan/src/aggregates/group_values/multi_group_by/bytes.rs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18+
use crate::aggregates::group_values::max_offset_extent;
1819
use crate::aggregates::group_values::multi_group_by::{
1920
GroupColumn, Nulls, nulls_equal_to,
2021
};
@@ -219,6 +220,10 @@ where
219220
}
220221
}
221222

223+
fn large_extent(offsets: &[i64]) -> usize {
224+
(offsets[offsets.len() - 1] - offsets[0]) as usize
225+
}
226+
222227
impl<O> GroupColumn for ByteGroupValueBuilder<O>
223228
where
224229
O: OffsetSizeTrait,
@@ -244,6 +249,19 @@ where
244249
}
245250
}
246251

252+
fn has_offset_room(&self, column: &ArrayRef) -> bool {
253+
let incoming = match column.data_type() {
254+
DataType::LargeUtf8 => {
255+
large_extent(column.as_string::<i64>().value_offsets())
256+
}
257+
DataType::LargeBinary => {
258+
large_extent(column.as_binary::<i64>().value_offsets())
259+
}
260+
_ => max_offset_extent(column.as_ref()),
261+
};
262+
self.buffer.len().saturating_add(incoming) <= self.max_buffer_size
263+
}
264+
247265
fn append_val(&mut self, column: &ArrayRef, row: usize) -> Result<()> {
248266
// Sanity array type
249267
match self.output_type {

‎native/vendor/datafusion-physical-plan/src/aggregates/group_values/multi_group_by/mod.rs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,12 @@ pub trait GroupColumn: Send + Sync {
112112
/// Builds a new array from the first `n` stored rows, shifting the
113113
/// remaining rows to the start of the builder
114114
fn take_n(&mut self, n: usize) -> ArrayRef;
115+
116+
/// Returns false if appending every row of `array` could take a 32-bit offset
117+
/// buffer of this builder, or of the array it builds, past `i32::MAX`
118+
fn has_offset_room(&self, _array: &ArrayRef) -> bool {
119+
true
120+
}
115121
}
116122

117123
/// Determines if the nullability of the existing and new input array can be used
@@ -1165,6 +1171,13 @@ impl<const STREAMING: bool> GroupValues for GroupValuesColumn<STREAMING> {
11651171
group_values_size + self.map_size + self.hashes_buffer.allocated_size()
11661172
}
11671173

1174+
fn has_offset_room(&self, cols: &[ArrayRef]) -> bool {
1175+
self.group_values
1176+
.iter()
1177+
.zip(cols)
1178+
.all(|(group_column, col)| group_column.has_offset_room(col))
1179+
}
1180+
11681181
fn is_empty(&self) -> bool {
11691182
self.len() == 0
11701183
}

‎native/vendor/datafusion-physical-plan/src/aggregates/group_values/multi_group_by/row_backed.rs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
4949
use crate::aggregates::group_values::multi_group_by::GroupColumn;
5050
use crate::aggregates::group_values::row::encode_array_if_necessary;
51+
use crate::aggregates::group_values::{has_offset_buffer, rows_have_offset_room};
5152

5253
use arrow::array::{Array, ArrayRef, BooleanBufferBuilder};
5354
use arrow::datatypes::DataType;
@@ -290,6 +291,14 @@ impl GroupColumn for RowsGroupColumn {
290291
self.row_converter.size() + self.group_values.size()
291292
}
292293

294+
fn has_offset_room(&self, array: &ArrayRef) -> bool {
295+
!has_offset_buffer(&self.output_type)
296+
|| rows_have_offset_room(
297+
self.group_values.size(),
298+
std::slice::from_ref(array),
299+
)
300+
}
301+
293302
fn build(self: Box<Self>) -> ArrayRef {
294303
self.rows_to_array(&self.group_values)
295304
}

0 commit comments

Comments
 (0)