1use std::collections::HashMap;
16use std::ops::Deref;
17use std::rc::Rc;
18
19use iceberg::spec::{Operation, TableMetadata};
20use itertools::Itertools;
21use risingwave_common::bail_not_implemented;
22use risingwave_common::catalog::{
23 CdcTableDesc, ColumnCatalog, Engine, Field, RISINGWAVE_ICEBERG_COMMIT_EPOCH,
24 RISINGWAVE_ICEBERG_ROW_ID, ROW_ID_COLUMN_NAME, Schema,
25};
26use risingwave_common::constants::log_store::{
27 EPOCH_COLUMN_NAME, INSERT_OP_CODE, ROW_OP_COLUMN_NAME, encode_epoch,
28};
29use risingwave_common::session_config::IcebergQueryStorageMode;
30use risingwave_common::types::{DataType, Interval, ScalarImpl};
31use risingwave_common::util::iter_util::ZipEqFast;
32use risingwave_connector::WithOptionsSecResolved;
33use risingwave_connector::source::ConnectorProperties;
34use risingwave_connector::source::cdc::CdcScanOptions;
35use risingwave_connector::source::iceberg::IcebergTimeTravelInfo;
36use risingwave_sqlparser::ast::AsOf;
37use thiserror_ext::AsReport;
38
39use crate::TableCatalog;
40use crate::binder::{
41 BoundBaseTable, BoundGapFill, BoundIcebergMetadataTable, BoundJoin, BoundMatchRecognize,
42 BoundShare, BoundShareInput, BoundSource, BoundSystemTable, BoundWatermark,
43 BoundWindowTableFunction, Relation, WindowTableFunctionKind,
44};
45use crate::catalog::source_catalog::SourceCatalog;
46use crate::error::{ErrorCode, Result};
47use crate::expr::{CastContext, Expr, ExprImpl, ExprType, FunctionCall, InputRef, Literal};
48use crate::handler::cdc::derive_with_options_for_cdc_table;
49use crate::optimizer::IcebergSnapshotInfo;
50use crate::optimizer::plan_node::generic::{self, GenericPlanRef, SourceNodeKind};
51use crate::optimizer::plan_node::utils::to_iceberg_time_travel_as_of;
52use crate::optimizer::plan_node::{
53 LogicalApply, LogicalCdcScan, LogicalGapFill, LogicalHopWindow, LogicalIcebergIntermediateScan,
54 LogicalIcebergMetadataScan, LogicalJoin, LogicalMatchRecognize, LogicalPlanRef as PlanRef,
55 LogicalProject, LogicalScan, LogicalShare, LogicalSource, LogicalSysScan, LogicalTableFunction,
56 LogicalUnion, LogicalValues,
57};
58use crate::optimizer::property::Cardinality;
59use crate::planner::{PlanFor, Planner};
60use crate::utils::{ColIndexMapping, Condition};
61
62const ERROR_WINDOW_SIZE_ARG: &str =
63 "The size arg of window table function should be an interval literal.";
64
65impl Planner {
66 pub fn plan_relation(&mut self, relation: Relation) -> Result<PlanRef> {
67 match relation {
68 Relation::BaseTable(t) => self.plan_base_table(&t),
69 Relation::SystemTable(st) => self.plan_sys_table(*st),
70 Relation::IcebergMetadataTable(table) => self.plan_iceberg_metadata_table(*table),
71 Relation::Subquery(q) => Ok(self.plan_query(q.query)?.into_unordered_subplan()),
73 Relation::Join(join) => self.plan_join(*join),
74 Relation::Apply(join) => self.plan_apply(*join),
75 Relation::WindowTableFunction(tf) => self.plan_window_table_function(*tf),
76 Relation::Source(s) => self.plan_source(*s),
77 Relation::TableFunction {
78 expr: tf,
79 with_ordinality,
80 } => self.plan_table_function(tf, with_ordinality),
81 Relation::Watermark(tf) => self.plan_watermark(*tf),
82 Relation::Share(share) => self.plan_share(*share),
83 Relation::GapFill(bound_gap_fill) => self.plan_gap_fill(*bound_gap_fill),
84 Relation::MatchRecognize(mr) => self.plan_match_recognize(*mr),
85 }
86 }
87
88 pub(crate) fn plan_sys_table(&mut self, sys_table: BoundSystemTable) -> Result<PlanRef> {
89 Ok(LogicalSysScan::create(
90 sys_table.sys_table_catalog,
91 self.ctx(),
92 Cardinality::unknown(), )
94 .into())
95 }
96
97 fn plan_iceberg_metadata_table(&mut self, table: BoundIcebergMetadataTable) -> Result<PlanRef> {
98 let timezone = self.ctx().get_session_timezone();
99 let time_travel_info = to_iceberg_time_travel_as_of(&table.as_of, &timezone)?;
100 let core = crate::optimizer::plan_node::generic::IcebergMetadataScan {
101 metadata_type: table.metadata_type,
102 properties: table.properties,
103 secret_refs: table.secret_refs,
104 time_travel_info,
105 ctx: self.ctx(),
106 };
107 Ok(LogicalIcebergMetadataScan::new(core).into())
108 }
109
110 fn check_no_time_travel_in_stream(&self, as_of: Option<&AsOf>) -> Result<()> {
114 let is_stream = matches!(
115 self.plan_for(),
116 PlanFor::Stream | PlanFor::StreamIcebergEngineInternal
117 );
118 let is_time_travel = matches!(
119 as_of,
120 Some(
121 AsOf::ProcessTimeWithInterval(_)
122 | AsOf::TimestampNum(_)
123 | AsOf::TimestampString(_)
124 | AsOf::VersionNum(_)
125 | AsOf::VersionString(_)
126 )
127 );
128 if is_stream && is_time_travel {
129 return Err(ErrorCode::NotSupported(
130 "time travel in a streaming job".to_owned(),
131 "`FOR SYSTEM_TIME AS OF` and `FOR SYSTEM_VERSION AS OF` are only supported in batch queries. Remove the clause, or use `FOR SYSTEM_TIME AS OF PROCTIME()` for a temporal join.".to_owned(),
132 )
133 .into());
134 }
135 Ok(())
136 }
137
138 pub(super) fn plan_base_table(&mut self, base_table: &BoundBaseTable) -> Result<PlanRef> {
139 let as_of = base_table.as_of.clone();
140 self.check_no_time_travel_in_stream(as_of.as_ref())?;
141 let scan = LogicalScan::from_base_table(base_table, self.ctx(), as_of.clone());
142
143 match base_table.table_catalog.engine {
144 Engine::Hummock => {
145 match as_of {
146 None
147 | Some(AsOf::ProcessTime)
148 | Some(AsOf::ProcessTimeBroadcast)
149 | Some(AsOf::TimestampNum(_))
150 | Some(AsOf::TimestampString(_))
151 | Some(AsOf::ProcessTimeWithInterval(_)) => {}
152 Some(AsOf::VersionNum(_)) | Some(AsOf::VersionString(_)) => {
153 bail_not_implemented!("As Of Version is not supported yet.")
154 }
155 };
156 Ok(scan.into())
157 }
158 Engine::Iceberg => self.plan_iceberg_table(base_table, scan, as_of),
159 }
160 }
161
162 fn plan_iceberg_table(
163 &mut self,
164 base_table: &BoundBaseTable,
165 scan: LogicalScan,
166 as_of: Option<AsOf>,
167 ) -> Result<PlanRef> {
168 let is_append_only = base_table.table_catalog.append_only;
169 let iceberg_query_storage_mode = self
170 .ctx()
171 .session_ctx()
172 .config()
173 .iceberg_query_storage_mode();
174
175 enum PlanTarget {
176 TableScan,
177 Source,
178 IntermediateScan,
179 }
180 let plan_target = match self.plan_for() {
181 PlanFor::StreamIcebergEngineInternal => PlanTarget::TableScan,
182 PlanFor::BatchDql => match iceberg_query_storage_mode {
183 IcebergQueryStorageMode::Hummock if !is_append_only => PlanTarget::TableScan,
186 _ => PlanTarget::IntermediateScan,
187 },
188 PlanFor::Stream => {
189 if is_append_only {
190 PlanTarget::Source
191 } else {
192 PlanTarget::TableScan
193 }
194 }
195 PlanFor::Batch => {
196 if is_append_only {
197 PlanTarget::IntermediateScan
198 } else {
199 PlanTarget::TableScan
200 }
201 }
202 };
203 match as_of {
204 None
205 | Some(AsOf::VersionNum(_))
206 | Some(AsOf::TimestampString(_))
207 | Some(AsOf::TimestampNum(_)) => {}
208 Some(AsOf::ProcessTime)
209 | Some(AsOf::ProcessTimeBroadcast)
210 | Some(AsOf::ProcessTimeWithInterval(_)) => {
211 bail_not_implemented!("As Of ProcessTime() is not supported yet.")
212 }
213 Some(AsOf::VersionString(_)) => {
214 bail_not_implemented!("As Of Version is not supported yet.")
215 }
216 }
217
218 if matches!(plan_target, PlanTarget::TableScan) {
219 return Ok(scan.into());
220 }
221
222 let source_catalog = self.get_iceberg_source_by_table_catalog(&base_table.table_catalog)
223 .ok_or_else(|| {
224 ErrorCode::BindError(format!(
225 "failed to plan an iceberg engine table: {}. Can't find the corresponding iceberg source. Maybe you need to recreate the table",
226 base_table.table_catalog.name()
227 ))
228 })?;
229
230 let mut table_column_type_mapping = HashMap::new();
235 let table_column_map: HashMap<&str, &DataType> = base_table
236 .table_catalog
237 .columns
238 .iter()
239 .map(|c| (c.name.as_str(), &c.column_desc.data_type))
240 .collect();
241 for source_col in &source_catalog.columns {
242 let source_name = source_col.name();
243 let table_name = if source_name == RISINGWAVE_ICEBERG_ROW_ID {
244 ROW_ID_COLUMN_NAME
245 } else {
246 source_name
247 };
248 if let Some(&table_type) = table_column_map.get(table_name)
249 && source_col.column_desc.data_type != *table_type
250 {
251 table_column_type_mapping.insert(source_name.to_owned(), table_type.clone());
252 }
253 }
254
255 let column_map: HashMap<String, (usize, ColumnCatalog)> = source_catalog
256 .columns
257 .clone()
258 .into_iter()
259 .enumerate()
260 .map(|(i, column)| (column.name().to_owned(), (i, column)))
261 .collect();
262 let exprs = scan
266 .table()
267 .column_schema()
268 .fields()
269 .iter()
270 .map(|field| {
271 let source_filed_name = if field.name == ROW_ID_COLUMN_NAME {
272 RISINGWAVE_ICEBERG_ROW_ID
273 } else {
274 &field.name
275 };
276 if let Some((i, source_column)) = column_map.get(source_filed_name) {
277 let input_type = &source_column.column_desc.data_type;
278 if matches!(plan_target, PlanTarget::Source) && input_type != &field.data_type {
279 let mut input_ref =
280 ExprImpl::InputRef(InputRef::new(*i, input_type.clone()).into());
281 FunctionCall::cast_mut(
282 &mut input_ref,
283 &field.data_type,
284 CastContext::Explicit,
285 )
286 .unwrap();
287 input_ref
288 } else {
289 ExprImpl::InputRef(InputRef::new(*i, field.data_type.clone()).into())
290 }
291 } else {
292 ExprImpl::Literal(Literal::new(None, field.data_type.clone()).into())
294 }
295 })
296 .collect_vec();
297
298 let table_col_index: HashMap<&str, usize> = base_table
301 .table_catalog
302 .columns
303 .iter()
304 .enumerate()
305 .map(|(i, c)| (c.name.as_str(), i))
306 .collect();
307 let source_to_table_mapping = ColIndexMapping::new(
308 source_catalog
309 .columns
310 .iter()
311 .map(|c| {
312 let table_name = if c.name() == RISINGWAVE_ICEBERG_ROW_ID {
313 ROW_ID_COLUMN_NAME
314 } else {
315 c.name()
316 };
317 table_col_index.get(table_name).copied()
318 })
319 .collect(),
320 base_table.table_catalog.columns.len(),
321 );
322
323 let logical_source = LogicalSource::with_catalog(
324 Rc::new(source_catalog),
325 SourceNodeKind::CreateMViewOrBatch,
326 self.ctx(),
327 as_of.clone(),
328 )?;
329 if matches!(plan_target, PlanTarget::Source) {
330 return Ok(LogicalProject::new(logical_source.into(), exprs).into());
331 }
332
333 let read_pending_log_store =
337 matches!(self.plan_for(), PlanFor::BatchDql) && is_append_only && as_of.is_none();
338 if read_pending_log_store {
339 drop(self.ctx().session_ctx().pinned_snapshot());
340 }
341
342 let mut logical_iceberg_intermediate_scan = self.plan_iceberg_intermediate_scan(
343 &logical_source,
344 table_column_type_mapping,
345 source_to_table_mapping,
346 )?;
347
348 if read_pending_log_store {
349 let snapshot_info = self.fetch_current_snapshot_info(&logical_source)?;
350 let committed_epoch = match snapshot_info {
354 None => Some(None),
355 Some(IcebergSnapshotInfo {
356 commit_epoch: Some(epoch),
357 ..
358 }) => Some(Some(epoch)),
359 Some(_) => None,
360 };
361 if let Some(committed_epoch) = committed_epoch
362 && let Some(log_store_scan) = self.plan_iceberg_log_store_scan(
363 &base_table.table_catalog,
364 logical_iceberg_intermediate_scan.schema(),
365 &logical_source.core.column_catalog,
366 committed_epoch,
367 )?
368 {
369 logical_iceberg_intermediate_scan = LogicalUnion::create(
370 true,
371 vec![logical_iceberg_intermediate_scan, log_store_scan],
372 );
373 }
374 }
375
376 Ok(LogicalProject::new(logical_iceberg_intermediate_scan, exprs).into())
377 }
378
379 pub(super) fn plan_source(&mut self, source: BoundSource) -> Result<PlanRef> {
380 if source.catalog.is_cdc_table_source() {
381 if !matches!(self.plan_for(), PlanFor::Stream) {
382 return Err(ErrorCode::NotSupported(
383 "batch queries on CDC table sources are not supported".to_owned(),
384 "Create a materialized view to consume the CDC table source".to_owned(),
385 )
386 .into());
387 }
388 if source.as_of.is_some() {
389 return Err(ErrorCode::NotSupported(
390 "AS OF on CDC table sources is not supported".to_owned(),
391 "Remove the AS OF clause".to_owned(),
392 )
393 .into());
394 }
395
396 let catalog_desc = source
397 .catalog
398 .info
399 .external_table
400 .as_ref()
401 .expect("checked by is_cdc_table_source");
402 let desc = CdcTableDesc::from_protobuf(catalog_desc)?;
403 let upstream_source = {
404 let session = self.ctx.session_ctx();
405 let catalog_reader = session.env().catalog_reader().read_guard();
406 catalog_reader
407 .get_source_by_id_with_db(&session.database(), desc.source_id)?
408 .clone()
409 };
410 let desc = resolve_current_cdc_table_desc(desc, &upstream_source.with_properties)?;
411 let scan = LogicalCdcScan::create(
412 source.catalog.name.clone(),
413 Rc::new(desc),
414 self.ctx(),
415 CdcScanOptions {
416 disable_backfill: true,
417 ..Default::default()
418 },
419 );
420 Ok(scan.into())
421 } else if source.is_shareable_cdc_connector() {
422 Err(ErrorCode::InternalError(
423 "Should not create MATERIALIZED VIEW or SELECT directly on shared CDC source. HINT: create TABLE from the source instead.".to_owned(),
424 )
425 .into())
426 } else {
427 let as_of = source.as_of.clone();
428 self.check_no_time_travel_in_stream(as_of.as_ref())?;
429 match as_of {
430 None
431 | Some(AsOf::VersionNum(_))
432 | Some(AsOf::TimestampString(_))
433 | Some(AsOf::TimestampNum(_)) => {}
434 Some(AsOf::ProcessTime)
435 | Some(AsOf::ProcessTimeBroadcast)
436 | Some(AsOf::ProcessTimeWithInterval(_)) => {
437 bail_not_implemented!("As Of ProcessTime() is not supported yet.")
438 }
439 Some(AsOf::VersionString(_)) => {
440 bail_not_implemented!("As Of Version is not supported yet.")
441 }
442 }
443 let is_iceberg = source.catalog.is_iceberg_connector();
444
445 if matches!(self.plan_for(), PlanFor::Stream) {
448 let has_pk =
449 source.catalog.row_id_index.is_some() || !source.catalog.pk_col_ids.is_empty();
450 if !has_pk {
451 debug_assert!(is_iceberg);
454 if is_iceberg {
455 return Err(ErrorCode::BindError(format!(
456 "Cannot create a stream job from an iceberg source without a primary key.\nThe iceberg source might be created in an older version of RisingWave. Please try recreating the source.\nSource: {:?}",
457 source.catalog
458 ))
459 .into());
460 } else {
461 return Err(ErrorCode::BindError(format!(
462 "Cannot create a stream job from a source without a primary key.
463This is a bug. We would appreciate a bug report at:
464https://github.com/risingwavelabs/risingwave/issues/new?labels=type%2Fbug&template=bug_report.yml
465
466source: {:?}",
467 source.catalog
468 ))
469 .into());
470 }
471 }
472 }
473
474 let source = LogicalSource::with_catalog(
475 Rc::new(source.catalog),
476 SourceNodeKind::CreateMViewOrBatch,
477 self.ctx(),
478 as_of,
479 )?;
480 if is_iceberg && !matches!(self.plan_for(), PlanFor::Stream) {
481 let num_cols = source.core.column_catalog.len();
482 let intermediate_scan = self.plan_iceberg_intermediate_scan(
483 &source,
484 HashMap::new(),
485 ColIndexMapping::identity(num_cols),
486 )?;
487 Ok(intermediate_scan)
488 } else {
489 Ok(source.into())
490 }
491 }
492 }
493
494 pub(super) fn plan_join(&mut self, join: BoundJoin) -> Result<PlanRef> {
495 let left = self.plan_relation(join.left)?;
496 let right = self.plan_relation(join.right)?;
497 let join_type = join.join_type;
498 let on_clause = join.cond;
499 if on_clause.has_subquery() {
500 bail_not_implemented!("Subquery in join on condition");
501 } else {
502 Ok(LogicalJoin::create(left, right, join_type, on_clause))
503 }
504 }
505
506 pub(super) fn plan_apply(&mut self, mut join: BoundJoin) -> Result<PlanRef> {
507 let join_type = join.join_type;
508 let on_clause = join.cond;
509 if on_clause.has_subquery() {
510 bail_not_implemented!("Subquery in join on condition");
511 }
512
513 let correlated_id = self.ctx.next_correlated_id();
514 let correlated_indices = join
515 .right
516 .collect_correlated_indices_by_depth_and_assign_id(0, correlated_id);
517 let left = self.plan_relation(join.left)?;
518 let right = self.plan_relation(join.right)?;
519
520 Ok(LogicalApply::create(
521 left,
522 right,
523 join_type,
524 Condition::with_expr(on_clause),
525 correlated_id,
526 correlated_indices,
527 false,
528 ))
529 }
530
531 pub(super) fn plan_window_table_function(
532 &mut self,
533 table_function: BoundWindowTableFunction,
534 ) -> Result<PlanRef> {
535 use WindowTableFunctionKind::*;
536 match table_function.kind {
537 Tumble => self.plan_tumble_window(
538 table_function.input,
539 table_function.time_col,
540 table_function.args,
541 ),
542 Hop => self.plan_hop_window(
543 table_function.input,
544 table_function.time_col,
545 table_function.args,
546 ),
547 }
548 }
549
550 pub(super) fn plan_table_function(
551 &mut self,
552 table_function: ExprImpl,
553 with_ordinality: bool,
554 ) -> Result<PlanRef> {
555 match table_function {
557 ExprImpl::TableFunction(tf) => {
558 Ok(LogicalTableFunction::new(*tf, with_ordinality, self.ctx()).into())
559 }
560 expr => {
561 let schema = Schema {
562 fields: vec![Field::unnamed(expr.return_type())],
564 };
565 let expr_return_type = expr.return_type();
566 let root = LogicalValues::create(vec![vec![expr]], schema, self.ctx());
567 let input_ref = ExprImpl::from(InputRef::new(0, expr_return_type.clone()));
568 let mut exprs = if let DataType::Struct(st) = expr_return_type {
569 st.iter()
570 .enumerate()
571 .map(|(i, (_, ty))| {
572 let idx = ExprImpl::literal_int(i.try_into().unwrap());
573 let args = vec![input_ref.clone(), idx];
574 FunctionCall::new_unchecked(ExprType::Field, args, ty.clone()).into()
575 })
576 .collect()
577 } else {
578 vec![input_ref]
579 };
580 if with_ordinality {
581 exprs.push(ExprImpl::literal_bigint(1));
582 }
583 Ok(LogicalProject::create(root, exprs))
584 }
585 }
586 }
587
588 pub(super) fn plan_share(&mut self, share: BoundShare) -> Result<PlanRef> {
589 match share.input {
590 BoundShareInput::Query(query) => {
591 let id = share.share_id;
592 match self.share_cache.get(&id) {
593 None => {
594 let result = self.plan_query(query)?.into_unordered_subplan();
595 let logical_share = LogicalShare::create(result);
596 self.share_cache.insert(id, logical_share.clone());
597 Ok(logical_share)
598 }
599 Some(result) => Ok(result.clone()),
600 }
601 }
602 BoundShareInput::ChangeLog {
603 relation,
604 key_indices,
605 } => {
606 let id = share.share_id;
607 let result = self.plan_changelog(relation, key_indices)?;
608 let logical_share = LogicalShare::create(result);
609 self.share_cache.insert(id, logical_share.clone());
610 Ok(logical_share)
611 }
612 }
613 }
614
615 pub(super) fn plan_watermark(&mut self, _watermark: BoundWatermark) -> Result<PlanRef> {
616 todo!("plan watermark");
617 }
618
619 pub(super) fn plan_gap_fill(&mut self, gap_fill: BoundGapFill) -> Result<PlanRef> {
620 let input = self.plan_relation(gap_fill.input)?;
621 Ok(LogicalGapFill::new(
622 input,
623 gap_fill.time_col,
624 gap_fill.interval,
625 gap_fill.fill_strategies,
626 gap_fill.partition_by_cols,
627 )
628 .into())
629 }
630
631 pub(super) fn plan_match_recognize(&mut self, mr: BoundMatchRecognize) -> Result<PlanRef> {
632 let input = self.plan_relation(mr.input)?;
633 Ok(LogicalMatchRecognize::new(
634 input,
635 mr.partition_by,
636 mr.order_by,
637 mr.measures,
638 mr.rows_per_match,
639 mr.after_match_skip,
640 mr.pattern,
641 mr.defines,
642 mr.within,
643 mr.within_deadline,
644 )
645 .into())
646 }
647
648 fn collect_col_data_types_for_tumble_window(relation: &Relation) -> Result<Vec<DataType>> {
649 let col_data_types = match relation {
650 Relation::Source(s) => s
651 .catalog
652 .columns
653 .iter()
654 .map(|col| col.data_type().clone())
655 .collect(),
656 Relation::BaseTable(t) => t
657 .table_catalog
658 .columns
659 .iter()
660 .map(|col| col.data_type().clone())
661 .collect(),
662 Relation::Subquery(q) => q.query.schema().data_types(),
663 Relation::Share(share) => share
664 .input
665 .fields()?
666 .into_iter()
667 .map(|(_, f)| f.data_type)
668 .collect(),
669 r => {
670 return Err(ErrorCode::BindError(format!(
671 "Invalid input relation to tumble: {r:?}"
672 ))
673 .into());
674 }
675 };
676 Ok(col_data_types)
677 }
678
679 fn plan_tumble_window(
680 &mut self,
681 input: Relation,
682 time_col: InputRef,
683 args: Vec<ExprImpl>,
684 ) -> Result<PlanRef> {
685 let mut args = args.into_iter();
686 let col_data_types: Vec<_> = Self::collect_col_data_types_for_tumble_window(&input)?;
687
688 match (args.next(), args.next(), args.next()) {
689 (Some(window_size @ ExprImpl::Literal(_)), None, None) => {
690 let mut exprs = Vec::with_capacity(col_data_types.len() + 2);
691 for (idx, col_dt) in col_data_types.iter().enumerate() {
692 exprs.push(InputRef::new(idx, col_dt.clone()).into());
693 }
694 let window_start: ExprImpl = FunctionCall::new(
695 ExprType::TumbleStart,
696 vec![ExprImpl::InputRef(Box::new(time_col)), window_size.clone()],
697 )?
698 .into();
699 let window_end =
703 FunctionCall::new(ExprType::Add, vec![window_start.clone(), window_size])?
704 .into();
705 exprs.push(window_start);
706 exprs.push(window_end);
707 let base = self.plan_relation(input)?;
708 let project = LogicalProject::create(base, exprs);
709 Ok(project)
710 }
711 (
712 Some(window_size @ ExprImpl::Literal(_)),
713 Some(window_offset @ ExprImpl::Literal(_)),
714 None,
715 ) => {
716 let mut exprs = Vec::with_capacity(col_data_types.len() + 2);
717 for (idx, col_dt) in col_data_types.iter().enumerate() {
718 exprs.push(InputRef::new(idx, col_dt.clone()).into());
719 }
720 let window_start: ExprImpl = FunctionCall::new(
721 ExprType::TumbleStart,
722 vec![
723 ExprImpl::InputRef(Box::new(time_col)),
724 window_size.clone(),
725 window_offset,
726 ],
727 )?
728 .into();
729 let window_end =
733 FunctionCall::new(ExprType::Add, vec![window_start.clone(), window_size])?
734 .into();
735 exprs.push(window_start);
736 exprs.push(window_end);
737 let base = self.plan_relation(input)?;
738 let project = LogicalProject::create(base, exprs);
739 Ok(project)
740 }
741 _ => Err(ErrorCode::BindError(ERROR_WINDOW_SIZE_ARG.to_owned()).into()),
742 }
743 }
744
745 fn plan_hop_window(
746 &mut self,
747 input: Relation,
748 time_col: InputRef,
749 args: Vec<ExprImpl>,
750 ) -> Result<PlanRef> {
751 let input = self.plan_relation(input)?;
752 let mut args = args.into_iter();
753 let Some((ExprImpl::Literal(window_slide), ExprImpl::Literal(window_size))) =
754 args.next_tuple()
755 else {
756 return Err(ErrorCode::BindError(ERROR_WINDOW_SIZE_ARG.to_owned()).into());
757 };
758
759 let Some(ScalarImpl::Interval(window_slide)) = *window_slide.get_data() else {
760 return Err(ErrorCode::BindError(ERROR_WINDOW_SIZE_ARG.to_owned()).into());
761 };
762 let Some(ScalarImpl::Interval(window_size)) = *window_size.get_data() else {
763 return Err(ErrorCode::BindError(ERROR_WINDOW_SIZE_ARG.to_owned()).into());
764 };
765
766 let window_offset = match (args.next(), args.next()) {
767 (Some(ExprImpl::Literal(window_offset)), None) => match *window_offset.get_data() {
768 Some(ScalarImpl::Interval(window_offset)) => window_offset,
769 _ => return Err(ErrorCode::BindError(ERROR_WINDOW_SIZE_ARG.to_owned()).into()),
770 },
771 (None, None) => Interval::from_month_day_usec(0, 0, 0),
772 _ => return Err(ErrorCode::BindError(ERROR_WINDOW_SIZE_ARG.to_owned()).into()),
773 };
774
775 if !window_size.is_positive() || !window_slide.is_positive() {
776 return Err(ErrorCode::BindError(format!(
777 "window_size {} and window_slide {} must be positive",
778 window_size, window_slide
779 ))
780 .into());
781 }
782
783 if window_size.exact_div(&window_slide).is_none() {
784 return Err(ErrorCode::BindError(format!("Invalid arguments for HOP window function: window_size {} cannot be divided by window_slide {}",window_size, window_slide)).into());
785 }
786
787 Ok(LogicalHopWindow::create(
788 input,
789 time_col,
790 window_slide,
791 window_size,
792 window_offset,
793 ))
794 }
795
796 fn plan_iceberg_intermediate_scan(
797 &self,
798 source: &LogicalSource,
799 table_column_type_mapping: HashMap<String, DataType>,
800 source_to_table_mapping: ColIndexMapping,
801 ) -> Result<PlanRef> {
802 let timezone = self.ctx().get_session_timezone();
804 let mut time_travel_info = to_iceberg_time_travel_as_of(&source.core.as_of, &timezone)?;
805 if time_travel_info.is_none() {
806 time_travel_info = self
807 .fetch_current_snapshot_info(source)?
808 .map(|info| IcebergTimeTravelInfo::Version(info.snapshot_id));
809 }
810 let Some(time_travel_info) = time_travel_info else {
811 let mut schema = source.schema().clone();
812 for field in &mut schema.fields {
813 if let Some(target_type) = table_column_type_mapping.get(&field.name) {
814 field.data_type = target_type.clone();
815 }
816 }
817 return Ok(LogicalValues::new(vec![], schema, self.ctx()).into());
818 };
819 let intermediate_scan = LogicalIcebergIntermediateScan::new(
820 source,
821 time_travel_info,
822 table_column_type_mapping,
823 source_to_table_mapping,
824 );
825 Ok(intermediate_scan.into())
826 }
827
828 fn fetch_current_snapshot_info(
829 &self,
830 source: &LogicalSource,
831 ) -> Result<Option<IcebergSnapshotInfo>> {
832 let mut map = self.ctx.iceberg_snapshot_info_map();
833 let catalog = source.source_catalog().ok_or_else(|| {
834 crate::error::ErrorCode::InternalError(
835 "Iceberg source must have a valid source catalog".to_owned(),
836 )
837 })?;
838 if let Some(&snapshot_info) = map.get(&catalog.id) {
839 return Ok(snapshot_info);
840 }
841
842 #[cfg(madsim)]
843 return Err(crate::error::ErrorCode::BindError(
844 "iceberg source time travel can't be used in the madsim mode".to_string(),
845 )
846 .into());
847
848 #[cfg(not(madsim))]
849 {
850 let ConnectorProperties::Iceberg(prop) =
851 ConnectorProperties::extract(catalog.with_properties.clone(), false)?
852 else {
853 return Err(crate::error::ErrorCode::InternalError(
854 "Iceberg source must have Iceberg connector properties".to_owned(),
855 )
856 .into());
857 };
858
859 let snapshot_info = tokio::task::block_in_place(|| {
860 crate::utils::FRONTEND_RUNTIME.block_on(async {
861 prop.load_table().await.map(|table| {
862 let metadata = table.metadata();
863 metadata
864 .current_snapshot()
865 .map(|snapshot| IcebergSnapshotInfo {
866 snapshot_id: snapshot.snapshot_id(),
867 commit_epoch: risingwave_iceberg_commit_epoch(
868 metadata,
869 snapshot.snapshot_id(),
870 ),
871 })
872 })
873 })
874 })?;
875 map.insert(catalog.id, snapshot_info);
876 Ok(snapshot_info)
877 }
878 }
879
880 fn plan_iceberg_log_store_scan(
883 &self,
884 table_catalog: &TableCatalog,
885 target_schema: &Schema,
886 source_columns: &[ColumnCatalog],
887 committed_epoch: Option<u64>,
888 ) -> Result<Option<PlanRef>> {
889 let Some(sink_name) = table_catalog.iceberg_sink_name() else {
890 return Ok(None);
891 };
892 let log_store_table = {
893 let catalog_reader = self.ctx.session_ctx().env().catalog_reader().read_guard();
894 let Ok(schema) =
895 catalog_reader.get_schema_by_id(table_catalog.database_id, table_catalog.schema_id)
896 else {
897 return Ok(None);
898 };
899 let Some(sink) = schema.get_created_sink_by_name(&sink_name) else {
900 return Ok(None);
901 };
902 schema
903 .iter_internal_table()
904 .find(|table| {
905 table.job_id == Some(sink.id.as_job_id())
906 && table
907 .columns()
908 .iter()
909 .any(|column| column.name() == EPOCH_COLUMN_NAME)
910 && table
911 .columns()
912 .iter()
913 .any(|column| column.name() == ROW_OP_COLUMN_NAME)
914 })
915 .cloned()
916 };
917 let Some(log_store_table) = log_store_table else {
918 return Ok(None);
919 };
920
921 let Some(epoch_idx) = log_store_table
922 .columns()
923 .iter()
924 .position(|column| column.name() == EPOCH_COLUMN_NAME)
925 else {
926 return Ok(None);
927 };
928 let Some(row_op_idx) = log_store_table
929 .columns()
930 .iter()
931 .position(|column| column.name() == ROW_OP_COLUMN_NAME)
932 else {
933 return Ok(None);
934 };
935 let payload_col_idx = log_store_table
939 .columns()
940 .iter()
941 .enumerate()
942 .skip(row_op_idx + 1)
943 .filter_map(|(idx, column)| (!column.is_hidden).then_some(idx))
944 .collect_vec();
945 if source_columns.len() != target_schema.len()
946 || payload_col_idx.len()
947 != source_columns
948 .iter()
949 .filter(|column| !column.is_hidden)
950 .count()
951 {
952 return Ok(None);
953 }
954
955 let row_op_predicate: ExprImpl = FunctionCall::new(
956 ExprType::Equal,
957 vec![
958 InputRef::new(row_op_idx, DataType::Int16).into(),
959 Literal::new(Some(ScalarImpl::Int16(INSERT_OP_CODE)), DataType::Int16).into(),
960 ],
961 )?
962 .into();
963 let mut predicate = Condition::with_expr(row_op_predicate);
964 if let Some(epoch) = committed_epoch {
965 let epoch_predicate: ExprImpl = FunctionCall::new(
966 ExprType::GreaterThan,
967 vec![
968 InputRef::new(epoch_idx, DataType::Int64).into(),
969 Literal::new(
970 Some(ScalarImpl::Int64(encode_epoch(epoch))),
971 DataType::Int64,
972 )
973 .into(),
974 ],
975 )?
976 .into();
977 predicate = predicate.and(Condition::with_expr(epoch_predicate));
978 }
979
980 let log_store_scan: PlanRef = LogicalScan::from(generic::TableScan::new(
981 payload_col_idx,
982 log_store_table,
983 vec![],
984 vec![],
985 self.ctx(),
986 predicate,
987 None,
988 ))
989 .into();
990
991 let source_types = log_store_scan.schema().data_types();
992 let target_types = target_schema.data_types();
993 let mut payload_idx = 0;
994 let project_exprs = source_columns
995 .iter()
996 .zip_eq_fast(target_types)
997 .map(|(source_column, target_type)| {
998 if source_column.is_hidden {
999 return Ok(Literal::new(None, target_type).into());
1000 }
1001
1002 let source_type = source_types[payload_idx].clone();
1003 let mut expr: ExprImpl = InputRef::new(payload_idx, source_type).into();
1004 payload_idx += 1;
1005 FunctionCall::cast_mut(&mut expr, &target_type, CastContext::Explicit).map_err(
1006 |error| {
1007 ErrorCode::InternalError(format!(
1008 "failed to align Iceberg log-store payload type: {}",
1009 error.as_report()
1010 ))
1011 },
1012 )?;
1013 Ok(expr)
1014 })
1015 .collect::<Result<Vec<_>>>()?;
1016 Ok(Some(LogicalProject::create(log_store_scan, project_exprs)))
1017 }
1018
1019 fn get_iceberg_source_by_table_catalog(
1020 &self,
1021 table_catalog: &TableCatalog,
1022 ) -> Option<SourceCatalog> {
1023 let catalog_reader = self.ctx.session_ctx().env().catalog_reader().read_guard();
1024
1025 let iceberg_source_name = table_catalog.iceberg_source_name()?;
1026 let schema = catalog_reader
1027 .get_schema_by_id(table_catalog.database_id, table_catalog.schema_id)
1028 .ok()?;
1029 let source_catalog = schema.get_source_by_name(&iceberg_source_name)?;
1030 Some(source_catalog.deref().clone())
1031 }
1032}
1033
1034fn risingwave_iceberg_commit_epoch(metadata: &TableMetadata, snapshot_id: i64) -> Option<u64> {
1039 let mut snapshot = metadata.snapshot_by_id(snapshot_id)?;
1040 loop {
1041 if let Some(epoch) = snapshot
1042 .summary()
1043 .additional_properties
1044 .get(RISINGWAVE_ICEBERG_COMMIT_EPOCH)
1045 {
1046 return epoch.parse().ok();
1047 }
1048 if snapshot.summary().operation != Operation::Replace {
1049 return None;
1050 }
1051 snapshot = metadata.snapshot_by_id(snapshot.parent_snapshot_id()?)?;
1052 }
1053}
1054
1055fn resolve_current_cdc_table_desc(
1056 mut desc: CdcTableDesc,
1057 upstream_properties: &WithOptionsSecResolved,
1058) -> Result<CdcTableDesc> {
1059 let (current_properties, normalized_external_table_name) =
1060 derive_with_options_for_cdc_table(upstream_properties, desc.external_table_name)?;
1061 let (connect_properties, secret_refs) = current_properties.into_parts();
1062 desc.external_table_name = normalized_external_table_name;
1063 desc.connect_properties = connect_properties;
1064 desc.secret_refs = secret_refs;
1065 Ok(desc)
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use std::collections::BTreeMap;
1071
1072 use iceberg::spec::{
1073 FormatVersion, MAIN_BRANCH, NestedField, PrimitiveType, Schema as IcebergSchema, Snapshot,
1074 SortOrder, Summary, TableMetadataBuilder, Type, UnboundPartitionSpec,
1075 };
1076
1077 use super::*;
1078
1079 #[test]
1080 fn test_risingwave_commit_epoch_inherits_only_across_replace_snapshots() {
1081 let append = snapshot(
1082 1,
1083 None,
1084 Operation::Append,
1085 HashMap::from([(RISINGWAVE_ICEBERG_COMMIT_EPOCH.to_owned(), "42".to_owned())]),
1086 );
1087 let replace = snapshot(2, Some(1), Operation::Replace, HashMap::new());
1088 let metadata = metadata_with_current(vec![append, replace]);
1089 assert_eq!(risingwave_iceberg_commit_epoch(&metadata, 2), Some(42));
1090
1091 let external_append = snapshot(3, Some(2), Operation::Append, HashMap::new());
1092 let metadata = metadata_with_current(vec![
1093 snapshot(
1094 1,
1095 None,
1096 Operation::Append,
1097 HashMap::from([(RISINGWAVE_ICEBERG_COMMIT_EPOCH.to_owned(), "42".to_owned())]),
1098 ),
1099 snapshot(2, Some(1), Operation::Replace, HashMap::new()),
1100 external_append,
1101 ]);
1102 assert_eq!(risingwave_iceberg_commit_epoch(&metadata, 3), None);
1103 }
1104
1105 fn metadata_with_current(mut snapshots: Vec<Snapshot>) -> TableMetadata {
1106 let current = snapshots.pop().unwrap();
1107 let mut builder = TableMetadataBuilder::new(
1108 IcebergSchema::builder()
1109 .with_fields(vec![
1110 NestedField::new(1, "id", Type::Primitive(PrimitiveType::Long), false).into(),
1111 ])
1112 .build()
1113 .unwrap(),
1114 UnboundPartitionSpec::builder().build(),
1115 SortOrder::unsorted_order(),
1116 "s3://warehouse/db/table".to_owned(),
1117 FormatVersion::V2,
1118 HashMap::new(),
1119 )
1120 .unwrap();
1121 for snapshot in snapshots {
1122 builder = builder.add_snapshot(snapshot).unwrap();
1123 }
1124 builder
1125 .set_branch_snapshot(current, MAIN_BRANCH)
1126 .unwrap()
1127 .build()
1128 .unwrap()
1129 .metadata
1130 }
1131
1132 fn snapshot(
1133 snapshot_id: i64,
1134 parent_snapshot_id: Option<i64>,
1135 operation: Operation,
1136 additional_properties: HashMap<String, String>,
1137 ) -> Snapshot {
1138 Snapshot::builder()
1139 .with_snapshot_id(snapshot_id)
1140 .with_parent_snapshot_id(parent_snapshot_id)
1141 .with_sequence_number(snapshot_id)
1142 .with_timestamp_ms(snapshot_id)
1143 .with_manifest_list(format!("/snap-{snapshot_id}.avro"))
1144 .with_summary(Summary {
1145 operation,
1146 additional_properties,
1147 })
1148 .with_schema_id(0)
1149 .build()
1150 }
1151
1152 #[test]
1153 fn test_resolve_current_cdc_table_desc_replaces_stale_properties() {
1154 let desc = CdcTableDesc {
1155 external_table_name: "risedev.orders".to_owned(),
1156 connect_properties: BTreeMap::from([("hostname".to_owned(), "old-host".to_owned())]),
1157 ..Default::default()
1158 };
1159 let upstream_properties = WithOptionsSecResolved::new(
1160 BTreeMap::from([
1161 ("connector".to_owned(), "mysql-cdc".to_owned()),
1162 ("database.name".to_owned(), "risedev".to_owned()),
1163 ("hostname".to_owned(), "new-host".to_owned()),
1164 ]),
1165 BTreeMap::new(),
1166 );
1167
1168 let desc = resolve_current_cdc_table_desc(desc, &upstream_properties).unwrap();
1169
1170 assert_eq!(desc.connect_properties.get("hostname").unwrap(), "new-host");
1171 assert_eq!(desc.connect_properties.get("table.name").unwrap(), "orders");
1172 assert_eq!(
1173 desc.connect_properties.get("database.name").unwrap(),
1174 "risedev"
1175 );
1176 }
1177}