1use 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 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}
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 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 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 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 heap.into_vec()
681 .into_iter()
682 .map(|reverse_group| reverse_group.0.tasks)
683 .collect()
684 }
685}