Skip to main content

risingwave_frontend/optimizer/rule/
iceberg_intermediate_scan_rule.rs

1// Copyright 2026 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
15//! This rule materializes a `LogicalIcebergIntermediateScan` to the final
16//! `LogicalIcebergScan` with delete file anti-joins.
17//!
18//! This is the final step in the Iceberg scan optimization pipeline:
19//! 1. `LogicalSource` -> `LogicalIcebergIntermediateScan`
20//! 2. Predicate pushdown and column pruning on `LogicalIcebergIntermediateScan`
21//! 3. `LogicalIcebergIntermediateScan` -> `LogicalIcebergScan` (this rule)
22//!
23//! At this point, the intermediate scan has accumulated:
24//! - The predicate to be pushed down to Iceberg
25//! - The output column indices for projection
26//!
27//! This rule:
28//! 1. Reads file scan tasks from Iceberg (data files and delete files)
29//! 2. Creates the `LogicalIcebergScan` for data files with pre-computed splits
30//! 3. Creates anti-joins for equality delete and position delete files
31//! 4. Adds a project if output columns differ from scan columns
32
33use std::collections::HashMap;
34
35use anyhow::Context;
36use iceberg::scan::FileScanTask;
37use iceberg::spec::{DataContentType, FormatVersion};
38use risingwave_common::catalog::{
39    ColumnCatalog, ICEBERG_FILE_PATH_COLUMN_NAME, ICEBERG_FILE_POS_COLUMN_NAME,
40    ICEBERG_SEQUENCE_NUM_COLUMN_NAME,
41};
42use risingwave_common::util::iter_util::ZipEqFast;
43use risingwave_connector::source::iceberg::{IcebergFileScanTask, IcebergSplitEnumerator};
44use risingwave_connector::source::{ConnectorProperties, SourceEnumeratorContext};
45
46use super::prelude::{PlanRef, *};
47use crate::error::Result;
48use crate::expr::{ExprImpl, ExprType, FunctionCall, InputRef};
49use crate::optimizer::plan_node::generic::GenericPlanRef;
50use crate::optimizer::plan_node::{
51    Logical, LogicalIcebergIntermediateScan, LogicalIcebergScan, LogicalJoin, LogicalProject,
52    LogicalValues,
53};
54use crate::optimizer::rule::{ApplyResult, FallibleRule};
55use crate::utils::{ColIndexMapping, Condition, FRONTEND_RUNTIME};
56
57pub struct IcebergIntermediateScanRule;
58
59impl FallibleRule<Logical> for IcebergIntermediateScanRule {
60    fn apply(&self, plan: PlanRef) -> ApplyResult<PlanRef> {
61        let scan: &LogicalIcebergIntermediateScan =
62            match plan.as_logical_iceberg_intermediate_scan() {
63                Some(s) => s,
64                None => return ApplyResult::NotApplicable,
65            };
66
67        let Some(catalog) = scan.source_catalog() else {
68            return ApplyResult::NotApplicable;
69        };
70
71        // Create the IcebergSplitEnumerator to get file scan tasks
72        let enumerator = if let ConnectorProperties::Iceberg(prop) =
73            ConnectorProperties::extract(catalog.with_properties.clone(), false)?
74        {
75            IcebergSplitEnumerator::new_inner(*prop, SourceEnumeratorContext::dummy().into())
76        } else {
77            return ApplyResult::NotApplicable;
78        };
79
80        #[cfg(madsim)]
81        return ApplyResult::Err(
82            crate::error::ErrorCode::BindError(
83                "iceberg_scan can't be used in the madsim mode".to_string(),
84            )
85            .into(),
86        );
87
88        #[cfg(not(madsim))]
89        {
90            use risingwave_connector::source::iceberg::IcebergListResult;
91
92            let list_result = tokio::task::block_in_place(|| {
93                FRONTEND_RUNTIME.block_on(enumerator.list_scan_tasks(
94                    Some(scan.time_travel_info.clone()),
95                    scan.iceberg_predicate.clone(),
96                ))
97            })?;
98            let Some(IcebergListResult {
99                mut data_files,
100                mut equality_delete_files,
101                position_delete_files,
102                equality_delete_columns,
103                format_version,
104                schema: table_schema,
105            }) = list_result
106            else {
107                tracing::info!(
108                    "There is no valid snapshot for the Iceberg table, returning empty table plan"
109                );
110                return ApplyResult::Ok(empty_table_plan(&plan, scan));
111            };
112            if data_files.is_empty() {
113                tracing::info!(
114                    "There is no data file for the Iceberg table, returning empty table plan"
115                );
116                return ApplyResult::Ok(empty_table_plan(&plan, scan));
117            }
118
119            // Build the data file scan with pre-computed splits
120            let mut projection_columns: Vec<&str> = scan
121                .output_columns()
122                .chain(equality_delete_columns.iter().map(|s| s.as_str()))
123                .collect();
124            projection_columns.sort_unstable_by_key(|&s| table_schema.field_id_by_name(s));
125            projection_columns.dedup();
126            set_project_field_ids(
127                &mut data_files,
128                table_schema.as_ref(),
129                projection_columns.iter(),
130            )?;
131            match format_version {
132                FormatVersion::V1 | FormatVersion::V2 => {
133                    for file in &mut data_files {
134                        file.deletes.clear();
135                    }
136                }
137                FormatVersion::V3 => {
138                    for file in &mut data_files {
139                        file.deletes
140                            .retain(|delete| delete.file_type == DataContentType::PositionDeletes);
141                    }
142                }
143            }
144
145            let column_catalog_map: HashMap<&str, &ColumnCatalog> = catalog
146                .columns
147                .iter()
148                .map(|c| (c.column_desc.name.as_str(), c))
149                .collect();
150            if !equality_delete_files.is_empty() {
151                projection_columns.push(ICEBERG_SEQUENCE_NUM_COLUMN_NAME);
152            }
153            let use_position_delete_join =
154                !position_delete_files.is_empty() && format_version < FormatVersion::V3;
155            if use_position_delete_join {
156                projection_columns.push(ICEBERG_FILE_PATH_COLUMN_NAME);
157                projection_columns.push(ICEBERG_FILE_POS_COLUMN_NAME);
158            }
159            let column_catalogs =
160                build_column_catalogs(projection_columns.iter(), &column_catalog_map)?;
161            let core = scan.core.clone_with_column_catalog(column_catalogs);
162            let mut plan: PlanRef =
163                LogicalIcebergScan::new(core, IcebergFileScanTask::Data(data_files)).into();
164
165            // Add anti-join for equality delete files
166            if !equality_delete_files.is_empty() {
167                set_project_field_ids(
168                    &mut equality_delete_files,
169                    table_schema.as_ref(),
170                    equality_delete_columns.iter(),
171                )?;
172                plan = build_equality_delete_hashjoin_scan(
173                    scan,
174                    &column_catalog_map,
175                    plan,
176                    equality_delete_files,
177                    equality_delete_columns,
178                )?;
179            }
180
181            // Add anti-join for position delete files
182            if use_position_delete_join {
183                plan = build_position_delete_hashjoin_scan(
184                    scan,
185                    &column_catalog_map,
186                    plan,
187                    position_delete_files,
188                )?;
189            }
190
191            // Add projection if output columns differ from scan columns
192            let schema_len = plan.schema().len();
193            let schema_names = plan.schema().fields.iter().map(|f| f.name.as_str());
194            let output_columns = scan.output_columns();
195            if schema_len != output_columns.len() || !itertools::equal(schema_names, output_columns)
196            {
197                let col_map: HashMap<&str, usize> = plan
198                    .schema()
199                    .fields
200                    .iter()
201                    .enumerate()
202                    .map(|(idx, field)| (field.name.as_str(), idx))
203                    .collect();
204                let output_col_idx: Vec<_> = scan
205                    .output_columns()
206                    .map(|col| {
207                        col_map.get(col).copied().with_context(|| {
208                            format!("Output column {} not found in scan schema", col)
209                        })
210                    })
211                    .try_collect()?;
212                let mapping = ColIndexMapping::with_remaining_columns(&output_col_idx, schema_len);
213                plan = LogicalProject::with_mapping(plan, mapping).into();
214            }
215
216            // For Iceberg engine tables, the intermediate scan's output schema has
217            // Hummock types (via table_column_type_mapping), but the LogicalIcebergScan
218            // reads from Iceberg with Iceberg types. Add casts to match the expected
219            // Hummock output types.
220            if scan.has_type_mapping() {
221                let cast_exprs: Vec<ExprImpl> = plan
222                    .schema()
223                    .fields
224                    .iter()
225                    .enumerate()
226                    .map(|(i, field)| {
227                        let input_ref: ExprImpl = InputRef::new(i, field.data_type.clone()).into();
228                        if let Some(target_type) = scan.table_column_type_mapping.get(&field.name) {
229                            if &field.data_type != target_type {
230                                match input_ref.cast_explicit(target_type) {
231                                    Ok(casted) => casted,
232                                    Err(_) => InputRef::new(i, field.data_type.clone()).into(),
233                                }
234                            } else {
235                                input_ref
236                            }
237                        } else {
238                            input_ref
239                        }
240                    })
241                    .collect();
242                plan = LogicalProject::create(plan, cast_exprs);
243            }
244
245            ApplyResult::Ok(plan)
246        }
247    }
248}
249
250impl IcebergIntermediateScanRule {
251    pub fn create() -> BoxedRule {
252        Box::new(IcebergIntermediateScanRule)
253    }
254}
255
256/// Returns an empty table plan with the same schema as the scan.
257fn empty_table_plan(plan: &PlanRef, scan: &LogicalIcebergIntermediateScan) -> PlanRef {
258    LogicalValues::new(vec![], scan.schema().clone(), plan.ctx()).into()
259}
260
261/// Builds a mapping of column names to their catalogs by looking them up from a catalog map.
262fn build_column_catalogs(
263    column_names: impl Iterator<Item = impl AsRef<str>>,
264    column_catalog_map: &HashMap<&str, &ColumnCatalog>,
265) -> Result<Vec<ColumnCatalog>> {
266    let res = column_names
267        .map(|name| {
268            let name = name.as_ref();
269            column_catalog_map
270                .get(name)
271                .map(|&c| c.clone())
272                .with_context(|| format!("Column catalog not found for column {}", name))
273        })
274        .try_collect()?;
275    Ok(res)
276}
277
278/// Sets the project field IDs for a list of files based on column names.
279fn set_project_field_ids(
280    files: &mut [FileScanTask],
281    schema: &iceberg::spec::Schema,
282    column_names: impl Iterator<Item = impl AsRef<str>>,
283) -> Result<()> {
284    let project_field_ids: Vec<i32> = column_names
285        .map(|name| {
286            let name = name.as_ref();
287            schema
288                .field_id_by_name(name)
289                .with_context(|| format!("Column {} not found in table schema", name))
290        })
291        .try_collect()?;
292    for file in files {
293        file.project_field_ids = project_field_ids.clone();
294    }
295    Ok(())
296}
297
298/// Builds equality conditions between two sets of input references.
299fn build_equal_conditions(
300    left_inputs: Vec<InputRef>,
301    right_inputs: Vec<InputRef>,
302) -> Result<Vec<ExprImpl>> {
303    left_inputs
304        .into_iter()
305        .zip_eq_fast(right_inputs.into_iter())
306        .map(|(left, right)| {
307            Ok(FunctionCall::new(ExprType::Equal, vec![left.into(), right.into()])?.into())
308        })
309        .collect()
310}
311
312pub fn build_equality_delete_hashjoin_scan(
313    scan: &LogicalIcebergIntermediateScan,
314    column_catalog_map: &HashMap<&str, &ColumnCatalog>,
315    child: PlanRef,
316    equality_delete_files: Vec<FileScanTask>,
317    equality_delete_columns: Vec<String>,
318) -> Result<PlanRef> {
319    let column_names = equality_delete_columns
320        .iter()
321        .map(|s| s.as_str())
322        .chain(std::iter::once(ICEBERG_SEQUENCE_NUM_COLUMN_NAME));
323    let column_catalogs = build_column_catalogs(column_names, column_catalog_map)?;
324    let source = scan.core.clone_with_column_catalog(column_catalogs);
325
326    let equality_delete_iceberg_scan: PlanRef = LogicalIcebergScan::new(
327        source,
328        IcebergFileScanTask::EqualityDelete(equality_delete_files),
329    )
330    .into();
331
332    let data_columns_len = child.schema().len();
333    // Build join condition: equality delete columns are equal AND sequence number is less than.
334    // Join type is LeftAnti to exclude rows that match delete records.
335    let build_inputs = |scan: &PlanRef, offset: usize| -> Result<(Vec<InputRef>, InputRef)> {
336        let delete_column_index_map = scan
337            .schema()
338            .fields()
339            .iter()
340            .enumerate()
341            .map(|(index, data_column)| (&data_column.name, (index, &data_column.data_type)))
342            .collect::<std::collections::HashMap<_, _>>();
343        let delete_column_inputs = equality_delete_columns
344            .iter()
345            .map(|name| {
346                let (index, data_type) = delete_column_index_map
347                    .get(name)
348                    .with_context(|| format!("Delete column {} not found in scan schema", name))?;
349                Ok(InputRef {
350                    index: offset + index,
351                    data_type: (*data_type).clone(),
352                })
353            })
354            .collect::<Result<Vec<InputRef>>>()?;
355        let seq_num_inputs = InputRef {
356            index: scan
357                .schema()
358                .fields()
359                .iter()
360                .position(|f| f.name.eq(ICEBERG_SEQUENCE_NUM_COLUMN_NAME))
361                .context("Sequence number column not found in scan schema")?
362                + offset,
363            data_type: risingwave_common::types::DataType::Int64,
364        };
365        Ok((delete_column_inputs, seq_num_inputs))
366    };
367    let (left_delete_column_inputs, left_seq_num_input) = build_inputs(&child, 0)?;
368    let (right_delete_column_inputs, right_seq_num_input) =
369        build_inputs(&equality_delete_iceberg_scan, data_columns_len)?;
370
371    let mut eq_join_expr =
372        build_equal_conditions(left_delete_column_inputs, right_delete_column_inputs)?;
373    eq_join_expr.push(
374        FunctionCall::new(
375            ExprType::LessThan,
376            vec![left_seq_num_input.into(), right_seq_num_input.into()],
377        )?
378        .into(),
379    );
380    let on = Condition {
381        conjunctions: eq_join_expr,
382    };
383    let join = LogicalJoin::new(
384        child,
385        equality_delete_iceberg_scan,
386        risingwave_pb::plan_common::JoinType::LeftAnti,
387        on,
388    );
389    Ok(join.into())
390}
391
392pub fn build_position_delete_hashjoin_scan(
393    scan: &LogicalIcebergIntermediateScan,
394    column_catalog_map: &HashMap<&str, &ColumnCatalog>,
395    child: PlanRef,
396    position_delete_files: Vec<FileScanTask>,
397) -> Result<PlanRef> {
398    // Position delete files use file path and position to identify deleted rows.
399    let delete_column_names = [ICEBERG_FILE_PATH_COLUMN_NAME, ICEBERG_FILE_POS_COLUMN_NAME];
400    let column_catalogs = build_column_catalogs(delete_column_names.iter(), column_catalog_map)?;
401    let position_delete_source = scan.core.clone_with_column_catalog(column_catalogs);
402
403    let position_delete_iceberg_scan: PlanRef = LogicalIcebergScan::new(
404        position_delete_source,
405        IcebergFileScanTask::PositionDelete(position_delete_files),
406    )
407    .into();
408    let data_columns_len = child.schema().len();
409
410    let build_inputs = |scan: &PlanRef, offset: usize| {
411        scan.schema()
412            .fields()
413            .iter()
414            .enumerate()
415            .filter_map(|(index, data_column)| {
416                if data_column.name.eq(ICEBERG_FILE_PATH_COLUMN_NAME)
417                    || data_column.name.eq(ICEBERG_FILE_POS_COLUMN_NAME)
418                {
419                    Some(InputRef {
420                        index: offset + index,
421                        data_type: data_column.data_type(),
422                    })
423                } else {
424                    None
425                }
426            })
427            .collect::<Vec<InputRef>>()
428    };
429    let left_delete_column_inputs = build_inputs(&child, 0);
430    let right_delete_column_inputs = build_inputs(&position_delete_iceberg_scan, data_columns_len);
431    let eq_join_expr =
432        build_equal_conditions(left_delete_column_inputs, right_delete_column_inputs)?;
433    let on = Condition {
434        conjunctions: eq_join_expr,
435    };
436    let join = LogicalJoin::new(
437        child,
438        position_delete_iceberg_scan,
439        risingwave_pb::plan_common::JoinType::LeftAnti,
440        on,
441    );
442    Ok(join.into())
443}