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 }
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 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 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 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 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 heap.into_vec()
784 .into_iter()
785 .map(|reverse_group| reverse_group.0.tasks)
786 .collect()
787 }
788}