Skip to main content

risingwave_connector/source/iceberg/
mod.rs

1// Copyright 2024 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15pub 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    /// Opt in to Insert/Delete ingestion of RisingWave PK-index sink output.
84    #[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    // For jdbc catalog
94    #[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            // These fields are deliberately not serializable in Iceberg. The previous
208            // Iceberg dependency skipped them during serde, so retain that split format.
209            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                // These scan types do not carry file tasks. Keep the split serializable without
234                // introducing a new empty-task variant.
235                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        // Iceberg source progress is tracked by persisted file tasks in the stream state table.
265        // A split does not carry an intra-file offset until partial-file reads are supported.
266        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        // Like file source, iceberg streaming source has a List Executor and a Fetch Executor,
296        // instead of relying on SplitEnumerator on meta.
297        // TODO: add some validation logic here.
298        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                        // convert unix time to human-readable time
352                        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            // Collect delete files for separate scan types, but keep task.deletes intact
412            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            // Top-level scan tasks always represent data files. Keep their delete
438            // descriptors intact so the SDK reader can apply them when requested.
439            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    /// Uniformly distribute scan tasks to compute nodes.
462    /// It's deterministic so that it can best utilize the data locality.
463    ///
464    /// # Arguments
465    /// * `file_scan_tasks`: The file scan tasks to be split.
466    /// * `split_num`: The number of splits to be created.
467    ///
468    /// This algorithm is based on a min-heap. It will push all groups into the heap, and then pop the smallest group and add the file scan task to it.
469    /// Ensure that the total length of each group is as balanced as possible.
470    /// The time complexity is O(n log k), where n is the number of file scan tasks and k is the number of splits.
471    /// The space complexity is O(k), where k is the number of splits.
472    /// The algorithm is stable, so the order of the file scan tasks will be preserved.
473    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/// Scan a data file. Delete files are handled by the iceberg-rust `reader.read` implementation.
489#[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        // Keep the delete files from being applied when the caller opts out.
524        data_file_scan_task.deletes.clear();
525    }
526
527    // Read the data file; delete application is delegated to the reader.
528    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    // The reader rejects a file with shredded variant columns before yielding any batch.
539    // Retry without the variant columns; NULL columns are spliced back in below.
540    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    // Process each record batch. Delete application is handled by the SDK.
576    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        // Add metadata columns if requested
588        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            // Generate position values for each row in the batch
603            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    // Record per-file metrics after reading all batches.
615    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        // APPROXIMATE: Estimate delete rows applied. The delta between expected_record_count
625        // and actual rows read may also include predicate pushdown / row-group pruning effects,
626        // so this metric can overcount. It is still useful as an approximate signal for
627        // detecting whether delete files cause significant row filtering.
628        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/// Whether the error is the reader's per-file rejection of shredded variant columns.
641// `IcebergError` hides the inner error, so the raw one is needed to inspect kind/message.
642#[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
647/// The projected VARIANT columns of a task: their field ids, their positions in the
648/// projected column order, and their names.
649fn 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
665/// Insert all-NULL variant columns at the given projected positions.
666fn 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<_>>(); // Ensure the correct function is called
865
866        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}