risingwave_frontend/optimizer/rule/
iceberg_intermediate_scan_rule.rs1use 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 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 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 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 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 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 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 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
265fn empty_table_plan(plan: &PlanRef, scan: &LogicalIcebergIntermediateScan) -> PlanRef {
267 LogicalValues::new(vec![], scan.schema().clone(), plan.ctx()).into()
268}
269
270fn 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
287fn 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
307fn 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 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 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}