Skip to main content

risingwave_storage/hummock/compactor/iceberg_compaction/
memory.rs

1// Copyright 2026 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::mem::size_of;
16
17use iceberg::scan::FileScanTask;
18use iceberg::spec::{DataContentType, FormatVersion, PrimitiveType, Schema, Type};
19use iceberg_compaction_core::compaction::CompactionPlan;
20
21/// `DataFusion`'s `datafusion.execution.sort_spill_reservation_bytes` default.
22///
23/// Each `ExternalSorter` partition resizes a merge reservation to this value before it sorts a
24/// single row. Account for it even when `DataFusion` uses its default unbounded memory pool.
25const DATAFUSION_SORT_MERGE_RESERVATION_BYTES: usize = 10 * 1024 * 1024;
26
27/// Fixed task-context, operator, and allocator overhead observed for small streaming plans.
28const DATAFUSION_RUNTIME_FIXED_BYTES: usize = 640 * 1024;
29
30/// Upper bound for decoded Arrow data retained beyond the explicitly accounted record batches.
31const DATAFUSION_STREAMING_DECODED_WINDOW_BYTES: usize = 16 * 1024 * 1024;
32
33/// Output writer memory retained across input batches.
34const DATAFUSION_WRITER_WINDOW_BYTES: usize = 10 * 1024 * 1024;
35
36const LARGE_SORT_THRESHOLD_BYTES: usize = 64 * 1024 * 1024;
37const SORT_TEMPORARY_HEADROOM_BYTES: usize = 32 * 1024 * 1024;
38const HEAP_FIXED_HEADROOM_BYTES: usize = 256 * 1024;
39
40/// Compressed-to-decoded fallback used when the schema contains variable-width fields.
41const COMPRESSED_TO_DECODED_INFLATION: usize = 4;
42
43/// At this scan concurrency, prefetched files and decoded buffers measurably overlap at peak.
44const STREAMING_PREFETCH_DECODE_OVERLAP_MIN_ACTIVE_FILES: usize = 4;
45
46/// Compressed-size estimate for decoded equality-delete value buffers.
47const EQUALITY_DELETE_INFLATION: usize = 8;
48
49/// Compressed-size estimate for decoded position-delete value buffers.
50const POSITION_DELETE_INFLATION: usize = 5;
51
52/// Per-row hash table and join bookkeeping floor for `DataFusion`'s retained `HashJoinInput`.
53const HASH_JOIN_ROW_OVERHEAD_BYTES: usize = 40;
54
55/// Per-row Arrow and hashing overhead for the two position-delete join keys.
56const POSITION_DELETE_KEY_OVERHEAD_BYTES: usize = 32;
57
58/// Estimates the peak heap bytes of one compaction plan for scheduler admission.
59pub fn estimate_plan_memory(
60    plan: &CompactionPlan,
61    schema: &Schema,
62    format_version: FormatVersion,
63    max_record_batch_rows: usize,
64    enable_prefetch: bool,
65    requires_sort: bool,
66) -> usize {
67    let data_files = &plan.file_group.data_files;
68    let compressed_input_bytes = sum_file_sizes(data_files.iter());
69    let prefetch = if enable_prefetch {
70        // `iceberg-compaction-core` stages one complete file per active scan partition in a
71        // memory-backed `FileIO`. The decoded batches are accounted separately below, so the
72        // staged compressed bytes must not be multiplied by another decoded-copy factor.
73        estimate_prefetch_bytes(plan, format_version)
74    } else {
75        0
76    };
77
78    let mut record_count = 0usize;
79    let mut has_complete_record_counts = true;
80    for task in data_files {
81        match task.record_count {
82            Some(count) => {
83                record_count = record_count.saturating_add(count as usize);
84            }
85            None => has_complete_record_counts = false,
86        }
87    }
88
89    let schema_row_width = estimated_schema_row_width(schema);
90    let hidden_row_width = hidden_row_width(plan, format_version);
91    let (batch_row_width, decoded_input_bytes, decoded_output_bytes) =
92        if let (Some(schema_row_width), true) = (schema_row_width, has_complete_record_counts) {
93            let row_width = schema_row_width.saturating_add(hidden_row_width);
94            (
95                row_width,
96                row_width.saturating_mul(record_count),
97                schema_row_width.saturating_mul(record_count),
98            )
99        } else {
100            let fallback_row_count = record_count.max(1);
101            let fallback_row_width = compressed_input_bytes
102                .checked_div(fallback_row_count)
103                .unwrap_or_default()
104                .saturating_mul(COMPRESSED_TO_DECODED_INFLATION)
105                .max(1);
106            // Keep whole-input inflation separate from batch sizing. Missing counts must not
107            // turn the entire compressed input into one synthetic row-sized batch.
108            let batch_row_width = fallback_row_width.saturating_add(hidden_row_width);
109            let decoded_output_bytes =
110                compressed_input_bytes.saturating_mul(COMPRESSED_TO_DECODED_INFLATION);
111            let decoded_input_bytes =
112                decoded_output_bytes.saturating_add(hidden_row_width.saturating_mul(record_count));
113            (batch_row_width, decoded_input_bytes, decoded_output_bytes)
114        };
115
116    // Equality deletes are broadcast and fully materialized by the merge-on-read plan.
117    let equality_delete_bytes = estimate_equality_delete_join_bytes(plan);
118
119    // Pre-V3 position deletes build a full anti-join hash table. V3 deletion vectors do not.
120    let position_delete_raw_bytes = if format_version < FormatVersion::V3 {
121        sum_file_sizes(plan.file_group.position_delete_files.iter())
122    } else {
123        0
124    };
125
126    let position_delete_join_bytes = if position_delete_raw_bytes > 0 {
127        let compressed_estimate = position_delete_raw_bytes
128            .saturating_mul(POSITION_DELETE_INFLATION)
129            .saturating_add(estimate_position_delete_join_overhead_bytes(plan));
130        // HashJoin retains both the collected build-side batches and its hash table for the whole
131        // probe stream, so both fully overlap a SortExec consuming and buffering the join output.
132        compressed_input_bytes.saturating_add(compressed_estimate)
133    } else {
134        0
135    };
136
137    let executor_parallelism = plan.recommended_executor_parallelism().max(1);
138    let output_parallelism = plan.recommended_output_parallelism().max(1);
139    let sort_workspace_bytes = if requires_sort {
140        // SortExec preserves and concurrently executes every output partition, so its tracked
141        // workspace covers the full decoded input rather than a single partition.
142        decoded_output_bytes
143    } else {
144        0
145    };
146    // Every sorted output partition pins a merge reservation before reading data.
147    let sort_merge_headroom_bytes = if requires_sort {
148        DATAFUSION_SORT_MERGE_RESERVATION_BYTES.saturating_mul(output_parallelism)
149    } else {
150        0
151    };
152    let batch_allocation_bytes = batch_row_width.saturating_mul(max_record_batch_rows);
153    let concurrent_batch_allocation_bytes =
154        batch_allocation_bytes.saturating_mul(executor_parallelism);
155    // Every scan partition has its own buffer. Streaming plans keep two batches in flight per
156    // partition; sorted plans keep one before handing it to the sorter.
157    let batch_overhead_bytes = if requires_sort {
158        concurrent_batch_allocation_bytes
159    } else {
160        concurrent_batch_allocation_bytes.saturating_mul(2)
161    };
162    let (retained_operator_bytes, estimated_datafusion_peak_bytes) = if requires_sort {
163        let retained_operator_bytes = sort_merge_headroom_bytes
164            .saturating_add(equality_delete_bytes)
165            .saturating_add(position_delete_join_bytes)
166            .saturating_add(prefetch)
167            .saturating_add(batch_overhead_bytes);
168        let sort_peak = sort_workspace_bytes
169            .saturating_add(sort_merge_headroom_bytes)
170            .saturating_add(equality_delete_bytes)
171            .saturating_add(position_delete_join_bytes)
172            .saturating_add(prefetch);
173        (
174            retained_operator_bytes,
175            sort_peak.max(retained_operator_bytes),
176        )
177    } else {
178        let data_file_count = data_files.len().max(1);
179        let active_files = executor_parallelism.min(data_files.len());
180        // Bound the extra decoder window from two directions: one partition's proportional share
181        // of the decoded input, and the active files' fair share with 25% pipeline headroom.
182        let per_partition_share = decoded_input_bytes
183            .checked_div(active_files.max(1))
184            .unwrap_or(decoded_input_bytes);
185        let active_file_share = decoded_input_bytes
186            .checked_div(data_file_count)
187            .unwrap_or(decoded_input_bytes)
188            .saturating_mul(active_files);
189        let active_file_share_with_headroom =
190            active_file_share.saturating_add(active_file_share / 4);
191        let decoded_in_flight = per_partition_share
192            .min(active_file_share_with_headroom)
193            .min(DATAFUSION_STREAMING_DECODED_WINDOW_BYTES);
194        let overlap_input_threshold =
195            DATAFUSION_STREAMING_DECODED_WINDOW_BYTES / COMPRESSED_TO_DECODED_INFLATION;
196        let scan_buffer_bytes = if active_files
197            >= STREAMING_PREFETCH_DECODE_OVERLAP_MIN_ACTIVE_FILES
198            && compressed_input_bytes >= overlap_input_threshold
199        {
200            prefetch.saturating_add(decoded_in_flight)
201        } else {
202            prefetch.max(decoded_in_flight)
203        };
204        let common_bytes = scan_buffer_bytes
205            .saturating_add(batch_overhead_bytes)
206            .saturating_add(equality_delete_bytes);
207        let retained_operator_bytes = common_bytes
208            .saturating_add(position_delete_join_bytes)
209            .max(batch_allocation_bytes)
210            .max(DATAFUSION_RUNTIME_FIXED_BYTES);
211        (retained_operator_bytes, retained_operator_bytes)
212    };
213
214    let data_file_count = data_files.len().max(1);
215    let active_data_files = executor_parallelism.min(data_files.len());
216    let active_decoded_bytes = decoded_input_bytes
217        .checked_div(data_file_count)
218        .unwrap_or(decoded_input_bytes)
219        .saturating_mul(active_data_files);
220    let active_input_bytes = estimate_prefetch_bytes(plan, format_version);
221    let total_batches = record_count
222        .saturating_add(max_record_batch_rows.saturating_sub(1))
223        .checked_div(max_record_batch_rows)
224        .unwrap_or_default();
225    let batch_overlap =
226        (total_batches as f64 / executor_parallelism.saturating_mul(8) as f64).min(1.0);
227    let batch_heap_bytes = (batch_overhead_bytes as f64 * batch_overlap) as usize;
228    // OpenDAL S3 buffers the active compressed input while Parquet decoding and scan batches
229    // overlap. This phase is independent from DataFusion's logical pool reservations.
230    let scan_heap_bytes = active_input_bytes
231        .saturating_add(active_input_bytes.min(DATAFUSION_STREAMING_DECODED_WINDOW_BYTES))
232        .saturating_add(active_decoded_bytes.min(DATAFUSION_STREAMING_DECODED_WINDOW_BYTES))
233        .saturating_add(batch_heap_bytes);
234    let writer_heap_bytes = if schema_row_width.is_some() {
235        decoded_output_bytes.saturating_mul(3) / 2
236    } else {
237        let output_scale = output_parallelism.min(4) as f64 / 4.0;
238        let large_single_scan_scale = if executor_parallelism == 1 {
239            (active_input_bytes as f64 / DATAFUSION_STREAMING_DECODED_WINDOW_BYTES as f64).min(1.0)
240        } else {
241            0.0
242        };
243        ((decoded_output_bytes / 2).min(DATAFUSION_WRITER_WINDOW_BYTES) as f64
244            * output_scale.max(large_single_scan_scale)) as usize
245    }
246    .min(DATAFUSION_WRITER_WINDOW_BYTES);
247    let streaming_heap_bytes = scan_heap_bytes.saturating_add(writer_heap_bytes);
248
249    // Delete joins retain hidden probe columns while the build side remains materialized.
250    let hidden_total_bytes = hidden_row_width.saturating_mul(record_count);
251    let position_delete_records = plan
252        .file_group
253        .position_delete_files
254        .iter()
255        .filter_map(|task| task.record_count)
256        .fold(0u64, u64::saturating_add) as usize;
257    let join_heap_bytes = if position_delete_records > 0 {
258        let delete_ratio = position_delete_records as f64 / record_count.max(1) as f64;
259        let overlap_factor = 0.6 + 0.3 * delete_ratio.min(1.0);
260        (hidden_total_bytes as f64 * overlap_factor) as usize
261            + position_delete_raw_bytes.saturating_mul(8)
262    } else if !plan.file_group.equality_delete_files.is_empty() {
263        hidden_total_bytes.saturating_add(equality_delete_bytes)
264    } else {
265        0
266    };
267    // Large sorts retain their full decoded input. Smaller sorts release part of each partition as
268    // merge runs, based on the S3 heap profiles used to calibrate this estimate.
269    let sorted_decoded_bytes = if decoded_output_bytes > 64 * 1024 * 1024 {
270        decoded_output_bytes
271    } else {
272        decoded_output_bytes.saturating_mul(2) / 3
273    };
274    let sorted_heap_bytes = active_input_bytes
275        .saturating_add(active_input_bytes.min(DATAFUSION_STREAMING_DECODED_WINDOW_BYTES))
276        .saturating_add(sorted_decoded_bytes);
277    let execution_heap_bytes = if requires_sort {
278        sorted_heap_bytes
279    } else {
280        streaming_heap_bytes
281    };
282    let join_phase_bytes = scan_heap_bytes.saturating_add(join_heap_bytes);
283    let join_phase_bytes = if requires_sort {
284        join_phase_bytes.saturating_mul(3) / 4
285    } else {
286        join_phase_bytes
287    };
288    let large_sorted = requires_sort && decoded_output_bytes > LARGE_SORT_THRESHOLD_BYTES;
289    let fixed_heap_peak_bytes = if large_sorted {
290        scan_heap_bytes.max(join_phase_bytes)
291    } else {
292        execution_heap_bytes.max(join_phase_bytes)
293    };
294    let datafusion_peak_with_headroom = estimated_datafusion_peak_bytes
295        .saturating_add(batch_allocation_bytes)
296        .saturating_add(if requires_sort {
297            DATAFUSION_STREAMING_DECODED_WINDOW_BYTES
298        } else {
299            0
300        });
301    // Preserve a progress-phase floor in the estimate. With an unbounded pool this is not an
302    // allocation limit; it accounts for decoded input that can overlap while the pipeline fills.
303    let datafusion_progress_peak_bytes = if large_sorted {
304        retained_operator_bytes
305            .saturating_add(active_decoded_bytes.saturating_mul(2))
306            .saturating_add(DATAFUSION_STREAMING_DECODED_WINDOW_BYTES)
307            .saturating_add(batch_allocation_bytes)
308    } else if requires_sort {
309        datafusion_peak_with_headroom
310    } else {
311        retained_operator_bytes.saturating_add(batch_allocation_bytes)
312    };
313    let estimated_datafusion_peak_bytes =
314        datafusion_peak_with_headroom.max(datafusion_progress_peak_bytes);
315
316    let heap_peak_bytes = if large_sorted {
317        fixed_heap_peak_bytes.max(
318            retained_operator_bytes
319                .saturating_add(estimated_datafusion_peak_bytes / 2)
320                .saturating_add(SORT_TEMPORARY_HEADROOM_BYTES),
321        )
322    } else if requires_sort {
323        fixed_heap_peak_bytes.max(estimated_datafusion_peak_bytes.saturating_mul(3) / 4)
324    } else {
325        fixed_heap_peak_bytes
326    }
327    .saturating_add(HEAP_FIXED_HEADROOM_BYTES);
328
329    heap_peak_bytes.saturating_add(heap_peak_bytes / 50)
330}
331
332fn estimate_prefetch_bytes(plan: &CompactionPlan, format_version: FormatVersion) -> usize {
333    let concurrency = plan.recommended_executor_parallelism().max(1);
334    let data = estimate_provider_prefetch(
335        plan.file_group.data_files.iter().filter(|task| {
336            format_version < FormatVersion::V3
337                || !task
338                    .deletes
339                    .iter()
340                    .any(|delete| delete.file_type == DataContentType::PositionDeletes)
341        }),
342        concurrency,
343    );
344    // Delete files that get their own table provider are scanned -- and therefore prefetched --
345    // concurrently with the data scan, because `DatafusionTableRegister` passes the same
346    // `enable_prefetch` flag to every provider it builds. From V3 on, position deletes are
347    // deletion vectors attached to the data task instead of a registered table, so only the
348    // pre-V3 position-delete provider counts here.
349    let position_deletes = if format_version < FormatVersion::V3 {
350        estimate_provider_prefetch(plan.file_group.position_delete_files.iter(), concurrency)
351    } else {
352        0
353    };
354    let equality_deletes =
355        estimate_provider_prefetch(plan.file_group.equality_delete_files.iter(), concurrency);
356
357    data.saturating_add(position_deletes)
358        .saturating_add(equality_deletes)
359}
360
361fn estimate_provider_prefetch<'a>(
362    tasks: impl Iterator<Item = &'a FileScanTask>,
363    concurrency: usize,
364) -> usize {
365    let mut file_sizes = tasks
366        .map(|task| task.file_size_in_bytes as usize)
367        .collect::<Vec<_>>();
368    file_sizes.sort_unstable_by(|left, right| right.cmp(left));
369    file_sizes
370        .into_iter()
371        .take(concurrency)
372        .fold(0usize, usize::saturating_add)
373}
374
375fn sum_file_sizes<'a>(tasks: impl Iterator<Item = &'a FileScanTask>) -> usize {
376    tasks
377        .map(|task| task.file_size_in_bytes as usize)
378        .fold(0usize, usize::saturating_add)
379}
380
381fn estimate_position_delete_join_overhead_bytes(plan: &CompactionPlan) -> usize {
382    let Some(record_count) = complete_record_count(&plan.file_group.position_delete_files) else {
383        return 0;
384    };
385
386    HASH_JOIN_ROW_OVERHEAD_BYTES
387        .saturating_add(POSITION_DELETE_KEY_OVERHEAD_BYTES)
388        .saturating_mul(record_count)
389}
390
391fn estimate_equality_delete_join_bytes(plan: &CompactionPlan) -> usize {
392    let compressed_estimate = sum_file_sizes(plan.file_group.equality_delete_files.iter())
393        .saturating_mul(EQUALITY_DELETE_INFLATION);
394    let row_overhead = complete_record_count(&plan.file_group.equality_delete_files)
395        .unwrap_or_default()
396        .saturating_mul(HASH_JOIN_ROW_OVERHEAD_BYTES);
397
398    compressed_estimate.saturating_add(row_overhead)
399}
400
401fn complete_record_count(tasks: &[FileScanTask]) -> Option<usize> {
402    tasks.iter().try_fold(0usize, |total, task| {
403        let count = usize::try_from(task.record_count?).unwrap_or(usize::MAX);
404        Some(total.saturating_add(count))
405    })
406}
407
408fn position_delete_row_width(plan: &CompactionPlan) -> usize {
409    let max_data_path_width = plan
410        .file_group
411        .data_files
412        .iter()
413        .map(|task| task.data_file_path.len())
414        .max()
415        .unwrap_or_default();
416    max_data_path_width
417        .saturating_add(size_of::<i32>())
418        .saturating_add(size_of::<i64>())
419}
420
421fn hidden_row_width(plan: &CompactionPlan, format_version: FormatVersion) -> usize {
422    // Match DataFusionTaskContextBuilder: equality deletes add an i64 sequence number, while
423    // pre-V3 position deletes add the data path Utf8 array and an i64 row position.
424    let sequence_number_width = if !plan.file_group.equality_delete_files.is_empty() {
425        size_of::<i64>()
426    } else {
427        0
428    };
429    let position_delete_width = if format_version < FormatVersion::V3
430        && !plan.file_group.position_delete_files.is_empty()
431    {
432        position_delete_row_width(plan)
433    } else {
434        0
435    };
436
437    sequence_number_width.saturating_add(position_delete_width)
438}
439
440fn estimated_schema_row_width(schema: &Schema) -> Option<usize> {
441    let mut width = 0usize;
442    for field in schema.as_struct().fields() {
443        let Type::Primitive(primitive) = field.field_type.as_ref() else {
444            return None;
445        };
446        let field_width = primitive_width(primitive)?;
447        width = width.saturating_add(field_width);
448    }
449    (width > 0).then_some(width)
450}
451
452fn primitive_width(primitive: &PrimitiveType) -> Option<usize> {
453    let width = match primitive {
454        PrimitiveType::Boolean => 1,
455        PrimitiveType::Int | PrimitiveType::Float | PrimitiveType::Date => 4,
456        PrimitiveType::Long
457        | PrimitiveType::Double
458        | PrimitiveType::Time
459        | PrimitiveType::Timestamp
460        | PrimitiveType::Timestamptz
461        | PrimitiveType::TimestampNs
462        | PrimitiveType::TimestamptzNs => 8,
463        PrimitiveType::Decimal { .. } | PrimitiveType::Uuid => 16,
464        PrimitiveType::Fixed(size) => *size as usize,
465        PrimitiveType::String | PrimitiveType::Binary => return None,
466    };
467    Some(width)
468}