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            // Batch V2 delete anti-joins still need physical positions internally. Do not put
146            // these columns in the update source catalog: the binder must never expose them.
147            // The final projection below removes them before returning the query's rows.
148            let internal_metadata = if catalog.is_iceberg_update_source() {
149                ColumnCatalog::iceberg_hidden_cols().to_vec()
150            } else {
151                vec![]
152            };
153            let column_catalog_map: HashMap<&str, &ColumnCatalog> = catalog
154                .columns
155                .iter()
156                .chain(&internal_metadata)
157                .map(|c| (c.column_desc.name.as_str(), c))
158                .collect();
159            if !equality_delete_files.is_empty() {
160                projection_columns.push(ICEBERG_SEQUENCE_NUM_COLUMN_NAME);
161            }
162            let use_position_delete_join =
163                !position_delete_files.is_empty() && format_version < FormatVersion::V3;
164            if use_position_delete_join {
165                projection_columns.push(ICEBERG_FILE_PATH_COLUMN_NAME);
166                projection_columns.push(ICEBERG_FILE_POS_COLUMN_NAME);
167            }
168            let column_catalogs =
169                build_column_catalogs(projection_columns.iter(), &column_catalog_map)?;
170            let core = scan.core.clone_with_column_catalog(column_catalogs);
171            let mut plan: PlanRef =
172                LogicalIcebergScan::new(core, IcebergFileScanTask::Data(data_files)).into();
173
174            // Add anti-join for equality delete files
175            if !equality_delete_files.is_empty() {
176                set_project_field_ids(
177                    &mut equality_delete_files,
178                    table_schema.as_ref(),
179                    equality_delete_columns.iter(),
180                )?;
181                plan = build_equality_delete_hashjoin_scan(
182                    scan,
183                    &column_catalog_map,
184                    plan,
185                    equality_delete_files,
186                    equality_delete_columns,
187                )?;
188            }
189
190            // Add anti-join for position delete files
191            if use_position_delete_join {
192                plan = build_position_delete_hashjoin_scan(
193                    scan,
194                    &column_catalog_map,
195                    plan,
196                    position_delete_files,
197                )?;
198            }
199
200            // Add projection if output columns differ from scan columns
201            let schema_len = plan.schema().len();
202            let schema_names = plan.schema().fields.iter().map(|f| f.name.as_str());
203            let output_columns = scan.output_columns();
204            if schema_len != output_columns.len() || !itertools::equal(schema_names, output_columns)
205            {
206                let col_map: HashMap<&str, usize> = plan
207                    .schema()
208                    .fields
209                    .iter()
210                    .enumerate()
211                    .map(|(idx, field)| (field.name.as_str(), idx))
212                    .collect();
213                let output_col_idx: Vec<_> = scan
214                    .output_columns()
215                    .map(|col| {
216                        col_map.get(col).copied().with_context(|| {
217                            format!("Output column {} not found in scan schema", col)
218                        })
219                    })
220                    .try_collect()?;
221                let mapping = ColIndexMapping::with_remaining_columns(&output_col_idx, schema_len);
222                plan = LogicalProject::with_mapping(plan, mapping).into();
223            }
224
225            // For Iceberg engine tables, the intermediate scan's output schema has
226            // Hummock types (via table_column_type_mapping), but the LogicalIcebergScan
227            // reads from Iceberg with Iceberg types. Add casts to match the expected
228            // Hummock output types.
229            if scan.has_type_mapping() {
230                let cast_exprs: Vec<ExprImpl> = plan
231                    .schema()
232                    .fields
233                    .iter()
234                    .enumerate()
235                    .map(|(i, field)| {
236                        let input_ref: ExprImpl = InputRef::new(i, field.data_type.clone()).into();
237                        if let Some(target_type) = scan.table_column_type_mapping.get(&field.name) {
238                            if &field.data_type != target_type {
239                                match input_ref.cast_explicit(target_type) {
240                                    Ok(casted) => casted,
241                                    Err(_) => InputRef::new(i, field.data_type.clone()).into(),
242                                }
243                            } else {
244                                input_ref
245                            }
246                        } else {
247                            input_ref
248                        }
249                    })
250                    .collect();
251                plan = LogicalProject::create(plan, cast_exprs);
252            }
253
254            ApplyResult::Ok(plan)
255        }
256    }
257}
258
259impl IcebergIntermediateScanRule {
260    pub fn create() -> BoxedRule {
261        Box::new(IcebergIntermediateScanRule)
262    }
263}
264
265/// Returns an empty table plan with the same schema as the scan.
266fn empty_table_plan(plan: &PlanRef, scan: &LogicalIcebergIntermediateScan) -> PlanRef {
267    LogicalValues::new(vec![], scan.schema().clone(), plan.ctx()).into()
268}
269
270/// Builds a mapping of column names to their catalogs by looking them up from a catalog map.
271fn build_column_catalogs(
272    column_names: impl Iterator<Item = impl AsRef<str>>,
273    column_catalog_map: &HashMap<&str, &ColumnCatalog>,
274) -> Result<Vec<ColumnCatalog>> {
275    let res = column_names
276        .map(|name| {
277            let name = name.as_ref();
278            column_catalog_map
279                .get(name)
280                .map(|&c| c.clone())
281                .with_context(|| format!("Column catalog not found for column {}", name))
282        })
283        .try_collect()?;
284    Ok(res)
285}
286
287/// Sets the project field IDs for a list of files based on column names.
288fn set_project_field_ids(
289    files: &mut [FileScanTask],
290    schema: &iceberg::spec::Schema,
291    column_names: impl Iterator<Item = impl AsRef<str>>,
292) -> Result<()> {
293    let project_field_ids: Vec<i32> = column_names
294        .map(|name| {
295            let name = name.as_ref();
296            schema
297                .field_id_by_name(name)
298                .with_context(|| format!("Column {} not found in table schema", name))
299        })
300        .try_collect()?;
301    for file in files {
302        file.project_field_ids = project_field_ids.clone();
303    }
304    Ok(())
305}
306
307/// Builds equality conditions between two sets of input references.
308fn build_equal_conditions(
309    left_inputs: Vec<InputRef>,
310    right_inputs: Vec<InputRef>,
311) -> Result<Vec<ExprImpl>> {
312    left_inputs
313        .into_iter()
314        .zip_eq_fast(right_inputs.into_iter())
315        .map(|(left, right)| {
316            Ok(FunctionCall::new(ExprType::Equal, vec![left.into(), right.into()])?.into())
317        })
318        .collect()
319}
320
321pub fn build_equality_delete_hashjoin_scan(
322    scan: &LogicalIcebergIntermediateScan,
323    column_catalog_map: &HashMap<&str, &ColumnCatalog>,
324    child: PlanRef,
325    equality_delete_files: Vec<FileScanTask>,
326    equality_delete_columns: Vec<String>,
327) -> Result<PlanRef> {
328    let column_names = equality_delete_columns
329        .iter()
330        .map(|s| s.as_str())
331        .chain(std::iter::once(ICEBERG_SEQUENCE_NUM_COLUMN_NAME));
332    let column_catalogs = build_column_catalogs(column_names, column_catalog_map)?;
333    let source = scan.core.clone_with_column_catalog(column_catalogs);
334
335    let equality_delete_iceberg_scan: PlanRef = LogicalIcebergScan::new(
336        source,
337        IcebergFileScanTask::EqualityDelete(equality_delete_files),
338    )
339    .into();
340
341    let data_columns_len = child.schema().len();
342    // Build join condition: equality delete columns are equal AND sequence number is less than.
343    // Join type is LeftAnti to exclude rows that match delete records.
344    let build_inputs = |scan: &PlanRef, offset: usize| -> Result<(Vec<InputRef>, InputRef)> {
345        let delete_column_index_map = scan
346            .schema()
347            .fields()
348            .iter()
349            .enumerate()
350            .map(|(index, data_column)| (&data_column.name, (index, &data_column.data_type)))
351            .collect::<std::collections::HashMap<_, _>>();
352        let delete_column_inputs = equality_delete_columns
353            .iter()
354            .map(|name| {
355                let (index, data_type) = delete_column_index_map
356                    .get(name)
357                    .with_context(|| format!("Delete column {} not found in scan schema", name))?;
358                Ok(InputRef {
359                    index: offset + index,
360                    data_type: (*data_type).clone(),
361                })
362            })
363            .collect::<Result<Vec<InputRef>>>()?;
364        let seq_num_inputs = InputRef {
365            index: scan
366                .schema()
367                .fields()
368                .iter()
369                .position(|f| f.name.eq(ICEBERG_SEQUENCE_NUM_COLUMN_NAME))
370                .context("Sequence number column not found in scan schema")?
371                + offset,
372            data_type: risingwave_common::types::DataType::Int64,
373        };
374        Ok((delete_column_inputs, seq_num_inputs))
375    };
376    let (left_delete_column_inputs, left_seq_num_input) = build_inputs(&child, 0)?;
377    let (right_delete_column_inputs, right_seq_num_input) =
378        build_inputs(&equality_delete_iceberg_scan, data_columns_len)?;
379
380    let mut eq_join_expr =
381        build_equal_conditions(left_delete_column_inputs, right_delete_column_inputs)?;
382    eq_join_expr.push(
383        FunctionCall::new(
384            ExprType::LessThan,
385            vec![left_seq_num_input.into(), right_seq_num_input.into()],
386        )?
387        .into(),
388    );
389    let on = Condition {
390        conjunctions: eq_join_expr,
391    };
392    let join = LogicalJoin::new(
393        child,
394        equality_delete_iceberg_scan,
395        risingwave_pb::plan_common::JoinType::LeftAnti,
396        on,
397    );
398    Ok(join.into())
399}
400
401pub fn build_position_delete_hashjoin_scan(
402    scan: &LogicalIcebergIntermediateScan,
403    column_catalog_map: &HashMap<&str, &ColumnCatalog>,
404    child: PlanRef,
405    position_delete_files: Vec<FileScanTask>,
406) -> Result<PlanRef> {
407    // Position delete files use file path and position to identify deleted rows.
408    let delete_column_names = [ICEBERG_FILE_PATH_COLUMN_NAME, ICEBERG_FILE_POS_COLUMN_NAME];
409    let column_catalogs = build_column_catalogs(delete_column_names.iter(), column_catalog_map)?;
410    let position_delete_source = scan.core.clone_with_column_catalog(column_catalogs);
411
412    let position_delete_iceberg_scan: PlanRef = LogicalIcebergScan::new(
413        position_delete_source,
414        IcebergFileScanTask::PositionDelete(position_delete_files),
415    )
416    .into();
417    let data_columns_len = child.schema().len();
418
419    let build_inputs = |scan: &PlanRef, offset: usize| {
420        scan.schema()
421            .fields()
422            .iter()
423            .enumerate()
424            .filter_map(|(index, data_column)| {
425                if data_column.name.eq(ICEBERG_FILE_PATH_COLUMN_NAME)
426                    || data_column.name.eq(ICEBERG_FILE_POS_COLUMN_NAME)
427                {
428                    Some(InputRef {
429                        index: offset + index,
430                        data_type: data_column.data_type(),
431                    })
432                } else {
433                    None
434                }
435            })
436            .collect::<Vec<InputRef>>()
437    };
438    let left_delete_column_inputs = build_inputs(&child, 0);
439    let right_delete_column_inputs = build_inputs(&position_delete_iceberg_scan, data_columns_len);
440    let eq_join_expr =
441        build_equal_conditions(left_delete_column_inputs, right_delete_column_inputs)?;
442    let on = Condition {
443        conjunctions: eq_join_expr,
444    };
445    let join = LogicalJoin::new(
446        child,
447        position_delete_iceberg_scan,
448        risingwave_pb::plan_common::JoinType::LeftAnti,
449        on,
450    );
451    Ok(join.into())
452}