Repository navigation
Description
Describe the bug
An order-preserving hash RepartitionExec fails with ResourcesExhausted on RepartitionExec[Merge N] reservations, which report can spill: false. The query returns only 7000 rows. The merge reservations grow until they fill the pool, whatever the pool size.
Commit: 34eb6dc (main).
To Reproduce
Run with datafusion-cli -m 200M -f repro.sql:
COPY (SELECT value % 1000 AS k, repeat('x', 100) || (value % 7)::varchar AS s, value AS v
FROM generate_series(1, 2000000) ORDER BY k)
TO 'data.parquet' STORED AS PARQUET;
SET datafusion.execution.target_partitions = 8;
CREATE EXTERNAL TABLE t STORED AS PARQUET LOCATION 'data.parquet' WITH ORDER (k ASC);
SELECT count(*) FROM (SELECT k, s, count(*) FROM t GROUP BY k, s ORDER BY k);The file is 5.6 MB. Error:
Resources exhausted: Additional allocation failed for RepartitionExec[Merge 6] with top memory consumers (across reservations) as:
RepartitionExec[Merge 3]#44(can spill: false) consumed 25.5 MB, peak 25.5 MB,
RepartitionExec[Merge 5]#43(can spill: false) consumed 25.3 MB, peak 25.3 MB,
RepartitionExec[Merge 0]#35(can spill: false) consumed 25.2 MB, peak 25.2 MB.
Error: Failed to allocate additional 640.0 B for RepartitionExec[Merge 6] with 24.8 MB already allocated for this reservation - 6.0 B remain available for the total memory pool: greedy(used: 200.0 MB, pool_size: 200.0 MB)
With -m 50M each merge holds about 6 MB. With -m 20M each holds about 2.5 MB. The query passes at -m 400M.
Plan (relevant part):
AggregateExec: mode=FinalPartitioned, gby=[k, s]
RepartitionExec: partitioning=Hash([k@0, s@1], 8), input_partitions=8, preserve_order=true, sort_exprs=k@0 ASC NULLS LAST
AggregateExec: mode=Partial, gby=[k, s], group_clustering_mode=Partial([0])
DataSourceExec: file_groups=8, format=parquet
EXPLAIN ANALYZE metrics without a limit:
| operator | output_rows | output_batches | output_bytes |
|---|---|---|---|
| DataSourceExec | 2.00 M | 245 | 46.1 MB |
| AggregateExec Partial | 7.01 K | 246 | 42.7 MB |
| RepartitionExec | 7.01 K | 8 | 330.1 MB |
If s is Utf8 (arrow_cast(s, 'Utf8')), the partial aggregate reports 1.7 MB, the repartition reports 828 KB, and the query passes at -m 10M.
Expected behavior
The query succeeds with a small limit. The data that the merge must hold is a few KB per output partition.
Additional context
Three effects combine.
- The partial aggregate emits
Utf8Viewbatches of about 28 rows that report about 174 KB each. The view data buffers dominate that size. - The hash partitioner uses
take_arrays(). Forlet columns = take_arrays(batch.columns(), &indices_array, None)?; Utf8Viewcolumns,takecopies only the views and shares the data buffers. Each of the 8 output batches reports the full buffer size. The sum over all merges is about 8 times the real memory. BatchBuilderkeeps every input batch that has a row inindicesuntil it builds an output batch (,datafusion/datafusion/physical-plan/src/sorts/builder.rs
Lines 89 to 95 in 34eb6dc
pub fn push_batch(&mut self, stream_idx: usize, batch: RecordBatch) -> Result<usize> { let size = get_record_batch_memory_size(&batch); self.batches_mem_used += size; // Only request additional memory from the pool when actual batch // usage exceeds the current reservation (which may include // pre-reserved bytes from sort_spill_reservation_bytes). try_grow_reservation_to_at_least(&mut self.reservation, self.batches_mem_used)?; ). Each output partition has fewer thandatafusion/datafusion/physical-plan/src/sorts/builder.rs
Lines 163 to 170 in 34eb6dc
// Remove consumed indices, keeping any remaining for the next call. self.indices.drain(..rows_to_emit); if self.indices.is_empty() { self.retain_cursor_batches(); } else { self.retain_live_batches(); } batch_sizerows, so the merge never emits before the end and keeps all of its input batches. The reservation grows with the number of input batches. It is not bounded byN inputs x batch size.
The merge reservation is created at
datafusion/datafusion/physical-plan/src/repartition/mod.rs
Lines 1857 to 1869 in 34eb6dc
| let merge_reservation = | |
| MemoryConsumer::new(format!("{name}[Merge {partition}]")) | |
| .register(context.memory_pool()); | |
| StreamingMergeBuilder::new() | |
| .with_streams(input_streams) | |
| .with_schema(schema_captured) | |
| .with_expressions(&sort_exprs.unwrap()) | |
| .with_metrics(BaselineMetrics::new(&metrics, partition)) | |
| .with_batch_size(context.session_config().batch_size()) | |
| .with_fetch(fetch) | |
| .with_reservation(merge_reservation) | |
| .with_spill_manager(spill_manager) | |
| .build() |
with_can_spill(true). A refused try_grow in BatchBuilder::push_batch fails the query. The repartition channel spill path (datafusion/datafusion/physical-plan/src/repartition/mod.rs
Lines 218 to 230 in 34eb6dc
| /// Used after [`OutputChannel::coalesce`] for performance purposes. | |
| async fn send(&mut self, batch: RecordBatch) -> Result<(), SendError<MaybeBatch>> { | |
| let size = batch.get_array_memory_size(); | |
| // Decide the payload outside of any await: never hold a MutexGuard | |
| // across an await point. | |
| let (payload, is_memory_batch) = { | |
| match self.reservation.try_grow(size) { | |
| Ok(_) => (Ok(RepartitionBatch::Memory(batch)), true), | |
| Err(_) => match self.spill_writer.push_batch(&batch) { | |
| Ok(()) => (Ok(RepartitionBatch::Spilled), false), | |
| Err(err) => (Err(err), false), | |
| }, |
SortPreservingMergeExec uses the same StreamingMergeBuilder and BatchBuilder, so it has the same retention behavior.
Possible fixes:
- In
BatchBuilder, whenpush_batchcannot grow the reservation, emit the rows inindicesas a short batch, then release the dead batches and retry. This bounds the retained batches to about one per input stream. - Avoid the shared-buffer over-count: compact (
gc) view arrays aftertakein the hash partitioner, or account shared buffers once.
Activity
Kristofferjoha commentedon Nov 11, 2026
take
Kristofferjoha commentedon Nov 12, 2026
@adriangb Dug into where the ~174 KB batches come from.
ByteViewGroupValueBuilder::take_ncopies the in-progress buffer from the start on every partial emit and never trims it, so each batch drags along the strings of all the batches before it. #27360 fixes that. With it the repro passes at "-m 10M", and the partial aggregate output drops from 42.6 MB to roughly 1 MB (repartition goes from 330 MB to 5.5 MB). That only fixes the trigger though. The merge still holds every input batch until it has a full output batch, and the repartition still charges each partition for the whole source batch's buffers. For the merge part, do you want to go with the early emit in BatchBuilder, or try settingwith_max_batch_byteson the repartition merge first? Looks like the sort spill merge already uses it, but I haven't tried it here.