Skip to main content

risingwave_connector/source/iceberg/
planner.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::cmp::{Ordering, Reverse};
16use std::collections::BinaryHeap;
17use std::time::{Duration, Instant};
18
19use anyhow::Context;
20use futures::StreamExt;
21use futures::stream::BoxStream;
22use futures_async_stream::try_stream;
23use iceberg::expr::BoundPredicate;
24use iceberg::scan::{FileScanTask, FileScanTaskDeleteFile};
25use iceberg::spec::{DataContentType, DataFileFormat, SchemaRef};
26use iceberg::table::Table;
27use risingwave_common::bail;
28use risingwave_common::catalog::ColumnCatalog;
29use risingwave_common::metrics::{LabelGuardedHistogram, LabelGuardedIntCounter};
30use risingwave_common::types::{JsonbRef, JsonbVal, ScalarRef};
31use risingwave_pb::batch_plan::iceberg_scan_node::IcebergScanType;
32use serde::{Deserialize, Serialize};
33
34use super::metrics::GLOBAL_ICEBERG_SCAN_METRICS;
35use super::{IcebergFileScanTask, IcebergProperties, IcebergScanOpts, IcebergSplit};
36use crate::error::ConnectorResult;
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum IcebergScanTaskBatchMode {
40    /// Keep the requested parallelism by returning empty task batches when there are fewer files
41    /// than workers. This is used by the distributed RisingWave batch scheduler.
42    PreserveParallelism,
43    /// Avoid empty partitions. This is used by DataFusion's local physical plan.
44    Compact,
45}
46
47pub struct IcebergScanTaskPlanner;
48
49pub type IcebergScanTaskStream = BoxStream<'static, ConnectorResult<FileScanTask>>;
50
51#[derive(Debug, Clone, Default)]
52pub struct IcebergScanProjection {
53    columns: Option<Vec<String>>,
54}
55
56impl IcebergScanProjection {
57    pub fn all() -> Self {
58        Self { columns: None }
59    }
60
61    pub fn from_downstream_columns(columns: Option<&[ColumnCatalog]>) -> Self {
62        Self {
63            columns: columns.map(|columns| {
64                columns
65                    .iter()
66                    .filter_map(|col| {
67                        if col.is_hidden() {
68                            None
69                        } else {
70                            Some(col.name().to_owned())
71                        }
72                    })
73                    .collect()
74            }),
75        }
76    }
77
78    fn apply<'a>(
79        &self,
80        scan_builder: iceberg::scan::TableScanBuilder<'a>,
81    ) -> iceberg::scan::TableScanBuilder<'a> {
82        if let Some(columns) = &self.columns {
83            scan_builder.select(columns)
84        } else {
85            scan_builder.select_all()
86        }
87    }
88}
89
90#[derive(Debug, Clone)]
91pub struct IcebergScanMetricsLabels {
92    source_id: String,
93    source_name: String,
94    table_name: String,
95    snapshots_discovered_total: LabelGuardedIntCounter,
96    list_duration_seconds: LabelGuardedHistogram,
97    delete_files_per_data_file: LabelGuardedHistogram,
98    data_files_discovered_total: LabelGuardedIntCounter,
99    equality_delete_files_discovered_total: LabelGuardedIntCounter,
100    position_delete_files_discovered_total: LabelGuardedIntCounter,
101    list_errors_total: LabelGuardedIntCounter,
102    fetch_errors_total: LabelGuardedIntCounter,
103}
104
105impl IcebergScanMetricsLabels {
106    pub fn new(source_id: String, source_name: String, table_name: String) -> Self {
107        let labels = [
108            source_id.as_str(),
109            source_name.as_str(),
110            table_name.as_str(),
111        ];
112        Self {
113            snapshots_discovered_total: GLOBAL_ICEBERG_SCAN_METRICS
114                .iceberg_source_snapshots_discovered_total
115                .with_guarded_label_values(&labels),
116            list_duration_seconds: GLOBAL_ICEBERG_SCAN_METRICS
117                .iceberg_source_list_duration_seconds
118                .with_guarded_label_values(&labels),
119            delete_files_per_data_file: GLOBAL_ICEBERG_SCAN_METRICS
120                .iceberg_source_delete_files_per_data_file
121                .with_guarded_label_values(&labels),
122            data_files_discovered_total: GLOBAL_ICEBERG_SCAN_METRICS
123                .iceberg_source_files_discovered_total
124                .with_guarded_label_values(&[labels[0], labels[1], labels[2], "data"]),
125            equality_delete_files_discovered_total: GLOBAL_ICEBERG_SCAN_METRICS
126                .iceberg_source_files_discovered_total
127                .with_guarded_label_values(&[labels[0], labels[1], labels[2], "eq_delete"]),
128            position_delete_files_discovered_total: GLOBAL_ICEBERG_SCAN_METRICS
129                .iceberg_source_files_discovered_total
130                .with_guarded_label_values(&[labels[0], labels[1], labels[2], "pos_delete"]),
131            list_errors_total: GLOBAL_ICEBERG_SCAN_METRICS
132                .iceberg_source_scan_errors_total
133                .with_guarded_label_values(&[labels[0], labels[1], labels[2], "list_error"]),
134            fetch_errors_total: GLOBAL_ICEBERG_SCAN_METRICS
135                .iceberg_source_scan_errors_total
136                .with_guarded_label_values(&[labels[0], labels[1], labels[2], "fetch_error"]),
137            source_id,
138            source_name,
139            table_name,
140        }
141    }
142
143    pub fn record_scan_error(&self, error_kind: &str) {
144        match error_kind {
145            "list_error" => self.list_errors_total.inc(),
146            "fetch_error" => self.fetch_errors_total.inc(),
147            _ => tracing::warn!(error_kind, "unknown Iceberg scan error metric label"),
148        }
149    }
150
151    pub fn record_snapshot_discovered(&self) {
152        self.snapshots_discovered_total.inc();
153    }
154
155    pub fn record_snapshot_lag(&self, lag_secs: i64) {
156        GLOBAL_ICEBERG_SCAN_METRICS
157            .iceberg_source_snapshot_lag_seconds
158            .with_guarded_label_values(&[
159                self.source_id.as_str(),
160                self.source_name.as_str(),
161                self.table_name.as_str(),
162            ])
163            .set(lag_secs);
164    }
165
166    pub fn record_caught_up(&self) {
167        self.record_snapshot_lag(0);
168    }
169
170    fn record_list_duration(&self, duration: Duration) {
171        self.list_duration_seconds.observe(duration.as_secs_f64());
172    }
173
174    fn record_delete_files_per_data_file(&self, delete_file_count: usize) {
175        self.delete_files_per_data_file
176            .observe(delete_file_count as f64);
177    }
178
179    fn record_file_counts(&self, stats: &IcebergScanPlanStats) {
180        for (metric, count) in [
181            (&self.data_files_discovered_total, stats.data_file_count),
182            (
183                &self.equality_delete_files_discovered_total,
184                stats.eq_delete_count,
185            ),
186            (
187                &self.position_delete_files_discovered_total,
188                stats.pos_delete_count,
189            ),
190        ] {
191            if count > 0 {
192                metric.inc_by(count);
193            }
194        }
195    }
196
197    pub fn record_fetch_error(&self) {
198        self.fetch_errors_total.inc();
199    }
200
201    pub fn set_inflight_file_count(&self, count: usize) {
202        GLOBAL_ICEBERG_SCAN_METRICS
203            .iceberg_source_inflight_file_count
204            .with_guarded_label_values(&[
205                self.source_id.as_str(),
206                self.source_name.as_str(),
207                self.table_name.as_str(),
208            ])
209            .set(count as i64);
210    }
211}
212
213#[derive(Debug, Default)]
214struct IcebergScanPlanStats {
215    data_file_count: u64,
216    eq_delete_count: u64,
217    pos_delete_count: u64,
218}
219
220impl IcebergScanPlanStats {
221    fn record_task(&mut self, scan_task: &FileScanTask) {
222        self.data_file_count += 1;
223        for delete_task in &scan_task.deletes {
224            match delete_task.file_type {
225                DataContentType::EqualityDeletes => self.eq_delete_count += 1,
226                DataContentType::PositionDeletes => self.pos_delete_count += 1,
227                _ => {}
228            }
229        }
230    }
231}
232
233pub struct IcebergScanPlan {
234    pub snapshot_id: i64,
235    pub tasks: IcebergScanTaskStream,
236}
237
238pub enum IcebergIncrementalScan {
239    EmptyTable,
240    UpToDate { current_snapshot_id: i64 },
241    Planned(IcebergScanPlan),
242}
243
244#[derive(Clone)]
245pub struct IcebergScanPlanner {
246    properties: IcebergProperties,
247    projection: IcebergScanProjection,
248    metrics: Option<IcebergScanMetricsLabels>,
249}
250
251impl IcebergScanPlanner {
252    pub fn new(
253        properties: IcebergProperties,
254        projection: IcebergScanProjection,
255        metrics: Option<IcebergScanMetricsLabels>,
256    ) -> Self {
257        Self {
258            properties,
259            projection,
260            metrics,
261        }
262    }
263
264    pub async fn plan_current_snapshot(&self) -> ConnectorResult<Option<IcebergScanPlan>> {
265        let table = self.properties.load_table().await?;
266        let Some(current_snapshot) = table.metadata().current_snapshot() else {
267            return Ok(None);
268        };
269        self.plan_snapshot(&table, current_snapshot.snapshot_id())
270            .await
271            .map(Some)
272    }
273
274    pub async fn plan_incremental(
275        &self,
276        last_snapshot: Option<i64>,
277    ) -> ConnectorResult<IcebergIncrementalScan> {
278        let table = self.properties.load_table().await?;
279
280        let Some(current_snapshot) = table.metadata().current_snapshot() else {
281            tracing::info!("Skip incremental scan because table is empty");
282            return Ok(IcebergIncrementalScan::EmptyTable);
283        };
284
285        let current_snapshot_id = current_snapshot.snapshot_id();
286        if Some(current_snapshot_id) == last_snapshot {
287            if let Some(metrics) = &self.metrics {
288                metrics.record_caught_up();
289            }
290            tracing::info!(
291                "Current table snapshot is already enumerated: {}, no new snapshot available",
292                current_snapshot_id
293            );
294            return Ok(IcebergIncrementalScan::UpToDate {
295                current_snapshot_id,
296            });
297        }
298
299        if let Some(metrics) = &self.metrics {
300            if let Some(last_snapshot_id) = last_snapshot
301                && let Some(last_ingested_snapshot) = table
302                    .metadata()
303                    .snapshots()
304                    .find(|snapshot| snapshot.snapshot_id() == last_snapshot_id)
305            {
306                let lag_secs = (current_snapshot.timestamp_ms()
307                    - last_ingested_snapshot.timestamp_ms())
308                .max(0)
309                    / 1000;
310                metrics.record_snapshot_lag(lag_secs);
311            }
312            metrics.record_snapshot_discovered();
313        }
314
315        let mut scan_builder = table.scan().to_snapshot_id(current_snapshot_id);
316        if let Some(last_snapshot) = last_snapshot {
317            scan_builder = scan_builder.from_snapshot_id(last_snapshot);
318        }
319        let scan = self.projection.apply(scan_builder).build()?;
320        let tasks = self.instrument_scan_task_stream(scan.plan_files().await?);
321
322        Ok(IcebergIncrementalScan::Planned(IcebergScanPlan {
323            snapshot_id: current_snapshot_id,
324            tasks,
325        }))
326    }
327
328    pub fn record_caught_up(&self) {
329        if let Some(metrics) = &self.metrics {
330            metrics.record_caught_up();
331        }
332    }
333
334    async fn plan_snapshot(
335        &self,
336        table: &Table,
337        snapshot_id: i64,
338    ) -> ConnectorResult<IcebergScanPlan> {
339        let scan = self
340            .projection
341            .apply(table.scan().snapshot_id(snapshot_id))
342            .build()?;
343        let tasks = self.instrument_scan_task_stream(scan.plan_files().await?);
344
345        Ok(IcebergScanPlan { snapshot_id, tasks })
346    }
347
348    fn instrument_scan_task_stream(
349        &self,
350        scan_tasks: iceberg::scan::FileScanTaskStream,
351    ) -> IcebergScanTaskStream {
352        instrument_scan_task_stream(scan_tasks, self.metrics.clone())
353    }
354}
355
356#[try_stream(boxed, ok = FileScanTask, error = crate::error::ConnectorError)]
357async fn instrument_scan_task_stream(
358    scan_tasks: iceberg::scan::FileScanTaskStream,
359    metrics: Option<IcebergScanMetricsLabels>,
360) {
361    let mut list_duration = Duration::default();
362    let mut active_since = Instant::now();
363    let mut stats = IcebergScanPlanStats::default();
364
365    let mut scan_tasks = scan_tasks;
366    while let Some(scan_task) = scan_tasks.next().await {
367        let scan_task = scan_task?;
368        if let Some(metrics) = &metrics {
369            stats.record_task(&scan_task);
370            metrics.record_delete_files_per_data_file(scan_task.deletes.len());
371        }
372        list_duration += active_since.elapsed();
373        yield scan_task;
374        active_since = Instant::now();
375    }
376    list_duration += active_since.elapsed();
377
378    if let Some(metrics) = &metrics {
379        metrics.record_list_duration(list_duration);
380        metrics.record_file_counts(&stats);
381    }
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
385pub struct PersistedFileScanTask {
386    pub start: u64,
387    pub length: u64,
388    pub record_count: Option<u64>,
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub first_row_id: Option<i64>,
391    #[serde(default, skip_serializing_if = "Option::is_none")]
392    pub data_sequence_number: Option<i64>,
393    pub data_file_path: String,
394    #[serde(default, skip_serializing_if = "Option::is_none")]
395    pub referenced_data_file: Option<String>,
396    pub data_file_content: DataContentType,
397    pub data_file_format: DataFileFormat,
398    pub schema: SchemaRef,
399    pub project_field_ids: Vec<i32>,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub predicate: Option<BoundPredicate>,
402    pub deletes: Vec<PersistedFileScanTask>,
403    #[serde(default)]
404    pub sequence_number: i64,
405    #[serde(default)]
406    pub equality_ids: Option<Vec<i32>>,
407    #[serde(default, skip_serializing_if = "is_zero")]
408    pub partition_spec_id: i32,
409    pub file_size_in_bytes: u64,
410    #[serde(default = "default_case_sensitive")]
411    pub case_sensitive: bool,
412}
413
414fn default_case_sensitive() -> bool {
415    true
416}
417
418fn is_zero(value: &i32) -> bool {
419    *value == 0
420}
421
422impl PersistedFileScanTask {
423    pub fn decode(jsonb_ref: JsonbRef<'_>) -> ConnectorResult<FileScanTask> {
424        let json = jsonb_ref.to_owned_scalar().take();
425        let persisted_task: Self = serde_json::from_value(json.clone()).with_context(|| {
426            format!("failed to decode persisted iceberg file scan task from json `{json}`")
427        })?;
428        Ok(Self::to_task(persisted_task))
429    }
430
431    pub fn encode(task: FileScanTask) -> ConnectorResult<JsonbVal> {
432        let persisted_task = Self::from_task(task);
433        Ok(serde_json::to_value(persisted_task)?.into())
434    }
435
436    fn to_task(
437        Self {
438            start,
439            length,
440            record_count,
441            first_row_id,
442            data_sequence_number,
443            data_file_path,
444            referenced_data_file: _,
445            data_file_content: _,
446            data_file_format,
447            schema,
448            project_field_ids,
449            predicate,
450            deletes,
451            sequence_number,
452            equality_ids: _,
453            partition_spec_id: _,
454            file_size_in_bytes,
455            case_sensitive,
456        }: Self,
457    ) -> FileScanTask {
458        FileScanTask {
459            start,
460            length,
461            record_count,
462            first_row_id,
463            data_sequence_number,
464            data_file_path,
465            data_file_format,
466            schema,
467            project_field_ids,
468            predicate,
469            deletes: deletes
470                .into_iter()
471                .map(PersistedFileScanTask::into_delete_task)
472                .collect(),
473            sequence_number,
474            file_size_in_bytes,
475            partition: None,
476            partition_spec: None,
477            name_mapping: None,
478            unified_partition_type: None,
479            case_sensitive,
480            key_metadata: None,
481        }
482    }
483
484    fn from_task(
485        FileScanTask {
486            start,
487            length,
488            record_count,
489            first_row_id,
490            data_sequence_number,
491            data_file_path,
492            data_file_format,
493            schema,
494            project_field_ids,
495            predicate,
496            deletes,
497            sequence_number,
498            file_size_in_bytes,
499            case_sensitive,
500            ..
501        }: FileScanTask,
502    ) -> Self {
503        let persisted_deletes = deletes
504            .into_iter()
505            .map(|task| {
506                PersistedFileScanTask::from_delete_task(
507                    task,
508                    schema.clone(),
509                    project_field_ids.clone(),
510                    case_sensitive,
511                )
512            })
513            .collect();
514        Self {
515            start,
516            length,
517            record_count,
518            first_row_id,
519            data_sequence_number,
520            data_file_path,
521            referenced_data_file: None,
522            data_file_content: DataContentType::Data,
523            data_file_format,
524            schema,
525            project_field_ids,
526            predicate,
527            deletes: persisted_deletes,
528            sequence_number,
529            equality_ids: None,
530            partition_spec_id: 0,
531            file_size_in_bytes,
532            case_sensitive,
533        }
534    }
535
536    fn into_delete_task(self) -> FileScanTaskDeleteFile {
537        let Self {
538            start,
539            length,
540            record_count,
541            data_file_path,
542            referenced_data_file,
543            data_file_content,
544            data_file_format,
545            equality_ids,
546            sequence_number,
547            partition_spec_id,
548            file_size_in_bytes,
549            ..
550        } = self;
551        let (content_offset, content_size_in_bytes) = if data_file_format == DataFileFormat::Puffin
552        {
553            (i64::try_from(start).ok(), i64::try_from(length).ok())
554        } else {
555            (None, None)
556        };
557
558        FileScanTaskDeleteFile {
559            file_path: data_file_path,
560            file_size_in_bytes,
561            file_type: data_file_content,
562            partition_spec_id,
563            equality_ids,
564            file_format: data_file_format,
565            referenced_data_file,
566            content_offset,
567            content_size_in_bytes,
568            record_count,
569            sequence_number,
570            // Persisted split state deliberately excludes plaintext encryption key material.
571            key_metadata: None,
572        }
573    }
574
575    fn from_delete_task(
576        task: FileScanTaskDeleteFile,
577        schema: SchemaRef,
578        project_field_ids: Vec<i32>,
579        case_sensitive: bool,
580    ) -> Self {
581        let (start, length) = if task.file_format == DataFileFormat::Puffin {
582            (
583                task.content_offset.unwrap_or_default().max(0) as u64,
584                task.content_size_in_bytes.unwrap_or_default().max(0) as u64,
585            )
586        } else {
587            (0, task.file_size_in_bytes)
588        };
589
590        Self {
591            start,
592            length,
593            record_count: task.record_count,
594            first_row_id: None,
595            data_sequence_number: None,
596            data_file_path: task.file_path,
597            referenced_data_file: task.referenced_data_file,
598            data_file_content: task.file_type,
599            data_file_format: task.file_format,
600            schema,
601            project_field_ids,
602            predicate: None,
603            deletes: Vec::new(),
604            sequence_number: task.sequence_number,
605            equality_ids: task.equality_ids,
606            partition_spec_id: task.partition_spec_id,
607            file_size_in_bytes: task.file_size_in_bytes,
608            case_sensitive,
609        }
610    }
611}
612
613impl IcebergFileScanTask {
614    pub fn scan_type(&self) -> IcebergScanType {
615        match self {
616            IcebergFileScanTask::Data(_) => IcebergScanType::DataScan,
617            IcebergFileScanTask::EqualityDelete(_) => IcebergScanType::EqualityDeleteScan,
618            IcebergFileScanTask::PositionDelete(_) => IcebergScanType::PositionDeleteScan,
619        }
620    }
621
622    pub fn from_tasks(
623        scan_type: IcebergScanType,
624        tasks: Vec<FileScanTask>,
625    ) -> ConnectorResult<Self> {
626        match scan_type {
627            IcebergScanType::DataScan => Ok(IcebergFileScanTask::Data(tasks)),
628            IcebergScanType::EqualityDeleteScan => Ok(IcebergFileScanTask::EqualityDelete(tasks)),
629            IcebergScanType::PositionDeleteScan => Ok(IcebergFileScanTask::PositionDelete(tasks)),
630            _ => {
631                bail!("unsupported Iceberg file scan task type: {:?}", scan_type)
632            }
633        }
634    }
635
636    pub fn into_tasks(self) -> Vec<FileScanTask> {
637        match self {
638            IcebergFileScanTask::Data(tasks)
639            | IcebergFileScanTask::EqualityDelete(tasks)
640            | IcebergFileScanTask::PositionDelete(tasks) => tasks,
641        }
642    }
643}
644
645impl IcebergScanOpts {
646    pub fn new(
647        chunk_size: usize,
648        need_seq_num: bool,
649        need_file_path_and_pos: bool,
650        handle_delete_files: bool,
651    ) -> Self {
652        Self {
653            chunk_size,
654            need_seq_num,
655            need_file_path_and_pos,
656            handle_delete_files,
657        }
658    }
659}
660
661impl IcebergScanTaskPlanner {
662    pub fn plan_splits(
663        task: IcebergFileScanTask,
664        split_num: usize,
665        limit: Option<u64>,
666    ) -> ConnectorResult<Vec<IcebergSplit>> {
667        let scan_type = task.scan_type();
668        if limit.is_some() && scan_type != IcebergScanType::DataScan {
669            bail!("Iceberg scan limit can only be planned for data scan tasks");
670        }
671
672        let task_batches = Self::plan_task_batches(
673            task,
674            split_num,
675            limit,
676            IcebergScanTaskBatchMode::PreserveParallelism,
677        );
678
679        task_batches
680            .into_iter()
681            .enumerate()
682            .map(|(id, tasks)| {
683                Ok(IcebergSplit {
684                    split_id: id.try_into().unwrap(),
685                    task: IcebergFileScanTask::from_tasks(scan_type, tasks)?,
686                    limit,
687                })
688            })
689            .collect()
690    }
691
692    pub fn plan_task_batches(
693        task: IcebergFileScanTask,
694        split_num: usize,
695        limit: Option<u64>,
696        mode: IcebergScanTaskBatchMode,
697    ) -> Vec<Vec<FileScanTask>> {
698        let tasks = task.into_tasks();
699        if limit.is_some() {
700            return vec![tasks];
701        }
702        Self::plan_file_task_batches(tasks, split_num, mode)
703    }
704
705    pub fn plan_file_task_batches(
706        file_scan_tasks: Vec<FileScanTask>,
707        split_num: usize,
708        mode: IcebergScanTaskBatchMode,
709    ) -> Vec<Vec<FileScanTask>> {
710        assert!(split_num > 0, "iceberg scan split number must be positive");
711        if mode == IcebergScanTaskBatchMode::Compact && file_scan_tasks.len() <= split_num {
712            return file_scan_tasks.into_iter().map(|task| vec![task]).collect();
713        }
714
715        Self::split_n_vecs(file_scan_tasks, split_num)
716    }
717
718    /// Uniformly distribute scan tasks to compute nodes.
719    /// It's deterministic so that it can best utilize the data locality.
720    ///
721    /// # Arguments
722    /// * `file_scan_tasks`: The file scan tasks to be split.
723    /// * `split_num`: The number of splits to be created.
724    ///
725    /// This algorithm is based on a min-heap. It will push all groups into the heap, and then pop the smallest group and add the file scan task to it.
726    /// Ensure that the total length of each group is as balanced as possible.
727    /// The time complexity is O(n log k), where n is the number of file scan tasks and k is the number of splits.
728    /// The space complexity is O(k), where k is the number of splits.
729    /// The algorithm is stable, so the order of the file scan tasks will be preserved.
730    pub fn split_n_vecs(
731        file_scan_tasks: Vec<FileScanTask>,
732        split_num: usize,
733    ) -> Vec<Vec<FileScanTask>> {
734        #[derive(Default)]
735        struct FileScanTaskGroup {
736            idx: usize,
737            tasks: Vec<FileScanTask>,
738            total_length: u64,
739        }
740
741        impl Ord for FileScanTaskGroup {
742            fn cmp(&self, other: &Self) -> Ordering {
743                // when total_length is the same, we will sort by index
744                if self.total_length == other.total_length {
745                    self.idx.cmp(&other.idx)
746                } else {
747                    self.total_length.cmp(&other.total_length)
748                }
749            }
750        }
751
752        impl PartialOrd for FileScanTaskGroup {
753            fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
754                Some(self.cmp(other))
755            }
756        }
757
758        impl Eq for FileScanTaskGroup {}
759
760        impl PartialEq for FileScanTaskGroup {
761            fn eq(&self, other: &Self) -> bool {
762                self.total_length == other.total_length && self.idx == other.idx
763            }
764        }
765
766        let mut heap = BinaryHeap::new();
767        // push all groups into heap
768        for idx in 0..split_num {
769            heap.push(Reverse(FileScanTaskGroup {
770                idx,
771                tasks: vec![],
772                total_length: 0,
773            }));
774        }
775
776        for file_task in file_scan_tasks {
777            let mut group = heap.peek_mut().unwrap();
778            group.0.total_length += file_task.length;
779            group.0.tasks.push(file_task);
780        }
781
782        // convert heap into vec and extract tasks
783        heap.into_vec()
784            .into_iter()
785            .map(|reverse_group| reverse_group.0.tasks)
786            .collect()
787    }
788}