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