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 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 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 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 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 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
256fn empty_table_plan(plan: &PlanRef, scan: &LogicalIcebergIntermediateScan) -> PlanRef {
258 LogicalValues::new(vec![], scan.schema().clone(), plan.ctx()).into()
259}
260
261fn 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
278fn 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
298fn 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 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 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}