1use 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 PreserveParallelism,
43 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 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 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 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 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 heap.into_vec()
786 .into_iter()
787 .map(|reverse_group| reverse_group.0.tasks)
788 .collect()
789 }
790}