1pub mod parquet_file_handler;
16pub mod planner;
17pub mod update_planner;
18pub mod update_reader;
19pub mod update_state;
20
21pub mod metrics;
22use std::collections::{HashMap, HashSet};
23use std::sync::Arc;
24
25use anyhow::anyhow;
26use async_trait::async_trait;
27use futures::StreamExt;
28use futures_async_stream::{for_await, try_stream};
29use iceberg::Catalog;
30use iceberg::expr::{BoundPredicate, Predicate as IcebergPredicate};
31use iceberg::scan::FileScanTask;
32use iceberg::spec::{FormatVersion, Schema, TableMetadata};
33use iceberg::table::Table;
34pub use parquet_file_handler::*;
35use phf::{Set, phf_set};
36pub use planner::{
37 IcebergIncrementalScan, IcebergScanMetricsLabels, IcebergScanPlan, IcebergScanPlanner,
38 IcebergScanProjection, IcebergScanTaskBatchMode, IcebergScanTaskPlanner, PersistedFileScanTask,
39};
40use risingwave_common::array::arrow::IcebergArrowConvert;
41use risingwave_common::array::{
42 ArrayBuilder, ArrayImpl, DataChunk, I64Array, Utf8Array, VariantArrayBuilder,
43};
44use risingwave_common::bail;
45use risingwave_common::types::JsonbVal;
46use risingwave_common_estimate_size::EstimateSize;
47use risingwave_pb::batch_plan::iceberg_scan_node::IcebergScanType;
48use serde::{Deserialize, Serialize};
49
50pub use self::metrics::{GLOBAL_ICEBERG_SCAN_METRICS, IcebergFileScanMetrics, IcebergScanMetrics};
51use crate::connector_common::{
52 IcebergCommon, IcebergTableIdentifier, iceberg_java_catalog_props_from_options,
53};
54use crate::enforce_secret::{EnforceSecret, EnforceSecretError};
55use crate::error::{ConnectorError, ConnectorResult};
56use crate::parser::ParserConfig;
57use crate::source::{
58 BoxSourceChunkStream, Column, SourceContextRef, SourceEnumeratorContextRef, SourceProperties,
59 SplitEnumerator, SplitId, SplitMetaData, SplitReader, UnknownFields,
60};
61pub const ICEBERG_CONNECTOR: &str = "iceberg";
62pub const STREAMING_UPDATES_KEY: &str = "streaming_updates";
63
64fn validate_update_projection(schema: &Schema, field_ids: &[i32]) -> ConnectorResult<()> {
65 let mut ids = HashSet::new();
66 for id in field_ids {
67 risingwave_common::ensure!(
68 ids.insert(*id)
69 && !iceberg::metadata_columns::is_metadata_field(*id)
70 && schema
71 .as_struct()
72 .fields()
73 .iter()
74 .any(|field| field.id == *id),
75 "update projection requires unique stored top-level fields, not virtual metadata"
76 );
77 }
78 Ok(())
79}
80
81#[derive(Clone, Debug, Deserialize, with_options::WithOptions)]
82pub struct IcebergProperties {
83 #[serde(default, deserialize_with = "crate::deserialize_bool_from_string")]
85 pub streaming_updates: bool,
86
87 #[serde(flatten)]
88 pub common: IcebergCommon,
89
90 #[serde(flatten)]
91 pub table: IcebergTableIdentifier,
92
93 #[serde(rename = "catalog.jdbc.user")]
95 pub jdbc_user: Option<String>,
96 #[serde(rename = "catalog.jdbc.password")]
97 pub jdbc_password: Option<String>,
98
99 #[serde(flatten)]
100 pub unknown_fields: HashMap<String, String>,
101}
102
103impl EnforceSecret for IcebergProperties {
104 const ENFORCE_SECRET_PROPERTIES: Set<&'static str> = phf_set! {
105 "catalog.jdbc.password",
106 };
107
108 fn enforce_secret<'a>(prop_iter: impl Iterator<Item = &'a str>) -> ConnectorResult<()> {
109 for prop in prop_iter {
110 IcebergCommon::enforce_one(prop)?;
111 if Self::ENFORCE_SECRET_PROPERTIES.contains(prop) {
112 return Err(EnforceSecretError {
113 key: prop.to_owned(),
114 }
115 .into());
116 }
117 }
118 Ok(())
119 }
120}
121
122impl IcebergProperties {
123 fn java_catalog_props(&self) -> HashMap<String, String> {
124 let mut java_catalog_props = iceberg_java_catalog_props_from_options(
125 self.unknown_fields
126 .iter()
127 .map(|(key, value)| (key.as_str(), value.as_str())),
128 );
129 if let Some(jdbc_user) = self.jdbc_user.clone() {
130 java_catalog_props.insert("jdbc.user".to_owned(), jdbc_user);
131 }
132 if let Some(jdbc_password) = self.jdbc_password.clone() {
133 java_catalog_props.insert("jdbc.password".to_owned(), jdbc_password);
134 }
135 java_catalog_props
136 }
137
138 pub async fn create_catalog(&self) -> ConnectorResult<Arc<dyn Catalog>> {
139 self.common
140 .resolve_catalog_config(self.java_catalog_props())?
141 .create_catalog()
142 .await
143 }
144
145 pub async fn load_table(&self) -> ConnectorResult<Table> {
146 self.common
147 .resolve_catalog_config(self.java_catalog_props())?
148 .load_table(&self.table)
149 .await
150 }
151}
152
153impl SourceProperties for IcebergProperties {
154 type Split = IcebergSplit;
155 type SplitEnumerator = IcebergSplitEnumerator;
156 type SplitReader = IcebergFileReader;
157
158 const SOURCE_NAME: &'static str = ICEBERG_CONNECTOR;
159}
160
161impl UnknownFields for IcebergProperties {
162 fn unknown_fields(&self) -> HashMap<String, String> {
163 self.unknown_fields.clone()
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
168pub enum IcebergFileScanTask {
169 Data(Vec<FileScanTask>),
170 EqualityDelete(Vec<FileScanTask>),
171 PositionDelete(Vec<FileScanTask>),
172}
173
174impl IcebergFileScanTask {
175 pub fn tasks(&self) -> &[FileScanTask] {
176 match self {
177 IcebergFileScanTask::Data(file_scan_tasks)
178 | IcebergFileScanTask::EqualityDelete(file_scan_tasks)
179 | IcebergFileScanTask::PositionDelete(file_scan_tasks) => file_scan_tasks,
180 }
181 }
182
183 pub fn is_empty(&self) -> bool {
184 self.tasks().is_empty()
185 }
186
187 pub fn files(&self) -> Vec<String> {
188 self.tasks()
189 .iter()
190 .map(|task| task.data_file_path.clone())
191 .collect()
192 }
193
194 pub fn predicate(&self) -> Option<&BoundPredicate> {
195 let first_task = self.tasks().first()?;
196 first_task.predicate.as_ref()
197 }
198
199 fn strip_non_serializable_planning_context(&mut self) {
200 let tasks = match self {
201 IcebergFileScanTask::Data(tasks)
202 | IcebergFileScanTask::EqualityDelete(tasks)
203 | IcebergFileScanTask::PositionDelete(tasks) => tasks,
204 };
205
206 for task in tasks {
207 task.partition = None;
210 task.partition_spec = None;
211 task.name_mapping = None;
212 task.unified_partition_type = None;
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218pub struct IcebergSplit {
219 pub split_id: i64,
220 pub task: IcebergFileScanTask,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub limit: Option<u64>,
223}
224
225impl IcebergSplit {
226 #[allow(deprecated)]
227 pub fn empty(iceberg_scan_type: IcebergScanType) -> Self {
228 let task = match iceberg_scan_type {
229 IcebergScanType::DataScan => IcebergFileScanTask::Data(vec![]),
230 IcebergScanType::EqualityDeleteScan => IcebergFileScanTask::EqualityDelete(vec![]),
231 IcebergScanType::PositionDeleteScan => IcebergFileScanTask::PositionDelete(vec![]),
232 IcebergScanType::Unspecified | IcebergScanType::CountStar => {
233 IcebergFileScanTask::Data(vec![])
236 }
237 };
238 Self {
239 split_id: 0,
240 task,
241 limit: None,
242 }
243 }
244}
245
246impl SplitMetaData for IcebergSplit {
247 fn id(&self) -> SplitId {
248 self.split_id.to_string().into()
249 }
250
251 fn restore_from_json(value: JsonbVal) -> ConnectorResult<Self> {
252 serde_json::from_value(value.take()).map_err(|e| anyhow!(e).into())
253 }
254
255 fn encode_to_json(&self) -> JsonbVal {
256 let mut split = self.clone();
257 split.task.strip_non_serializable_planning_context();
258 serde_json::to_value(split)
259 .expect("iceberg split serialization should not fail")
260 .into()
261 }
262
263 fn update_offset(&mut self, _last_seen_offset: String) -> ConnectorResult<()> {
264 Ok(())
267 }
268}
269
270#[derive(Debug, Clone)]
271pub struct IcebergSplitEnumerator {
272 config: IcebergProperties,
273}
274
275#[derive(Debug, Clone)]
276pub struct IcebergDeleteParameters {
277 pub equality_delete_columns: Vec<String>,
278 pub has_position_delete: bool,
279 pub snapshot_id: Option<i64>,
280}
281
282#[async_trait]
283impl SplitEnumerator for IcebergSplitEnumerator {
284 type Properties = IcebergProperties;
285 type Split = IcebergSplit;
286
287 async fn new(
288 properties: Self::Properties,
289 context: SourceEnumeratorContextRef,
290 ) -> ConnectorResult<Self> {
291 Ok(Self::new_inner(properties, context))
292 }
293
294 async fn list_splits(&mut self) -> ConnectorResult<Vec<Self::Split>> {
295 Ok(vec![])
299 }
300}
301impl IcebergSplitEnumerator {
302 pub fn new_inner(properties: IcebergProperties, _context: SourceEnumeratorContextRef) -> Self {
303 Self { config: properties }
304 }
305}
306
307#[derive(Debug, Clone, PartialEq, Eq, Hash)]
308pub enum IcebergTimeTravelInfo {
309 Version(i64),
310 TimestampMs(i64),
311}
312
313#[derive(Debug, Clone)]
314pub struct IcebergListResult {
315 pub data_files: Vec<FileScanTask>,
316 pub equality_delete_files: Vec<FileScanTask>,
317 pub position_delete_files: Vec<FileScanTask>,
318 pub equality_delete_columns: Vec<String>,
319 pub format_version: FormatVersion,
320 pub schema: std::sync::Arc<iceberg::spec::Schema>,
321}
322
323impl IcebergSplitEnumerator {
324 pub fn get_snapshot_id(
325 table: &Table,
326 time_travel_info: Option<IcebergTimeTravelInfo>,
327 ) -> ConnectorResult<Option<i64>> {
328 Self::get_snapshot_id_from_metadata(table.metadata(), time_travel_info)
329 }
330
331 fn get_snapshot_id_from_metadata(
332 metadata: &TableMetadata,
333 time_travel_info: Option<IcebergTimeTravelInfo>,
334 ) -> ConnectorResult<Option<i64>> {
335 let snapshot_id = match time_travel_info {
336 Some(IcebergTimeTravelInfo::Version(version)) => {
337 let Some(snapshot) = metadata.snapshot_by_id(version) else {
338 bail!("Cannot find the snapshot id in the iceberg table.");
339 };
340 Some(snapshot.snapshot_id())
341 }
342 Some(IcebergTimeTravelInfo::TimestampMs(timestamp)) => {
343 let snapshot_log = metadata
344 .history()
345 .iter()
346 .rev()
347 .find(|snapshot_log| snapshot_log.timestamp_ms() <= timestamp);
348 match snapshot_log {
349 Some(snapshot_log) => Some(snapshot_log.snapshot_id),
350 None => {
351 let time = chrono::DateTime::from_timestamp_millis(timestamp);
353 if let Some(time) = time {
354 tracing::warn!("Cannot find a snapshot older than {}", time);
355 } else {
356 tracing::warn!("Cannot find a snapshot");
357 }
358 return Ok(None);
359 }
360 }
361 }
362 None => metadata.current_snapshot_id(),
363 };
364 Ok(snapshot_id)
365 }
366
367 pub async fn list_scan_tasks(
368 &self,
369 time_travel_info: Option<IcebergTimeTravelInfo>,
370 predicate: IcebergPredicate,
371 ) -> ConnectorResult<Option<IcebergListResult>> {
372 let table = self.config.load_table().await?;
373 let snapshot_id = Self::get_snapshot_id(&table, time_travel_info)?;
374
375 let Some(snapshot_id) = snapshot_id else {
376 return Ok(None);
377 };
378 let res = self
379 .list_scan_tasks_inner(&table, snapshot_id, predicate)
380 .await?;
381 Ok(Some(res))
382 }
383
384 async fn list_scan_tasks_inner(
385 &self,
386 table: &Table,
387 snapshot_id: i64,
388 predicate: IcebergPredicate,
389 ) -> ConnectorResult<IcebergListResult> {
390 let format_version = table.metadata().format_version();
391 let table_schema = table.metadata().current_schema();
392 tracing::debug!("iceberg_table_schema: {:?}", table_schema);
393
394 let mut position_delete_files = vec![];
395 let mut position_delete_files_set = HashSet::new();
396 let mut data_files = vec![];
397 let mut equality_delete_files = vec![];
398 let mut equality_delete_files_set = HashSet::new();
399 let mut equality_delete_ids = None;
400 let mut scan_builder = table.scan().snapshot_id(snapshot_id).select_all();
401 if predicate != IcebergPredicate::AlwaysTrue {
402 scan_builder = scan_builder.with_filter(predicate.clone());
403 }
404 let scan = scan_builder.build()?;
405 let file_scan_stream = scan.plan_files().await?;
406
407 #[for_await]
408 for task in file_scan_stream {
409 let task: FileScanTask = task?;
410
411 for delete_file in &task.deletes {
413 match delete_file.file_type {
414 iceberg::spec::DataContentType::Data => {
415 bail!("Data file should not in task deletes");
416 }
417 iceberg::spec::DataContentType::EqualityDeletes => {
418 if equality_delete_files_set.insert(delete_file.file_path.clone()) {
419 if equality_delete_ids.is_none() {
420 equality_delete_ids = delete_file.equality_ids.clone();
421 } else if equality_delete_ids != delete_file.equality_ids {
422 bail!(
423 "The schema of iceberg equality delete file must be consistent"
424 );
425 }
426 equality_delete_files.push(delete_file.to_file_scan_task(&task));
427 }
428 }
429 iceberg::spec::DataContentType::PositionDeletes => {
430 if position_delete_files_set.insert(delete_file.file_path.clone()) {
431 position_delete_files.push(delete_file.to_file_scan_task(&task));
432 }
433 }
434 }
435 }
436
437 data_files.push(task);
440 }
441 let schema = table_schema.clone();
442 let equality_delete_columns = equality_delete_ids
443 .unwrap_or_default()
444 .into_iter()
445 .map(|id| match schema.name_by_field_id(id) {
446 Some(name) => Ok::<std::string::String, ConnectorError>(name.to_owned()),
447 None => bail!("Delete field id {} not found in schema", id),
448 })
449 .collect::<ConnectorResult<Vec<_>>>()?;
450
451 Ok(IcebergListResult {
452 data_files,
453 equality_delete_files,
454 position_delete_files,
455 equality_delete_columns,
456 format_version,
457 schema,
458 })
459 }
460
461 pub fn split_n_vecs(
474 file_scan_tasks: Vec<FileScanTask>,
475 split_num: usize,
476 ) -> Vec<Vec<FileScanTask>> {
477 IcebergScanTaskPlanner::split_n_vecs(file_scan_tasks, split_num)
478 }
479}
480
481pub struct IcebergScanOpts {
482 pub chunk_size: usize,
483 pub need_seq_num: bool,
484 pub need_file_path_and_pos: bool,
485 pub handle_delete_files: bool,
486}
487
488#[try_stream(ok = DataChunk, error = ConnectorError)]
490pub async fn scan_task_to_chunk_with_deletes(
491 table: Table,
492 mut data_file_scan_task: FileScanTask,
493 IcebergScanOpts {
494 chunk_size,
495 need_seq_num,
496 need_file_path_and_pos,
497 handle_delete_files,
498 }: IcebergScanOpts,
499 metrics: Option<IcebergFileScanMetrics>,
500) {
501 let num_delete_files = data_file_scan_task.deletes.len();
502 let expected_record_count = data_file_scan_task.record_count;
503 let file_start = std::time::Instant::now();
504
505 let read_metrics = metrics.clone();
506 let mut read_bytes = scopeguard::guard(0u64, move |read_bytes| {
507 if let Some(metrics) = read_metrics {
508 metrics.record_read_bytes(read_bytes);
509 }
510 });
511
512 let data_file_path = data_file_scan_task.data_file_path.clone();
513 let data_sequence_number = data_file_scan_task.sequence_number;
514
515 tracing::debug!(
516 "scan_task_to_chunk_with_deletes: data_file={}, handle_delete_files={}, total_delete_files={}",
517 data_file_path,
518 handle_delete_files,
519 data_file_scan_task.deletes.len()
520 );
521
522 if !handle_delete_files {
523 data_file_scan_task.deletes.clear();
525 }
526
527 let reader = table
529 .reader_builder()
530 .with_batch_size(chunk_size)
531 .with_row_group_filtering_enabled(true)
532 .build();
533 let file_scan_stream = tokio_stream::once(Ok(data_file_scan_task.clone()));
534
535 let mut record_batch_stream: iceberg::scan::ArrowRecordBatchStream =
536 reader.read(Box::pin(file_scan_stream))?.stream();
537
538 let mut null_padded_variant_positions: Option<Vec<usize>> = None;
541 let record_batch_stream: iceberg::scan::ArrowRecordBatchStream =
542 match record_batch_stream.next().await {
543 Some(Err(e)) if is_shredded_variant_rejection(&e) => {
544 let (variant_field_ids, variant_positions, variant_names) =
545 projected_variant_columns(&data_file_scan_task);
546 if variant_field_ids.is_empty() {
547 return Err(e.into());
548 }
549 tracing::warn!(
550 data_file_path,
551 columns = ?variant_names,
552 "shredded variant columns are not supported yet; reading them as NULL",
553 );
554 null_padded_variant_positions = Some(variant_positions);
555
556 let mut reduced_task = data_file_scan_task;
557 reduced_task
558 .project_field_ids
559 .retain(|id| !variant_field_ids.contains(id));
560 let reader = table
561 .reader_builder()
562 .with_batch_size(chunk_size)
563 .with_row_group_filtering_enabled(true)
564 .build();
565 reader
566 .read(Box::pin(tokio_stream::once(Ok(reduced_task))))?
567 .stream()
568 }
569 first => Box::pin(futures::stream::iter(first).chain(record_batch_stream)),
570 };
571 let mut record_batch_stream = record_batch_stream.enumerate();
572
573 let mut total_rows_read: u64 = 0;
574
575 while let Some((batch_index, record_batch)) = record_batch_stream.next().await {
577 let record_batch = record_batch?;
578 let batch_start_pos = (batch_index * chunk_size) as i64;
579
580 let mut chunk = IcebergArrowConvert.chunk_from_record_batch(&record_batch)?;
581 if let Some(positions) = &null_padded_variant_positions {
582 chunk = pad_null_variant_columns(chunk, positions, record_batch.num_rows());
583 }
584 let row_count = chunk.capacity();
585 total_rows_read += row_count as u64;
586
587 if need_seq_num {
589 let (mut columns, visibility) = chunk.into_parts();
590 columns.push(Arc::new(ArrayImpl::Int64(I64Array::from_iter(
591 std::iter::repeat_n(data_sequence_number, row_count),
592 ))));
593 chunk = DataChunk::from_parts(columns.into(), visibility);
594 }
595
596 if need_file_path_and_pos {
597 let (mut columns, visibility) = chunk.into_parts();
598 columns.push(Arc::new(ArrayImpl::Utf8(Utf8Array::from_iter(
599 std::iter::repeat_n(data_file_path.as_str(), row_count),
600 ))));
601
602 let positions: Vec<i64> =
604 (batch_start_pos..(batch_start_pos + row_count as i64)).collect();
605 columns.push(Arc::new(ArrayImpl::Int64(I64Array::from_iter(positions))));
606
607 chunk = DataChunk::from_parts(columns.into(), visibility);
608 }
609
610 *read_bytes += chunk.estimated_heap_size() as u64;
611 yield chunk;
612 }
613
614 if let Some(metrics) = metrics {
616 metrics.record_file_read_duration(file_start.elapsed().as_secs_f64());
617
618 if total_rows_read > 0 {
619 metrics.record_rows_read(total_rows_read);
620 }
621
622 metrics.record_file_read();
623
624 if handle_delete_files
629 && num_delete_files > 0
630 && let Some(expected) = expected_record_count
631 {
632 let deleted = expected.saturating_sub(total_rows_read);
633 if deleted > 0 {
634 metrics.record_delete_rows_applied(deleted);
635 }
636 }
637 }
638}
639
640#[expect(clippy::disallowed_types)]
643fn is_shredded_variant_rejection(e: &iceberg::Error) -> bool {
644 e.kind() == iceberg::ErrorKind::FeatureUnsupported && e.message().contains("shredded variant")
645}
646
647fn projected_variant_columns(task: &FileScanTask) -> (Vec<i32>, Vec<usize>, Vec<String>) {
650 let mut field_ids = Vec::new();
651 let mut positions = Vec::new();
652 let mut names = Vec::new();
653 for (position, field_id) in task.project_field_ids.iter().enumerate() {
654 if let Some(field) = task.schema.field_by_id(*field_id)
655 && matches!(field.field_type.as_ref(), iceberg::spec::Type::Variant(_))
656 {
657 field_ids.push(*field_id);
658 positions.push(position);
659 names.push(field.name.clone());
660 }
661 }
662 (field_ids, positions, names)
663}
664
665fn pad_null_variant_columns(chunk: DataChunk, positions: &[usize], row_count: usize) -> DataChunk {
667 let (mut columns, visibility) = chunk.into_parts();
668 for &position in positions {
669 let mut builder = VariantArrayBuilder::new(row_count);
670 for _ in 0..row_count {
671 builder.append_null();
672 }
673 columns.insert(position, Arc::new(ArrayImpl::Variant(builder.finish())));
674 }
675 DataChunk::from_parts(columns.into(), visibility)
676}
677
678#[derive(Debug)]
679pub struct IcebergFileReader {}
680
681#[async_trait]
682impl SplitReader for IcebergFileReader {
683 type Properties = IcebergProperties;
684 type Split = IcebergSplit;
685
686 async fn new(
687 _props: IcebergProperties,
688 _splits: Vec<IcebergSplit>,
689 _parser_config: ParserConfig,
690 _source_ctx: SourceContextRef,
691 _columns: Option<Vec<Column>>,
692 ) -> ConnectorResult<Self> {
693 unimplemented!()
694 }
695
696 fn into_stream(self) -> BoxSourceChunkStream {
697 unimplemented!()
698 }
699}
700
701#[cfg(test)]
702mod tests {
703 use std::collections::HashMap;
704 use std::sync::Arc;
705
706 use iceberg::scan::FileScanTask;
707 use iceberg::spec::{
708 FormatVersion, MAIN_BRANCH, NestedField, Operation, PrimitiveType, Schema, Snapshot,
709 SortOrder, Struct, Summary, TableMetadataBuilder, Type, UnboundPartitionSpec,
710 };
711
712 use super::*;
713
714 fn test_snapshot(
715 snapshot_id: i64,
716 parent_snapshot_id: Option<i64>,
717 timestamp_ms: i64,
718 ) -> Snapshot {
719 Snapshot::builder()
720 .with_snapshot_id(snapshot_id)
721 .with_parent_snapshot_id(parent_snapshot_id)
722 .with_sequence_number(snapshot_id)
723 .with_timestamp_ms(timestamp_ms)
724 .with_manifest_list(format!("/snap-{snapshot_id}.avro"))
725 .with_summary(Summary {
726 operation: Operation::Append,
727 additional_properties: HashMap::new(),
728 })
729 .with_schema_id(0)
730 .build()
731 }
732
733 fn test_table_metadata_builder() -> TableMetadataBuilder {
734 TableMetadataBuilder::new(
735 Schema::builder()
736 .with_fields(vec![
737 NestedField::new(1, "id", Type::Primitive(PrimitiveType::Long), false).into(),
738 ])
739 .build()
740 .unwrap(),
741 UnboundPartitionSpec::builder().build(),
742 SortOrder::unsorted_order(),
743 "s3://warehouse/db/table".to_owned(),
744 FormatVersion::V2,
745 HashMap::new(),
746 )
747 .unwrap()
748 }
749
750 #[test]
751 fn test_get_snapshot_id_uses_main_branch_history_for_timestamp() {
752 let metadata = test_table_metadata_builder()
753 .set_branch_snapshot(test_snapshot(1, None, 1_000), MAIN_BRANCH)
754 .unwrap()
755 .build()
756 .unwrap()
757 .metadata;
758 let metadata = metadata
759 .into_builder(Some("s3://warehouse/db/table/v2.metadata.json".to_owned()))
760 .set_branch_snapshot(test_snapshot(2, Some(1), 2_000), MAIN_BRANCH)
761 .unwrap()
762 .build()
763 .unwrap()
764 .metadata;
765 let metadata = metadata
766 .into_builder(Some("s3://warehouse/db/table/v3.metadata.json".to_owned()))
767 .set_branch_snapshot(test_snapshot(3, Some(1), 3_000), "audit")
768 .unwrap()
769 .build()
770 .unwrap()
771 .metadata;
772
773 assert_eq!(
774 IcebergSplitEnumerator::get_snapshot_id_from_metadata(
775 &metadata,
776 Some(IcebergTimeTravelInfo::TimestampMs(3_500)),
777 )
778 .unwrap(),
779 Some(2)
780 );
781 assert_eq!(
782 IcebergSplitEnumerator::get_snapshot_id_from_metadata(
783 &metadata,
784 Some(IcebergTimeTravelInfo::TimestampMs(1_500)),
785 )
786 .unwrap(),
787 Some(1)
788 );
789 }
790
791 #[test]
792 fn test_get_snapshot_id_version_without_current_snapshot() {
793 let metadata = test_table_metadata_builder()
794 .add_snapshot(test_snapshot(7, None, 1_000))
795 .unwrap()
796 .build()
797 .unwrap()
798 .metadata;
799
800 assert_eq!(metadata.current_snapshot_id(), None);
801 assert_eq!(
802 IcebergSplitEnumerator::get_snapshot_id_from_metadata(
803 &metadata,
804 Some(IcebergTimeTravelInfo::Version(7)),
805 )
806 .unwrap(),
807 Some(7)
808 );
809 assert_eq!(
810 IcebergSplitEnumerator::get_snapshot_id_from_metadata(&metadata, None).unwrap(),
811 None
812 );
813 }
814
815 fn create_file_scan_task(length: u64, id: u64) -> FileScanTask {
816 FileScanTask {
817 length,
818 start: 0,
819 record_count: Some(0),
820 first_row_id: None,
821 data_sequence_number: None,
822 data_file_path: format!("test_{}.parquet", id),
823 data_file_format: iceberg::spec::DataFileFormat::Parquet,
824 schema: Arc::new(Schema::builder().build().unwrap()),
825 project_field_ids: vec![],
826 predicate: None,
827 deletes: vec![],
828 sequence_number: 0,
829 file_sequence_number: Some(0),
830 file_size_in_bytes: 0,
831 partition: None,
832 partition_spec: None,
833 name_mapping: None,
834 unified_partition_type: None,
835 case_sensitive: true,
836 key_metadata: None,
837 }
838 }
839
840 #[test]
841 fn test_split_serialization_strips_iceberg_planning_context() {
842 let mut task = create_file_scan_task(100, 1);
843 task.partition = Some(Struct::empty());
844 task.partition_spec = Some(Arc::new(iceberg::spec::PartitionSpec::unpartition_spec()));
845
846 let split = IcebergSplit {
847 split_id: 1,
848 task: IcebergFileScanTask::Data(vec![task]),
849 limit: None,
850 };
851 let restored = IcebergSplit::restore_from_json(split.encode_to_json()).unwrap();
852 let task = &restored.task.tasks()[0];
853
854 assert!(task.partition.is_none());
855 assert!(task.partition_spec.is_none());
856 assert!(task.name_mapping.is_none());
857 assert!(task.unified_partition_type.is_none());
858 }
859
860 #[test]
861 fn test_split_n_vecs_basic() {
862 let file_scan_tasks = (1..=12)
863 .map(|i| create_file_scan_task(i + 100, i))
864 .collect::<Vec<_>>(); let groups = IcebergSplitEnumerator::split_n_vecs(file_scan_tasks, 3);
867
868 assert_eq!(groups.len(), 3);
869
870 let group_lengths: Vec<u64> = groups
871 .iter()
872 .map(|group| group.iter().map(|task| task.length).sum())
873 .collect();
874
875 let max_length = *group_lengths.iter().max().unwrap();
876 let min_length = *group_lengths.iter().min().unwrap();
877 assert!(max_length - min_length <= 10, "Groups should be balanced");
878
879 let total_tasks: usize = groups.iter().map(|group| group.len()).sum();
880 assert_eq!(total_tasks, 12);
881 }
882
883 #[test]
884 fn test_split_n_vecs_empty() {
885 let file_scan_tasks = Vec::new();
886 let groups = IcebergSplitEnumerator::split_n_vecs(file_scan_tasks, 3);
887 assert_eq!(groups.len(), 3);
888 assert!(groups.iter().all(|group| group.is_empty()));
889 }
890
891 #[test]
892 fn test_split_n_vecs_single_task() {
893 let file_scan_tasks = vec![create_file_scan_task(100, 1)];
894 let groups = IcebergSplitEnumerator::split_n_vecs(file_scan_tasks, 3);
895 assert_eq!(groups.len(), 3);
896 assert_eq!(groups.iter().filter(|group| !group.is_empty()).count(), 1);
897 }
898
899 #[test]
900 fn test_split_n_vecs_uneven_distribution() {
901 let file_scan_tasks = vec![
902 create_file_scan_task(1000, 1),
903 create_file_scan_task(100, 2),
904 create_file_scan_task(100, 3),
905 create_file_scan_task(100, 4),
906 create_file_scan_task(100, 5),
907 ];
908
909 let groups = IcebergSplitEnumerator::split_n_vecs(file_scan_tasks, 2);
910 assert_eq!(groups.len(), 2);
911
912 let group_with_large_task = groups
913 .iter()
914 .find(|group| group.iter().any(|task| task.length == 1000))
915 .unwrap();
916 assert_eq!(group_with_large_task.len(), 1);
917 }
918
919 #[test]
920 fn test_split_n_vecs_same_files_distribution() {
921 let file_scan_tasks = vec![
922 create_file_scan_task(100, 1),
923 create_file_scan_task(100, 2),
924 create_file_scan_task(100, 3),
925 create_file_scan_task(100, 4),
926 create_file_scan_task(100, 5),
927 create_file_scan_task(100, 6),
928 create_file_scan_task(100, 7),
929 create_file_scan_task(100, 8),
930 ];
931
932 let groups = IcebergSplitEnumerator::split_n_vecs(file_scan_tasks.clone(), 4)
933 .iter()
934 .map(|g| {
935 g.iter()
936 .map(|task| task.data_file_path.clone())
937 .collect::<Vec<_>>()
938 })
939 .collect::<Vec<_>>();
940
941 for _ in 0..10000 {
942 let groups_2 = IcebergSplitEnumerator::split_n_vecs(file_scan_tasks.clone(), 4)
943 .iter()
944 .map(|g| {
945 g.iter()
946 .map(|task| task.data_file_path.clone())
947 .collect::<Vec<_>>()
948 })
949 .collect::<Vec<_>>();
950
951 assert_eq!(groups, groups_2);
952 }
953 }
954}