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