1pub mod parquet_file_handler;
16pub mod planner;
17
18pub mod metrics;
19use std::collections::{HashMap, HashSet};
20use std::sync::Arc;
21
22use anyhow::anyhow;
23use async_trait::async_trait;
24use futures::StreamExt;
25use futures_async_stream::{for_await, try_stream};
26use iceberg::Catalog;
27use iceberg::expr::{BoundPredicate, Predicate as IcebergPredicate};
28use iceberg::scan::FileScanTask;
29use iceberg::spec::FormatVersion;
30use iceberg::table::Table;
31pub use parquet_file_handler::*;
32use phf::{Set, phf_set};
33pub use planner::{
34 IcebergIncrementalScan, IcebergScanMetricsLabels, IcebergScanPlan, IcebergScanPlanner,
35 IcebergScanProjection, IcebergScanTaskBatchMode, IcebergScanTaskPlanner, PersistedFileScanTask,
36};
37use risingwave_common::array::arrow::IcebergArrowConvert;
38use risingwave_common::array::{ArrayImpl, DataChunk, I64Array, Utf8Array};
39use risingwave_common::bail;
40use risingwave_common::types::JsonbVal;
41use risingwave_common_estimate_size::EstimateSize;
42use risingwave_pb::batch_plan::iceberg_scan_node::IcebergScanType;
43use serde::{Deserialize, Serialize};
44
45pub use self::metrics::{GLOBAL_ICEBERG_SCAN_METRICS, IcebergScanMetrics};
46use crate::connector_common::{
47 IcebergCommon, IcebergTableIdentifier, iceberg_java_catalog_props_from_options,
48};
49use crate::enforce_secret::{EnforceSecret, EnforceSecretError};
50use crate::error::{ConnectorError, ConnectorResult};
51use crate::parser::ParserConfig;
52use crate::source::{
53 BoxSourceChunkStream, Column, SourceContextRef, SourceEnumeratorContextRef, SourceProperties,
54 SplitEnumerator, SplitId, SplitMetaData, SplitReader, UnknownFields,
55};
56pub const ICEBERG_CONNECTOR: &str = "iceberg";
57
58#[derive(Clone, Debug, Deserialize, with_options::WithOptions)]
59pub struct IcebergProperties {
60 #[serde(flatten)]
61 pub common: IcebergCommon,
62
63 #[serde(flatten)]
64 pub table: IcebergTableIdentifier,
65
66 #[serde(rename = "catalog.jdbc.user")]
68 pub jdbc_user: Option<String>,
69 #[serde(rename = "catalog.jdbc.password")]
70 pub jdbc_password: Option<String>,
71
72 #[serde(flatten)]
73 pub unknown_fields: HashMap<String, String>,
74}
75
76impl EnforceSecret for IcebergProperties {
77 const ENFORCE_SECRET_PROPERTIES: Set<&'static str> = phf_set! {
78 "catalog.jdbc.password",
79 };
80
81 fn enforce_secret<'a>(prop_iter: impl Iterator<Item = &'a str>) -> ConnectorResult<()> {
82 for prop in prop_iter {
83 IcebergCommon::enforce_one(prop)?;
84 if Self::ENFORCE_SECRET_PROPERTIES.contains(prop) {
85 return Err(EnforceSecretError {
86 key: prop.to_owned(),
87 }
88 .into());
89 }
90 }
91 Ok(())
92 }
93}
94
95impl IcebergProperties {
96 fn java_catalog_props(&self) -> HashMap<String, String> {
97 let mut java_catalog_props = iceberg_java_catalog_props_from_options(
98 self.unknown_fields
99 .iter()
100 .map(|(key, value)| (key.as_str(), value.as_str())),
101 );
102 if let Some(jdbc_user) = self.jdbc_user.clone() {
103 java_catalog_props.insert("jdbc.user".to_owned(), jdbc_user);
104 }
105 if let Some(jdbc_password) = self.jdbc_password.clone() {
106 java_catalog_props.insert("jdbc.password".to_owned(), jdbc_password);
107 }
108 java_catalog_props
109 }
110
111 pub async fn create_catalog(&self) -> ConnectorResult<Arc<dyn Catalog>> {
112 self.common
113 .resolve_catalog_config(self.java_catalog_props())?
114 .create_catalog()
115 .await
116 }
117
118 pub async fn load_table(&self) -> ConnectorResult<Table> {
119 self.common
120 .resolve_catalog_config(self.java_catalog_props())?
121 .load_table(&self.table)
122 .await
123 }
124}
125
126impl SourceProperties for IcebergProperties {
127 type Split = IcebergSplit;
128 type SplitEnumerator = IcebergSplitEnumerator;
129 type SplitReader = IcebergFileReader;
130
131 const SOURCE_NAME: &'static str = ICEBERG_CONNECTOR;
132}
133
134impl UnknownFields for IcebergProperties {
135 fn unknown_fields(&self) -> HashMap<String, String> {
136 self.unknown_fields.clone()
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141pub enum IcebergFileScanTask {
142 Data(Vec<FileScanTask>),
143 EqualityDelete(Vec<FileScanTask>),
144 PositionDelete(Vec<FileScanTask>),
145}
146
147impl IcebergFileScanTask {
148 pub fn tasks(&self) -> &[FileScanTask] {
149 match self {
150 IcebergFileScanTask::Data(file_scan_tasks)
151 | IcebergFileScanTask::EqualityDelete(file_scan_tasks)
152 | IcebergFileScanTask::PositionDelete(file_scan_tasks) => file_scan_tasks,
153 }
154 }
155
156 pub fn is_empty(&self) -> bool {
157 self.tasks().is_empty()
158 }
159
160 pub fn files(&self) -> Vec<String> {
161 self.tasks()
162 .iter()
163 .map(|task| task.data_file_path.clone())
164 .collect()
165 }
166
167 pub fn predicate(&self) -> Option<&BoundPredicate> {
168 let first_task = self.tasks().first()?;
169 first_task.predicate.as_ref()
170 }
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
174pub struct IcebergSplit {
175 pub split_id: i64,
176 pub task: IcebergFileScanTask,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub limit: Option<u64>,
179}
180
181impl IcebergSplit {
182 #[allow(deprecated)]
183 pub fn empty(iceberg_scan_type: IcebergScanType) -> Self {
184 let task = match iceberg_scan_type {
185 IcebergScanType::DataScan => IcebergFileScanTask::Data(vec![]),
186 IcebergScanType::EqualityDeleteScan => IcebergFileScanTask::EqualityDelete(vec![]),
187 IcebergScanType::PositionDeleteScan => IcebergFileScanTask::PositionDelete(vec![]),
188 IcebergScanType::Unspecified | IcebergScanType::CountStar => {
189 IcebergFileScanTask::Data(vec![])
192 }
193 };
194 Self {
195 split_id: 0,
196 task,
197 limit: None,
198 }
199 }
200}
201
202impl SplitMetaData for IcebergSplit {
203 fn id(&self) -> SplitId {
204 self.split_id.to_string().into()
205 }
206
207 fn restore_from_json(value: JsonbVal) -> ConnectorResult<Self> {
208 serde_json::from_value(value.take()).map_err(|e| anyhow!(e).into())
209 }
210
211 fn encode_to_json(&self) -> JsonbVal {
212 serde_json::to_value(self.clone())
213 .expect("iceberg split serialization should not fail")
214 .into()
215 }
216
217 fn update_offset(&mut self, _last_seen_offset: String) -> ConnectorResult<()> {
218 Ok(())
221 }
222}
223
224#[derive(Debug, Clone)]
225pub struct IcebergSplitEnumerator {
226 config: IcebergProperties,
227}
228
229#[derive(Debug, Clone)]
230pub struct IcebergDeleteParameters {
231 pub equality_delete_columns: Vec<String>,
232 pub has_position_delete: bool,
233 pub snapshot_id: Option<i64>,
234}
235
236#[async_trait]
237impl SplitEnumerator for IcebergSplitEnumerator {
238 type Properties = IcebergProperties;
239 type Split = IcebergSplit;
240
241 async fn new(
242 properties: Self::Properties,
243 context: SourceEnumeratorContextRef,
244 ) -> ConnectorResult<Self> {
245 Ok(Self::new_inner(properties, context))
246 }
247
248 async fn list_splits(&mut self) -> ConnectorResult<Vec<Self::Split>> {
249 Ok(vec![])
253 }
254}
255impl IcebergSplitEnumerator {
256 pub fn new_inner(properties: IcebergProperties, _context: SourceEnumeratorContextRef) -> Self {
257 Self { config: properties }
258 }
259}
260
261#[derive(Debug, Clone, PartialEq, Eq, Hash)]
262pub enum IcebergTimeTravelInfo {
263 Version(i64),
264 TimestampMs(i64),
265}
266
267#[derive(Debug, Clone)]
268pub struct IcebergListResult {
269 pub data_files: Vec<FileScanTask>,
270 pub equality_delete_files: Vec<FileScanTask>,
271 pub position_delete_files: Vec<FileScanTask>,
272 pub equality_delete_columns: Vec<String>,
273 pub format_version: FormatVersion,
274 pub schema: std::sync::Arc<iceberg::spec::Schema>,
275}
276
277impl IcebergSplitEnumerator {
278 pub fn get_snapshot_id(
279 table: &Table,
280 time_travel_info: Option<IcebergTimeTravelInfo>,
281 ) -> ConnectorResult<Option<i64>> {
282 let current_snapshot = table.metadata().current_snapshot();
283 let Some(current_snapshot) = current_snapshot else {
284 return Ok(None);
285 };
286
287 let snapshot_id = match time_travel_info {
288 Some(IcebergTimeTravelInfo::Version(version)) => {
289 let Some(snapshot) = table.metadata().snapshot_by_id(version) else {
290 bail!("Cannot find the snapshot id in the iceberg table.");
291 };
292 snapshot.snapshot_id()
293 }
294 Some(IcebergTimeTravelInfo::TimestampMs(timestamp)) => {
295 let snapshot = table
296 .metadata()
297 .snapshots()
298 .filter(|snapshot| snapshot.timestamp_ms() <= timestamp)
299 .max_by_key(|snapshot| snapshot.timestamp_ms());
300 match snapshot {
301 Some(snapshot) => snapshot.snapshot_id(),
302 None => {
303 let time = chrono::DateTime::from_timestamp_millis(timestamp);
305 if let Some(time) = time {
306 tracing::warn!("Cannot find a snapshot older than {}", time);
307 } else {
308 tracing::warn!("Cannot find a snapshot");
309 }
310 return Ok(None);
311 }
312 }
313 }
314 None => current_snapshot.snapshot_id(),
315 };
316 Ok(Some(snapshot_id))
317 }
318
319 pub async fn list_scan_tasks(
320 &self,
321 time_travel_info: Option<IcebergTimeTravelInfo>,
322 predicate: IcebergPredicate,
323 ) -> ConnectorResult<Option<IcebergListResult>> {
324 let table = self.config.load_table().await?;
325 let snapshot_id = Self::get_snapshot_id(&table, time_travel_info)?;
326
327 let Some(snapshot_id) = snapshot_id else {
328 return Ok(None);
329 };
330 let res = self
331 .list_scan_tasks_inner(&table, snapshot_id, predicate)
332 .await?;
333 Ok(Some(res))
334 }
335
336 async fn list_scan_tasks_inner(
337 &self,
338 table: &Table,
339 snapshot_id: i64,
340 predicate: IcebergPredicate,
341 ) -> ConnectorResult<IcebergListResult> {
342 let format_version = table.metadata().format_version();
343 let table_schema = table.metadata().current_schema();
344 tracing::debug!("iceberg_table_schema: {:?}", table_schema);
345
346 let mut position_delete_files = vec![];
347 let mut position_delete_files_set = HashSet::new();
348 let mut data_files = vec![];
349 let mut equality_delete_files = vec![];
350 let mut equality_delete_files_set = HashSet::new();
351 let mut equality_delete_ids = None;
352 let mut scan_builder = table.scan().snapshot_id(snapshot_id).select_all();
353 if predicate != IcebergPredicate::AlwaysTrue {
354 scan_builder = scan_builder.with_filter(predicate.clone());
355 }
356 let scan = scan_builder.build()?;
357 let file_scan_stream = scan.plan_files().await?;
358
359 #[for_await]
360 for task in file_scan_stream {
361 let task: FileScanTask = task?;
362
363 for delete_file in &task.deletes {
365 let delete_file = delete_file.as_ref().clone();
366 match delete_file.data_file_content {
367 iceberg::spec::DataContentType::Data => {
368 bail!("Data file should not in task deletes");
369 }
370 iceberg::spec::DataContentType::EqualityDeletes => {
371 if equality_delete_files_set.insert(delete_file.data_file_path.clone()) {
372 if equality_delete_ids.is_none() {
373 equality_delete_ids = delete_file.equality_ids.clone();
374 } else if equality_delete_ids != delete_file.equality_ids {
375 bail!(
376 "The schema of iceberg equality delete file must be consistent"
377 );
378 }
379 equality_delete_files.push(delete_file);
380 }
381 }
382 iceberg::spec::DataContentType::PositionDeletes => {
383 if position_delete_files_set.insert(delete_file.data_file_path.clone()) {
384 position_delete_files.push(delete_file);
385 }
386 }
387 }
388 }
389
390 match task.data_file_content {
391 iceberg::spec::DataContentType::Data => {
392 data_files.push(task);
394 }
395 iceberg::spec::DataContentType::EqualityDeletes => {
396 bail!("Equality delete files should not be in the data files");
397 }
398 iceberg::spec::DataContentType::PositionDeletes => {
399 bail!("Position delete files should not be in the data files");
400 }
401 }
402 }
403 let schema = table_schema.clone();
404 let equality_delete_columns = equality_delete_ids
405 .unwrap_or_default()
406 .into_iter()
407 .map(|id| match schema.name_by_field_id(id) {
408 Some(name) => Ok::<std::string::String, ConnectorError>(name.to_owned()),
409 None => bail!("Delete field id {} not found in schema", id),
410 })
411 .collect::<ConnectorResult<Vec<_>>>()?;
412
413 Ok(IcebergListResult {
414 data_files,
415 equality_delete_files,
416 position_delete_files,
417 equality_delete_columns,
418 format_version,
419 schema,
420 })
421 }
422
423 pub fn split_n_vecs(
436 file_scan_tasks: Vec<FileScanTask>,
437 split_num: usize,
438 ) -> Vec<Vec<FileScanTask>> {
439 IcebergScanTaskPlanner::split_n_vecs(file_scan_tasks, split_num)
440 }
441}
442
443pub struct IcebergScanOpts {
444 pub chunk_size: usize,
445 pub need_seq_num: bool,
446 pub need_file_path_and_pos: bool,
447 pub handle_delete_files: bool,
448}
449
450#[try_stream(ok = DataChunk, error = ConnectorError)]
452pub async fn scan_task_to_chunk_with_deletes(
453 table: Table,
454 mut data_file_scan_task: FileScanTask,
455 IcebergScanOpts {
456 chunk_size,
457 need_seq_num,
458 need_file_path_and_pos,
459 handle_delete_files,
460 }: IcebergScanOpts,
461 metrics: Option<Arc<IcebergScanMetrics>>,
462) {
463 let table_name = table.identifier().name().to_owned();
464
465 let num_delete_files = data_file_scan_task.deletes.len();
466 let expected_record_count = data_file_scan_task.record_count;
467 let file_start = std::time::Instant::now();
468
469 let mut read_bytes = scopeguard::guard(0u64, |read_bytes| {
470 if let Some(metrics) = metrics.clone() {
471 metrics
472 .iceberg_read_bytes
473 .with_guarded_label_values(&[&table_name])
474 .inc_by(read_bytes as _);
475 }
476 });
477
478 let data_file_path = data_file_scan_task.data_file_path.clone();
479 let data_sequence_number = data_file_scan_task.sequence_number;
480
481 tracing::debug!(
482 "scan_task_to_chunk_with_deletes: data_file={}, handle_delete_files={}, total_delete_files={}",
483 data_file_path,
484 handle_delete_files,
485 data_file_scan_task.deletes.len()
486 );
487
488 if !handle_delete_files {
489 data_file_scan_task.deletes.clear();
491 }
492
493 let reader = table
495 .reader_builder()
496 .with_batch_size(chunk_size)
497 .with_row_group_filtering_enabled(true)
498 .build();
499 let file_scan_stream = tokio_stream::once(Ok(data_file_scan_task));
500
501 let record_batch_stream: iceberg::scan::ArrowRecordBatchStream =
502 reader.read(Box::pin(file_scan_stream))?;
503 let mut record_batch_stream = record_batch_stream.enumerate();
504
505 let mut total_rows_read: u64 = 0;
506
507 while let Some((batch_index, record_batch)) = record_batch_stream.next().await {
509 let record_batch = record_batch?;
510 let batch_start_pos = (batch_index * chunk_size) as i64;
511
512 let mut chunk = IcebergArrowConvert.chunk_from_record_batch(&record_batch)?;
513 let row_count = chunk.capacity();
514 total_rows_read += row_count as u64;
515
516 if need_seq_num {
518 let (mut columns, visibility) = chunk.into_parts();
519 columns.push(Arc::new(ArrayImpl::Int64(I64Array::from_iter(
520 std::iter::repeat_n(data_sequence_number, row_count),
521 ))));
522 chunk = DataChunk::from_parts(columns.into(), visibility);
523 }
524
525 if need_file_path_and_pos {
526 let (mut columns, visibility) = chunk.into_parts();
527 columns.push(Arc::new(ArrayImpl::Utf8(Utf8Array::from_iter(
528 std::iter::repeat_n(data_file_path.as_str(), row_count),
529 ))));
530
531 let positions: Vec<i64> =
533 (batch_start_pos..(batch_start_pos + row_count as i64)).collect();
534 columns.push(Arc::new(ArrayImpl::Int64(I64Array::from_iter(positions))));
535
536 chunk = DataChunk::from_parts(columns.into(), visibility);
537 }
538
539 *read_bytes += chunk.estimated_heap_size() as u64;
540 yield chunk;
541 }
542
543 if let Some(ref metrics) = metrics {
545 let label_values = [table_name.as_str()];
546
547 metrics
549 .iceberg_source_file_read_duration_seconds
550 .with_guarded_label_values(&label_values)
551 .observe(file_start.elapsed().as_secs_f64());
552
553 if total_rows_read > 0 {
555 metrics
556 .iceberg_source_rows_read_total
557 .with_guarded_label_values(&label_values)
558 .inc_by(total_rows_read);
559 }
560
561 metrics
563 .iceberg_source_files_read_total
564 .with_guarded_label_values(&[table_name.as_str(), "data"])
565 .inc();
566
567 if handle_delete_files
572 && num_delete_files > 0
573 && let Some(expected) = expected_record_count
574 {
575 let deleted = expected.saturating_sub(total_rows_read);
576 if deleted > 0 {
577 metrics
578 .iceberg_source_delete_rows_applied_total
579 .with_guarded_label_values(&[table_name.as_str(), "sdk_applied_approx"])
580 .inc_by(deleted);
581 }
582 }
583 }
584}
585
586#[derive(Debug)]
587pub struct IcebergFileReader {}
588
589#[async_trait]
590impl SplitReader for IcebergFileReader {
591 type Properties = IcebergProperties;
592 type Split = IcebergSplit;
593
594 async fn new(
595 _props: IcebergProperties,
596 _splits: Vec<IcebergSplit>,
597 _parser_config: ParserConfig,
598 _source_ctx: SourceContextRef,
599 _columns: Option<Vec<Column>>,
600 ) -> ConnectorResult<Self> {
601 unimplemented!()
602 }
603
604 fn into_stream(self) -> BoxSourceChunkStream {
605 unimplemented!()
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use std::sync::Arc;
612
613 use iceberg::scan::FileScanTask;
614 use iceberg::spec::{DataContentType, Schema};
615
616 use super::*;
617
618 fn create_file_scan_task(length: u64, id: u64) -> FileScanTask {
619 FileScanTask {
620 length,
621 start: 0,
622 record_count: Some(0),
623 data_file_path: format!("test_{}.parquet", id),
624 referenced_data_file: None,
625 data_file_content: DataContentType::Data,
626 data_file_format: iceberg::spec::DataFileFormat::Parquet,
627 schema: Arc::new(Schema::builder().build().unwrap()),
628 project_field_ids: vec![],
629 predicate: None,
630 deletes: vec![],
631 sequence_number: 0,
632 equality_ids: None,
633 file_size_in_bytes: 0,
634 partition: None,
635 partition_spec: None,
636 name_mapping: None,
637 case_sensitive: true,
638 }
639 }
640
641 #[test]
642 fn test_split_n_vecs_basic() {
643 let file_scan_tasks = (1..=12)
644 .map(|i| create_file_scan_task(i + 100, i))
645 .collect::<Vec<_>>(); let groups = IcebergSplitEnumerator::split_n_vecs(file_scan_tasks, 3);
648
649 assert_eq!(groups.len(), 3);
650
651 let group_lengths: Vec<u64> = groups
652 .iter()
653 .map(|group| group.iter().map(|task| task.length).sum())
654 .collect();
655
656 let max_length = *group_lengths.iter().max().unwrap();
657 let min_length = *group_lengths.iter().min().unwrap();
658 assert!(max_length - min_length <= 10, "Groups should be balanced");
659
660 let total_tasks: usize = groups.iter().map(|group| group.len()).sum();
661 assert_eq!(total_tasks, 12);
662 }
663
664 #[test]
665 fn test_split_n_vecs_empty() {
666 let file_scan_tasks = Vec::new();
667 let groups = IcebergSplitEnumerator::split_n_vecs(file_scan_tasks, 3);
668 assert_eq!(groups.len(), 3);
669 assert!(groups.iter().all(|group| group.is_empty()));
670 }
671
672 #[test]
673 fn test_split_n_vecs_single_task() {
674 let file_scan_tasks = vec![create_file_scan_task(100, 1)];
675 let groups = IcebergSplitEnumerator::split_n_vecs(file_scan_tasks, 3);
676 assert_eq!(groups.len(), 3);
677 assert_eq!(groups.iter().filter(|group| !group.is_empty()).count(), 1);
678 }
679
680 #[test]
681 fn test_split_n_vecs_uneven_distribution() {
682 let file_scan_tasks = vec![
683 create_file_scan_task(1000, 1),
684 create_file_scan_task(100, 2),
685 create_file_scan_task(100, 3),
686 create_file_scan_task(100, 4),
687 create_file_scan_task(100, 5),
688 ];
689
690 let groups = IcebergSplitEnumerator::split_n_vecs(file_scan_tasks, 2);
691 assert_eq!(groups.len(), 2);
692
693 let group_with_large_task = groups
694 .iter()
695 .find(|group| group.iter().any(|task| task.length == 1000))
696 .unwrap();
697 assert_eq!(group_with_large_task.len(), 1);
698 }
699
700 #[test]
701 fn test_split_n_vecs_same_files_distribution() {
702 let file_scan_tasks = vec![
703 create_file_scan_task(100, 1),
704 create_file_scan_task(100, 2),
705 create_file_scan_task(100, 3),
706 create_file_scan_task(100, 4),
707 create_file_scan_task(100, 5),
708 create_file_scan_task(100, 6),
709 create_file_scan_task(100, 7),
710 create_file_scan_task(100, 8),
711 ];
712
713 let groups = IcebergSplitEnumerator::split_n_vecs(file_scan_tasks.clone(), 4)
714 .iter()
715 .map(|g| {
716 g.iter()
717 .map(|task| task.data_file_path.clone())
718 .collect::<Vec<_>>()
719 })
720 .collect::<Vec<_>>();
721
722 for _ in 0..10000 {
723 let groups_2 = IcebergSplitEnumerator::split_n_vecs(file_scan_tasks.clone(), 4)
724 .iter()
725 .map(|g| {
726 g.iter()
727 .map(|task| task.data_file_path.clone())
728 .collect::<Vec<_>>()
729 })
730 .collect::<Vec<_>>();
731
732 assert_eq!(groups, groups_2);
733 }
734 }
735}