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            file_sequence_number: None,
482        }
483    }
484
485    fn from_task(
486        FileScanTask {
487            start,
488            length,
489            record_count,
490            first_row_id,
491            data_sequence_number,
492            data_file_path,
493            data_file_format,
494            schema,
495            project_field_ids,
496            predicate,
497            deletes,
498            sequence_number,
499            file_sequence_number: _,
500            file_size_in_bytes,
501            case_sensitive,
502            ..
503        }: FileScanTask,
504    ) -> Self {
505        let persisted_deletes = deletes
506            .into_iter()
507            .map(|task| {
508                PersistedFileScanTask::from_delete_task(
509                    task,
510                    schema.clone(),
511                    project_field_ids.clone(),
512                    case_sensitive,
513                )
514            })
515            .collect();
516        Self {
517            start,
518            length,
519            record_count,
520            first_row_id,
521            data_sequence_number,
522            data_file_path,
523            referenced_data_file: None,
524            data_file_content: DataContentType::Data,
525            data_file_format,
526            schema,
527            project_field_ids,
528            predicate,
529            deletes: persisted_deletes,
530            sequence_number,
531            equality_ids: None,
532            partition_spec_id: 0,
533            file_size_in_bytes,
534            case_sensitive,
535        }
536    }
537
538    fn into_delete_task(self) -> FileScanTaskDeleteFile {
539        let Self {
540            start,
541            length,
542            record_count,
543            data_file_path,
544            referenced_data_file,
545            data_file_content,
546            data_file_format,
547            equality_ids,
548            sequence_number,
549            partition_spec_id,
550            file_size_in_bytes,
551            ..
552        } = self;
553        let (content_offset, content_size_in_bytes) = if data_file_format == DataFileFormat::Puffin
554        {
555            (i64::try_from(start).ok(), i64::try_from(length).ok())
556        } else {
557            (None, None)
558        };
559
560        FileScanTaskDeleteFile {
561            file_path: data_file_path,
562            file_size_in_bytes,
563            file_type: data_file_content,
564            partition_spec_id,
565            equality_ids,
566            file_format: data_file_format,
567            referenced_data_file,
568            content_offset,
569            content_size_in_bytes,
570            record_count,
571            sequence_number,
572            // Persisted split state deliberately excludes plaintext encryption key material.
573            key_metadata: None,
574        }
575    }
576
577    fn from_delete_task(
578        task: FileScanTaskDeleteFile,
579        schema: SchemaRef,
580        project_field_ids: Vec<i32>,
581        case_sensitive: bool,
582    ) -> Self {
583        let (start, length) = if task.file_format == DataFileFormat::Puffin {
584            (
585                task.content_offset.unwrap_or_default().max(0) as u64,
586                task.content_size_in_bytes.unwrap_or_default().max(0) as u64,
587            )
588        } else {
589            (0, task.file_size_in_bytes)
590        };
591
592        Self {
593            start,
594            length,
595            record_count: task.record_count,
596            first_row_id: None,
597            data_sequence_number: None,
598            data_file_path: task.file_path,
599            referenced_data_file: task.referenced_data_file,
600            data_file_content: task.file_type,
601            data_file_format: task.file_format,
602            schema,
603            project_field_ids,
604            predicate: None,
605            deletes: Vec::new(),
606            sequence_number: task.sequence_number,
607            equality_ids: task.equality_ids,
608            partition_spec_id: task.partition_spec_id,
609            file_size_in_bytes: task.file_size_in_bytes,
610            case_sensitive,
611        }
612    }
613}
614
615impl IcebergFileScanTask {
616    pub fn scan_type(&self) -> IcebergScanType {
617        match self {
618            IcebergFileScanTask::Data(_) => IcebergScanType::DataScan,
619            IcebergFileScanTask::EqualityDelete(_) => IcebergScanType::EqualityDeleteScan,
620            IcebergFileScanTask::PositionDelete(_) => IcebergScanType::PositionDeleteScan,
621        }
622    }
623
624    pub fn from_tasks(
625        scan_type: IcebergScanType,
626        tasks: Vec<FileScanTask>,
627    ) -> ConnectorResult<Self> {
628        match scan_type {
629            IcebergScanType::DataScan => Ok(IcebergFileScanTask::Data(tasks)),
630            IcebergScanType::EqualityDeleteScan => Ok(IcebergFileScanTask::EqualityDelete(tasks)),
631            IcebergScanType::PositionDeleteScan => Ok(IcebergFileScanTask::PositionDelete(tasks)),
632            _ => {
633                bail!("unsupported Iceberg file scan task type: {:?}", scan_type)
634            }
635        }
636    }
637
638    pub fn into_tasks(self) -> Vec<FileScanTask> {
639        match self {
640            IcebergFileScanTask::Data(tasks)
641            | IcebergFileScanTask::EqualityDelete(tasks)
642            | IcebergFileScanTask::PositionDelete(tasks) => tasks,
643        }
644    }
645}
646
647impl IcebergScanOpts {
648    pub fn new(
649        chunk_size: usize,
650        need_seq_num: bool,
651        need_file_path_and_pos: bool,
652        handle_delete_files: bool,
653    ) -> Self {
654        Self {
655            chunk_size,
656            need_seq_num,
657            need_file_path_and_pos,
658            handle_delete_files,
659        }
660    }
661}
662
663impl IcebergScanTaskPlanner {
664    pub fn plan_splits(
665        task: IcebergFileScanTask,
666        split_num: usize,
667        limit: Option<u64>,
668    ) -> ConnectorResult<Vec<IcebergSplit>> {
669        let scan_type = task.scan_type();
670        if limit.is_some() && scan_type != IcebergScanType::DataScan {
671            bail!("Iceberg scan limit can only be planned for data scan tasks");
672        }
673
674        let task_batches = Self::plan_task_batches(
675            task,
676            split_num,
677            limit,
678            IcebergScanTaskBatchMode::PreserveParallelism,
679        );
680
681        task_batches
682            .into_iter()
683            .enumerate()
684            .map(|(id, tasks)| {
685                Ok(IcebergSplit {
686                    split_id: id.try_into().unwrap(),
687                    task: IcebergFileScanTask::from_tasks(scan_type, tasks)?,
688                    limit,
689                })
690            })
691            .collect()
692    }
693
694    pub fn plan_task_batches(
695        task: IcebergFileScanTask,
696        split_num: usize,
697        limit: Option<u64>,
698        mode: IcebergScanTaskBatchMode,
699    ) -> Vec<Vec<FileScanTask>> {
700        let tasks = task.into_tasks();
701        if limit.is_some() {
702            return vec![tasks];
703        }
704        Self::plan_file_task_batches(tasks, split_num, mode)
705    }
706
707    pub fn plan_file_task_batches(
708        file_scan_tasks: Vec<FileScanTask>,
709        split_num: usize,
710        mode: IcebergScanTaskBatchMode,
711    ) -> Vec<Vec<FileScanTask>> {
712        assert!(split_num > 0, "iceberg scan split number must be positive");
713        if mode == IcebergScanTaskBatchMode::Compact && file_scan_tasks.len() <= split_num {
714            return file_scan_tasks.into_iter().map(|task| vec![task]).collect();
715        }
716
717        Self::split_n_vecs(file_scan_tasks, split_num)
718    }
719
720    /// Uniformly distribute scan tasks to compute nodes.
721    /// It's deterministic so that it can best utilize the data locality.
722    ///
723    /// # Arguments
724    /// * `file_scan_tasks`: The file scan tasks to be split.
725    /// * `split_num`: The number of splits to be created.
726    ///
727    /// 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.
728    /// Ensure that the total length of each group is as balanced as possible.
729    /// The time complexity is O(n log k), where n is the number of file scan tasks and k is the number of splits.
730    /// The space complexity is O(k), where k is the number of splits.
731    /// The algorithm is stable, so the order of the file scan tasks will be preserved.
732    pub fn split_n_vecs(
733        file_scan_tasks: Vec<FileScanTask>,
734        split_num: usize,
735    ) -> Vec<Vec<FileScanTask>> {
736        #[derive(Default)]
737        struct FileScanTaskGroup {
738            idx: usize,
739            tasks: Vec<FileScanTask>,
740            total_length: u64,
741        }
742
743        impl Ord for FileScanTaskGroup {
744            fn cmp(&self, other: &Self) -> Ordering {
745                // when total_length is the same, we will sort by index
746                if self.total_length == other.total_length {
747                    self.idx.cmp(&other.idx)
748                } else {
749                    self.total_length.cmp(&other.total_length)
750                }
751            }
752        }
753
754        impl PartialOrd for FileScanTaskGroup {
755            fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
756                Some(self.cmp(other))
757            }
758        }
759
760        impl Eq for FileScanTaskGroup {}
761
762        impl PartialEq for FileScanTaskGroup {
763            fn eq(&self, other: &Self) -> bool {
764                self.total_length == other.total_length && self.idx == other.idx
765            }
766        }
767
768        let mut heap = BinaryHeap::new();
769        // push all groups into heap
770        for idx in 0..split_num {
771            heap.push(Reverse(FileScanTaskGroup {
772                idx,
773                tasks: vec![],
774                total_length: 0,
775            }));
776        }
777
778        for file_task in file_scan_tasks {
779            let mut group = heap.peek_mut().unwrap();
780            group.0.total_length += file_task.length;
781            group.0.tasks.push(file_task);
782        }
783
784        // convert heap into vec and extract tasks
785        heap.into_vec()
786            .into_iter()
787            .map(|reverse_group| reverse_group.0.tasks)
788            .collect()
789    }
790}