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::sync::Arc;
18use std::time::{Duration, Instant};
19
20use anyhow::Context;
21use futures::StreamExt;
22use futures::stream::BoxStream;
23use futures_async_stream::try_stream;
24use iceberg::expr::BoundPredicate;
25use iceberg::scan::FileScanTask;
26use iceberg::spec::{DataContentType, DataFileFormat, SchemaRef};
27use iceberg::table::Table;
28use risingwave_common::bail;
29use risingwave_common::catalog::ColumnCatalog;
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}
96
97impl IcebergScanMetricsLabels {
98    pub fn new(source_id: String, source_name: String, table_name: String) -> Self {
99        Self {
100            source_id,
101            source_name,
102            table_name,
103        }
104    }
105
106    pub fn record_scan_error(&self, error_kind: &str) {
107        GLOBAL_ICEBERG_SCAN_METRICS
108            .iceberg_source_scan_errors_total
109            .with_guarded_label_values(&[
110                self.source_id.as_str(),
111                self.source_name.as_str(),
112                self.table_name.as_str(),
113                error_kind,
114            ])
115            .inc();
116    }
117
118    pub fn record_snapshot_discovered(&self) {
119        GLOBAL_ICEBERG_SCAN_METRICS
120            .iceberg_source_snapshots_discovered_total
121            .with_guarded_label_values(&[
122                self.source_id.as_str(),
123                self.source_name.as_str(),
124                self.table_name.as_str(),
125            ])
126            .inc();
127    }
128
129    pub fn record_snapshot_lag(&self, lag_secs: i64) {
130        GLOBAL_ICEBERG_SCAN_METRICS
131            .iceberg_source_snapshot_lag_seconds
132            .with_guarded_label_values(&[
133                self.source_id.as_str(),
134                self.source_name.as_str(),
135                self.table_name.as_str(),
136            ])
137            .set(lag_secs);
138    }
139
140    pub fn record_caught_up(&self) {
141        self.record_snapshot_lag(0);
142    }
143
144    fn record_list_duration(&self, duration: Duration) {
145        GLOBAL_ICEBERG_SCAN_METRICS
146            .iceberg_source_list_duration_seconds
147            .with_guarded_label_values(&[
148                self.source_id.as_str(),
149                self.source_name.as_str(),
150                self.table_name.as_str(),
151            ])
152            .observe(duration.as_secs_f64());
153    }
154
155    fn record_delete_files_per_data_file(&self, delete_file_count: usize) {
156        GLOBAL_ICEBERG_SCAN_METRICS
157            .iceberg_source_delete_files_per_data_file
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            .observe(delete_file_count as f64);
164    }
165
166    fn record_file_counts(&self, stats: &IcebergScanPlanStats) {
167        for (file_type, count) in [
168            ("data", stats.data_file_count),
169            ("eq_delete", stats.eq_delete_count),
170            ("pos_delete", stats.pos_delete_count),
171        ] {
172            if count > 0 {
173                GLOBAL_ICEBERG_SCAN_METRICS
174                    .iceberg_source_files_discovered_total
175                    .with_guarded_label_values(&[
176                        self.source_id.as_str(),
177                        self.source_name.as_str(),
178                        self.table_name.as_str(),
179                        file_type,
180                    ])
181                    .inc_by(count);
182            }
183        }
184    }
185}
186
187#[derive(Debug, Default)]
188struct IcebergScanPlanStats {
189    data_file_count: u64,
190    eq_delete_count: u64,
191    pos_delete_count: u64,
192}
193
194impl IcebergScanPlanStats {
195    fn record_task(&mut self, scan_task: &FileScanTask) {
196        self.data_file_count += 1;
197        for delete_task in &scan_task.deletes {
198            match delete_task.data_file_content {
199                DataContentType::EqualityDeletes => self.eq_delete_count += 1,
200                DataContentType::PositionDeletes => self.pos_delete_count += 1,
201                _ => {}
202            }
203        }
204    }
205}
206
207pub struct IcebergScanPlan {
208    pub snapshot_id: i64,
209    pub tasks: IcebergScanTaskStream,
210}
211
212pub enum IcebergIncrementalScan {
213    EmptyTable,
214    UpToDate { current_snapshot_id: i64 },
215    Planned(IcebergScanPlan),
216}
217
218#[derive(Clone)]
219pub struct IcebergScanPlanner {
220    properties: IcebergProperties,
221    projection: IcebergScanProjection,
222    metrics: Option<IcebergScanMetricsLabels>,
223}
224
225impl IcebergScanPlanner {
226    pub fn new(
227        properties: IcebergProperties,
228        projection: IcebergScanProjection,
229        metrics: Option<IcebergScanMetricsLabels>,
230    ) -> Self {
231        Self {
232            properties,
233            projection,
234            metrics,
235        }
236    }
237
238    pub async fn plan_current_snapshot(&self) -> ConnectorResult<Option<IcebergScanPlan>> {
239        let table = self.properties.load_table().await?;
240        let Some(current_snapshot) = table.metadata().current_snapshot() else {
241            return Ok(None);
242        };
243        self.plan_snapshot(&table, current_snapshot.snapshot_id())
244            .await
245            .map(Some)
246    }
247
248    pub async fn plan_incremental(
249        &self,
250        last_snapshot: Option<i64>,
251    ) -> ConnectorResult<IcebergIncrementalScan> {
252        let table = self.properties.load_table().await?;
253
254        let Some(current_snapshot) = table.metadata().current_snapshot() else {
255            tracing::info!("Skip incremental scan because table is empty");
256            return Ok(IcebergIncrementalScan::EmptyTable);
257        };
258
259        let current_snapshot_id = current_snapshot.snapshot_id();
260        if Some(current_snapshot_id) == last_snapshot {
261            if let Some(metrics) = &self.metrics {
262                metrics.record_caught_up();
263            }
264            tracing::info!(
265                "Current table snapshot is already enumerated: {}, no new snapshot available",
266                current_snapshot_id
267            );
268            return Ok(IcebergIncrementalScan::UpToDate {
269                current_snapshot_id,
270            });
271        }
272
273        if let Some(metrics) = &self.metrics {
274            if let Some(last_snapshot_id) = last_snapshot
275                && let Some(last_ingested_snapshot) = table
276                    .metadata()
277                    .snapshots()
278                    .find(|snapshot| snapshot.snapshot_id() == last_snapshot_id)
279            {
280                let lag_secs = (current_snapshot.timestamp_ms()
281                    - last_ingested_snapshot.timestamp_ms())
282                .max(0)
283                    / 1000;
284                metrics.record_snapshot_lag(lag_secs);
285            }
286            metrics.record_snapshot_discovered();
287        }
288
289        let mut scan_builder = table.scan().to_snapshot_id(current_snapshot_id);
290        if let Some(last_snapshot) = last_snapshot {
291            scan_builder = scan_builder.from_snapshot_id(last_snapshot);
292        }
293        let scan = self.projection.apply(scan_builder).build()?;
294        let tasks = self.instrument_scan_task_stream(scan.plan_files().await?);
295
296        Ok(IcebergIncrementalScan::Planned(IcebergScanPlan {
297            snapshot_id: current_snapshot_id,
298            tasks,
299        }))
300    }
301
302    pub fn record_caught_up(&self) {
303        if let Some(metrics) = &self.metrics {
304            metrics.record_caught_up();
305        }
306    }
307
308    async fn plan_snapshot(
309        &self,
310        table: &Table,
311        snapshot_id: i64,
312    ) -> ConnectorResult<IcebergScanPlan> {
313        let scan = self
314            .projection
315            .apply(table.scan().snapshot_id(snapshot_id))
316            .build()?;
317        let tasks = self.instrument_scan_task_stream(scan.plan_files().await?);
318
319        Ok(IcebergScanPlan { snapshot_id, tasks })
320    }
321
322    fn instrument_scan_task_stream(
323        &self,
324        scan_tasks: iceberg::scan::FileScanTaskStream,
325    ) -> IcebergScanTaskStream {
326        instrument_scan_task_stream(scan_tasks, self.metrics.clone())
327    }
328}
329
330#[try_stream(boxed, ok = FileScanTask, error = crate::error::ConnectorError)]
331async fn instrument_scan_task_stream(
332    scan_tasks: iceberg::scan::FileScanTaskStream,
333    metrics: Option<IcebergScanMetricsLabels>,
334) {
335    let mut list_duration = Duration::default();
336    let mut active_since = Instant::now();
337    let mut stats = IcebergScanPlanStats::default();
338
339    let mut scan_tasks = scan_tasks;
340    while let Some(scan_task) = scan_tasks.next().await {
341        let scan_task = scan_task?;
342        if let Some(metrics) = &metrics {
343            stats.record_task(&scan_task);
344            metrics.record_delete_files_per_data_file(scan_task.deletes.len());
345        }
346        list_duration += active_since.elapsed();
347        yield scan_task;
348        active_since = Instant::now();
349    }
350    list_duration += active_since.elapsed();
351
352    if let Some(metrics) = &metrics {
353        metrics.record_list_duration(list_duration);
354        metrics.record_file_counts(&stats);
355    }
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
359pub struct PersistedFileScanTask {
360    pub start: u64,
361    pub length: u64,
362    pub record_count: Option<u64>,
363    pub data_file_path: String,
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub referenced_data_file: Option<String>,
366    pub data_file_content: DataContentType,
367    pub data_file_format: DataFileFormat,
368    pub schema: SchemaRef,
369    pub project_field_ids: Vec<i32>,
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub predicate: Option<BoundPredicate>,
372    pub deletes: Vec<PersistedFileScanTask>,
373    pub sequence_number: i64,
374    pub equality_ids: Option<Vec<i32>>,
375    pub file_size_in_bytes: u64,
376    #[serde(default = "default_case_sensitive")]
377    pub case_sensitive: bool,
378}
379
380fn default_case_sensitive() -> bool {
381    true
382}
383
384impl PersistedFileScanTask {
385    pub fn decode(jsonb_ref: JsonbRef<'_>) -> ConnectorResult<FileScanTask> {
386        let json = jsonb_ref.to_owned_scalar().take();
387        let persisted_task: Self = serde_json::from_value(json.clone()).with_context(|| {
388            format!("failed to decode persisted iceberg file scan task from json `{json}`")
389        })?;
390        Ok(Self::to_task(persisted_task))
391    }
392
393    pub fn encode(task: FileScanTask) -> ConnectorResult<JsonbVal> {
394        let persisted_task = Self::from_task(task);
395        Ok(serde_json::to_value(persisted_task)?.into())
396    }
397
398    fn to_task(
399        Self {
400            start,
401            length,
402            record_count,
403            data_file_path,
404            referenced_data_file,
405            data_file_content,
406            data_file_format,
407            schema,
408            project_field_ids,
409            predicate,
410            deletes,
411            sequence_number,
412            equality_ids,
413            file_size_in_bytes,
414            case_sensitive,
415        }: Self,
416    ) -> FileScanTask {
417        FileScanTask {
418            start,
419            length,
420            record_count,
421            data_file_path,
422            referenced_data_file,
423            data_file_content,
424            data_file_format,
425            schema,
426            project_field_ids,
427            predicate,
428            deletes: deletes
429                .into_iter()
430                .map(|task| Arc::new(PersistedFileScanTask::to_task(task)))
431                .collect(),
432            sequence_number,
433            equality_ids,
434            file_size_in_bytes,
435            partition: None,
436            partition_spec: None,
437            name_mapping: None,
438            case_sensitive,
439        }
440    }
441
442    fn from_task(
443        FileScanTask {
444            start,
445            length,
446            record_count,
447            data_file_path,
448            referenced_data_file,
449            data_file_content,
450            data_file_format,
451            schema,
452            project_field_ids,
453            predicate,
454            deletes,
455            sequence_number,
456            equality_ids,
457            file_size_in_bytes,
458            case_sensitive,
459            ..
460        }: FileScanTask,
461    ) -> Self {
462        Self {
463            start,
464            length,
465            record_count,
466            data_file_path,
467            referenced_data_file,
468            data_file_content,
469            data_file_format,
470            schema,
471            project_field_ids,
472            predicate,
473            deletes: deletes
474                .into_iter()
475                .map(PersistedFileScanTask::from_task_ref)
476                .collect(),
477            sequence_number,
478            equality_ids,
479            file_size_in_bytes,
480            case_sensitive,
481        }
482    }
483
484    fn from_task_ref(task: Arc<FileScanTask>) -> Self {
485        Self {
486            start: task.start,
487            length: task.length,
488            record_count: task.record_count,
489            data_file_path: task.data_file_path.clone(),
490            referenced_data_file: task.referenced_data_file.clone(),
491            data_file_content: task.data_file_content,
492            data_file_format: task.data_file_format,
493            schema: task.schema.clone(),
494            project_field_ids: task.project_field_ids.clone(),
495            predicate: task.predicate.clone(),
496            deletes: task
497                .deletes
498                .iter()
499                .cloned()
500                .map(PersistedFileScanTask::from_task_ref)
501                .collect(),
502            sequence_number: task.sequence_number,
503            equality_ids: task.equality_ids.clone(),
504            file_size_in_bytes: task.file_size_in_bytes,
505            case_sensitive: task.case_sensitive,
506        }
507    }
508}
509
510impl IcebergFileScanTask {
511    pub fn scan_type(&self) -> IcebergScanType {
512        match self {
513            IcebergFileScanTask::Data(_) => IcebergScanType::DataScan,
514            IcebergFileScanTask::EqualityDelete(_) => IcebergScanType::EqualityDeleteScan,
515            IcebergFileScanTask::PositionDelete(_) => IcebergScanType::PositionDeleteScan,
516        }
517    }
518
519    pub fn from_tasks(
520        scan_type: IcebergScanType,
521        tasks: Vec<FileScanTask>,
522    ) -> ConnectorResult<Self> {
523        match scan_type {
524            IcebergScanType::DataScan => Ok(IcebergFileScanTask::Data(tasks)),
525            IcebergScanType::EqualityDeleteScan => Ok(IcebergFileScanTask::EqualityDelete(tasks)),
526            IcebergScanType::PositionDeleteScan => Ok(IcebergFileScanTask::PositionDelete(tasks)),
527            _ => {
528                bail!("unsupported Iceberg file scan task type: {:?}", scan_type)
529            }
530        }
531    }
532
533    pub fn into_tasks(self) -> Vec<FileScanTask> {
534        match self {
535            IcebergFileScanTask::Data(tasks)
536            | IcebergFileScanTask::EqualityDelete(tasks)
537            | IcebergFileScanTask::PositionDelete(tasks) => tasks,
538        }
539    }
540}
541
542impl IcebergScanOpts {
543    pub fn new(
544        chunk_size: usize,
545        need_seq_num: bool,
546        need_file_path_and_pos: bool,
547        handle_delete_files: bool,
548    ) -> Self {
549        Self {
550            chunk_size,
551            need_seq_num,
552            need_file_path_and_pos,
553            handle_delete_files,
554        }
555    }
556}
557
558impl IcebergScanTaskPlanner {
559    pub fn plan_splits(
560        task: IcebergFileScanTask,
561        split_num: usize,
562        limit: Option<u64>,
563    ) -> ConnectorResult<Vec<IcebergSplit>> {
564        let scan_type = task.scan_type();
565        if limit.is_some() && scan_type != IcebergScanType::DataScan {
566            bail!("Iceberg scan limit can only be planned for data scan tasks");
567        }
568
569        let task_batches = Self::plan_task_batches(
570            task,
571            split_num,
572            limit,
573            IcebergScanTaskBatchMode::PreserveParallelism,
574        );
575
576        task_batches
577            .into_iter()
578            .enumerate()
579            .map(|(id, tasks)| {
580                Ok(IcebergSplit {
581                    split_id: id.try_into().unwrap(),
582                    task: IcebergFileScanTask::from_tasks(scan_type, tasks)?,
583                    limit,
584                })
585            })
586            .collect()
587    }
588
589    pub fn plan_task_batches(
590        task: IcebergFileScanTask,
591        split_num: usize,
592        limit: Option<u64>,
593        mode: IcebergScanTaskBatchMode,
594    ) -> Vec<Vec<FileScanTask>> {
595        let tasks = task.into_tasks();
596        if limit.is_some() {
597            return vec![tasks];
598        }
599        Self::plan_file_task_batches(tasks, split_num, mode)
600    }
601
602    pub fn plan_file_task_batches(
603        file_scan_tasks: Vec<FileScanTask>,
604        split_num: usize,
605        mode: IcebergScanTaskBatchMode,
606    ) -> Vec<Vec<FileScanTask>> {
607        assert!(split_num > 0, "iceberg scan split number must be positive");
608        if mode == IcebergScanTaskBatchMode::Compact && file_scan_tasks.len() <= split_num {
609            return file_scan_tasks.into_iter().map(|task| vec![task]).collect();
610        }
611
612        Self::split_n_vecs(file_scan_tasks, split_num)
613    }
614
615    /// Uniformly distribute scan tasks to compute nodes.
616    /// It's deterministic so that it can best utilize the data locality.
617    ///
618    /// # Arguments
619    /// * `file_scan_tasks`: The file scan tasks to be split.
620    /// * `split_num`: The number of splits to be created.
621    ///
622    /// 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.
623    /// Ensure that the total length of each group is as balanced as possible.
624    /// The time complexity is O(n log k), where n is the number of file scan tasks and k is the number of splits.
625    /// The space complexity is O(k), where k is the number of splits.
626    /// The algorithm is stable, so the order of the file scan tasks will be preserved.
627    pub fn split_n_vecs(
628        file_scan_tasks: Vec<FileScanTask>,
629        split_num: usize,
630    ) -> Vec<Vec<FileScanTask>> {
631        #[derive(Default)]
632        struct FileScanTaskGroup {
633            idx: usize,
634            tasks: Vec<FileScanTask>,
635            total_length: u64,
636        }
637
638        impl Ord for FileScanTaskGroup {
639            fn cmp(&self, other: &Self) -> Ordering {
640                // when total_length is the same, we will sort by index
641                if self.total_length == other.total_length {
642                    self.idx.cmp(&other.idx)
643                } else {
644                    self.total_length.cmp(&other.total_length)
645                }
646            }
647        }
648
649        impl PartialOrd for FileScanTaskGroup {
650            fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
651                Some(self.cmp(other))
652            }
653        }
654
655        impl Eq for FileScanTaskGroup {}
656
657        impl PartialEq for FileScanTaskGroup {
658            fn eq(&self, other: &Self) -> bool {
659                self.total_length == other.total_length && self.idx == other.idx
660            }
661        }
662
663        let mut heap = BinaryHeap::new();
664        // push all groups into heap
665        for idx in 0..split_num {
666            heap.push(Reverse(FileScanTaskGroup {
667                idx,
668                tasks: vec![],
669                total_length: 0,
670            }));
671        }
672
673        for file_task in file_scan_tasks {
674            let mut group = heap.peek_mut().unwrap();
675            group.0.total_length += file_task.length;
676            group.0.tasks.push(file_task);
677        }
678
679        // convert heap into vec and extract tasks
680        heap.into_vec()
681            .into_iter()
682            .map(|reverse_group| reverse_group.0.tasks)
683            .collect()
684    }
685}