1use std::assert_matches;
16use std::sync::Arc;
17
18use iceberg::spec::Transform;
19use itertools::Itertools;
20use pretty_xmlish::{Pretty, XmlNode};
21use risingwave_common::catalog::{
22 ColumnCatalog, ConflictBehavior, CreateType, FieldLike, RISINGWAVE_ICEBERG_ROW_ID,
23 ROW_ID_COLUMN_NAME,
24};
25use risingwave_common::types::{DataType, StructType};
26use risingwave_common::util::iter_util::ZipEqDebug;
27use risingwave_connector::sink::catalog::desc::SinkDesc;
28use risingwave_connector::sink::catalog::{SinkFormat, SinkFormatDesc, SinkId, SinkType};
29use risingwave_connector::sink::file_sink::fs::FsSink;
30use risingwave_connector::sink::iceberg::{
31 ENABLE_PK_INDEX, ICEBERG_SINK, LEGACY_PK_INDEX_FILE_PATH,
32};
33use risingwave_connector::sink::trivial::TABLE_SINK;
34use risingwave_connector::sink::{
35 CONNECTOR_TYPE_KEY, SINK_TYPE_APPEND_ONLY, SINK_TYPE_DEBEZIUM, SINK_TYPE_OPTION,
36 SINK_TYPE_RETRACT, SINK_TYPE_UPSERT, SINK_USER_FORCE_APPEND_ONLY_OPTION,
37 SINK_USER_IGNORE_DELETE_OPTION, SINK_USER_PRESERVE_ROW_LEVEL_CHANGES,
38};
39use risingwave_connector::{AUTO_SCHEMA_CHANGE_KEY, WithPropertiesExt, match_sink_name_str};
40use risingwave_pb::expr::expr_node::Type;
41use risingwave_pb::stream_plan::SinkLogStoreType;
42use risingwave_pb::stream_plan::stream_node::PbNodeBody;
43
44use super::derive::{derive_columns, derive_pk};
45use super::stream::prelude::*;
46use super::utils::{
47 Distill, IndicesDisplay, childless_record, infer_kv_log_store_table_catalog_inner,
48};
49use super::{
50 ExprRewritable, PlanBase, StreamExchange, StreamNode, StreamPlanRef as PlanRef, StreamProject,
51 StreamSyncLogStore, generic,
52};
53use crate::TableCatalog;
54use crate::error::{ErrorCode, Result, RwError, bail_bind_error, bail_invalid_input_syntax};
55use crate::expr::{ExprImpl, FunctionCall, InputRef};
56use crate::optimizer::StreamOptimizedLogicalPlanRoot;
57use crate::optimizer::plan_node::PlanTreeNodeUnary;
58use crate::optimizer::plan_node::expr_visitable::ExprVisitable;
59use crate::optimizer::plan_node::utils::plan_can_use_background_ddl;
60use crate::optimizer::property::{Distribution, RequiredDist};
61use crate::stream_fragmenter::BuildFragmentGraphState;
62use crate::utils::WithOptionsSecResolved;
63
64const DOWNSTREAM_PK_KEY: &str = "primary_key";
65const CREATE_TABLE_IF_NOT_EXISTS: &str = "create_table_if_not_exists";
66
67fn target_table_requires_row_level_conflict_handling(target_table: &TableCatalog) -> bool {
68 !target_table.version_column_indices.is_empty()
69 || matches!(
70 target_table.conflict_behavior(),
71 ConflictBehavior::DoUpdateIfNotNull | ConflictBehavior::IgnoreConflict
72 )
73}
74
75pub enum PartitionComputeInfo {
88 Iceberg(IcebergPartitionInfo),
89}
90
91impl PartitionComputeInfo {
92 pub fn convert_to_expression(self, columns: &[ColumnCatalog]) -> Result<ExprImpl> {
93 match self {
94 PartitionComputeInfo::Iceberg(info) => info.convert_to_expression(columns),
95 }
96 }
97}
98
99pub struct IcebergPartitionInfo {
100 pub partition_type: StructType,
101 pub partition_fields: Vec<(String, Transform)>,
103}
104
105impl IcebergPartitionInfo {
106 #[inline]
107 fn transform_to_expression(
108 transform: &Transform,
109 col_id: usize,
110 columns: &[ColumnCatalog],
111 result_type: DataType,
112 ) -> Result<ExprImpl> {
113 match transform {
114 Transform::Identity => {
115 if columns[col_id].column_desc.data_type != result_type {
116 return Err(ErrorCode::InvalidInputSyntax(format!(
117 "The partition field {} has type {}, but the partition field is {}",
118 columns[col_id].column_desc.name,
119 columns[col_id].column_desc.data_type,
120 result_type
121 ))
122 .into());
123 }
124 Ok(ExprImpl::InputRef(
125 InputRef::new(col_id, result_type).into(),
126 ))
127 }
128 Transform::Void => Ok(ExprImpl::literal_null(result_type)),
129 _ => Ok(ExprImpl::FunctionCall(
130 FunctionCall::new_unchecked(
131 Type::IcebergTransform,
132 vec![
133 ExprImpl::literal_varchar(transform.to_string()),
134 ExprImpl::InputRef(
135 InputRef::new(col_id, columns[col_id].column_desc.data_type.clone())
136 .into(),
137 ),
138 ],
139 result_type,
140 )
141 .into(),
142 )),
143 }
144 }
145
146 pub fn convert_to_expression(self, columns: &[ColumnCatalog]) -> Result<ExprImpl> {
147 let child_exprs = self
148 .partition_fields
149 .into_iter()
150 .zip_eq_debug(self.partition_type.iter())
151 .map(|((field_name, transform), (_, result_type))| {
152 let col_id = find_column_idx_by_name(columns, &field_name)?;
153 Self::transform_to_expression(&transform, col_id, columns, result_type.clone())
154 })
155 .collect::<Result<Vec<_>>>()?;
156
157 Ok(ExprImpl::FunctionCall(
158 FunctionCall::new_unchecked(
159 Type::Row,
160 child_exprs,
161 DataType::Struct(self.partition_type),
162 )
163 .into(),
164 ))
165 }
166}
167
168#[inline]
169fn find_column_idx_by_name(columns: &[ColumnCatalog], col_name: &str) -> Result<usize> {
170 columns
171 .iter()
172 .position(|col| col.column_desc.name == col_name)
173 .ok_or_else(|| {
174 ErrorCode::InvalidInputSyntax(format!("Sink primary key column not found: {}. Please use ',' as the delimiter for different primary key columns.", col_name))
175 .into()
176 })
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Hash)]
181pub struct StreamSink {
182 pub base: PlanBase<Stream>,
183 input: PlanRef,
184 sink_desc: SinkDesc,
185 log_store_type: SinkLogStoreType,
186}
187
188impl StreamSink {
189 #[must_use]
190 pub fn new(input: PlanRef, sink_desc: SinkDesc, log_store_type: SinkLogStoreType) -> Self {
191 let input_kind = input.stream_kind();
196 let kind = match sink_desc.sink_type {
197 SinkType::AppendOnly => {
198 if !sink_desc.ignore_delete {
199 assert_eq!(
200 input_kind,
201 StreamKind::AppendOnly,
202 "{input_kind} stream cannot be used as input of append-only sink",
203 );
204 }
205 StreamKind::AppendOnly
206 }
207 SinkType::Upsert => StreamKind::Upsert,
208 SinkType::Retract => {
209 assert_ne!(
210 input_kind,
211 StreamKind::Upsert,
212 "upsert stream cannot be used as input of retract sink",
213 );
214 StreamKind::Retract
215 }
216 };
217
218 let base = PlanBase::new_stream(
219 input.ctx(),
220 input.schema().clone(),
221 input.stream_key().map(|v| v.to_vec()),
228 input.functional_dependency().clone(),
229 input.distribution().clone(),
230 kind,
231 input.emit_on_window_close(),
232 input.watermark_columns().clone(),
233 input.columns_monotonicity().clone(),
234 );
235
236 Self {
237 base,
238 input,
239 sink_desc,
240 log_store_type,
241 }
242 }
243
244 pub fn sink_desc(&self) -> &SinkDesc {
245 &self.sink_desc
246 }
247
248 fn derive_iceberg_sink_distribution(
249 input: PlanRef,
250 partition_info: Option<PartitionComputeInfo>,
251 columns: &[ColumnCatalog],
252 ) -> Result<(RequiredDist, PlanRef, Option<usize>)> {
253 if let Some(partition_info) = partition_info {
255 let input_fields = input.schema().fields();
256
257 let mut exprs: Vec<_> = input_fields
258 .iter()
259 .enumerate()
260 .map(|(idx, field)| InputRef::new(idx, field.data_type.clone()).into())
261 .collect();
262
263 exprs.push(partition_info.convert_to_expression(columns)?);
265 let partition_col_idx = exprs.len() - 1;
266 let project = StreamProject::new(generic::Project::new(exprs.clone(), input));
267 Ok((
268 RequiredDist::shard_by_key(project.schema().len(), &[partition_col_idx]),
269 project.into(),
270 Some(partition_col_idx),
271 ))
272 } else {
273 Ok((
274 RequiredDist::shard_by_key(input.schema().len(), input.expect_stream_key()),
275 input,
276 None,
277 ))
278 }
279 }
280
281 #[expect(clippy::too_many_arguments)]
282 pub fn create(
283 StreamOptimizedLogicalPlanRoot {
284 plan: mut input,
285 required_dist: user_distributed_by,
286 required_order: user_order_by,
287 out_fields: user_cols,
288 out_names,
289 ..
290 }: StreamOptimizedLogicalPlanRoot,
291 name: String,
292 db_name: String,
293 sink_from_table_name: String,
294 target_table: Option<Arc<TableCatalog>>,
295 target_table_mapping: Option<Vec<Option<usize>>>,
296 definition: String,
297 mut properties: WithOptionsSecResolved,
298 format_desc: Option<SinkFormatDesc>,
299 partition_info: Option<PartitionComputeInfo>,
300 auto_refresh_schema_from_table: Option<Arc<TableCatalog>>,
301 ) -> Result<Self> {
302 let (sink_type, ignore_delete) =
303 Self::derive_sink_type(input.stream_kind(), &properties, format_desc.as_ref())?;
304
305 let mut emit_pk_extension_notice: Option<String> = None;
306 let mut columns = derive_columns(input.schema(), out_names, &user_cols)?;
307 let (pk, _) = derive_pk(
308 input.clone(),
309 user_distributed_by.clone(),
310 user_order_by,
311 &columns,
312 );
313 let derived_pk = pk.iter().map(|k| k.column_index).collect_vec();
314
315 let is_iceberg_pk_index = properties.is_iceberg_connector()
316 && properties
317 .get(ENABLE_PK_INDEX)
318 .is_some_and(|v| v.eq_ignore_ascii_case("true"));
319
320 if is_iceberg_pk_index && properties.get(LEGACY_PK_INDEX_FILE_PATH).is_none() {
323 properties.insert(LEGACY_PK_INDEX_FILE_PATH.to_owned(), "false".to_owned());
324 }
325
326 if is_iceberg_pk_index && properties.get(DOWNSTREAM_PK_KEY).is_some() {
329 return Err(ErrorCode::InvalidInputSyntax(
330 "Iceberg sink with `enable_pk_index='true'` does not allow a user-specified `primary_key`. \
331 The primary key is automatically derived from the upstream stream key.".to_owned(),
332 )
333 .into());
334 }
335
336 let mut downstream_pk = properties
338 .get(DOWNSTREAM_PK_KEY)
339 .map(|v| Self::parse_downstream_pk(v, &columns))
340 .transpose()?;
341
342 if let Some(t) = &target_table {
343 let user_defined_primary_key_table = t.row_id_index.is_none();
344 let sink_is_append_only = sink_type.is_append_only();
345
346 if !user_defined_primary_key_table && !sink_is_append_only {
347 return Err(RwError::from(ErrorCode::BindError(
348 "Only append-only sinks can sink to a table without primary keys. please try to add type = 'append-only' in the with option. e.g. create sink s into t as select * from t1 with (type = 'append-only')".to_owned(),
349 )));
350 }
351
352 if t.append_only && !sink_is_append_only {
353 return Err(RwError::from(ErrorCode::BindError(
354 "Only append-only sinks can sink to a append only table. please try to add type = 'append-only' in the with option. e.g. create sink s into t as select * from t1 with (type = 'append-only')".to_owned(),
355 )));
356 }
357
358 if sink_is_append_only {
359 downstream_pk = None;
360 } else {
361 let target_table_mapping = target_table_mapping.unwrap();
362 let pk = t.pk()
363 .iter()
364 .map(|c| {
365 target_table_mapping[c.column_index].ok_or_else(
366 || ErrorCode::InvalidInputSyntax("When using non append only sink into table, the primary key of the table must be included in the sink result.".to_owned()).into())
367 })
368 .try_collect::<_, _, RwError>()?;
369 downstream_pk = Some(pk);
370 }
371 } else if downstream_pk.is_none()
372 && sink_type == SinkType::Upsert
373 && (properties
374 .get(CREATE_TABLE_IF_NOT_EXISTS)
375 .is_some_and(|v| v.eq_ignore_ascii_case("true"))
376 || properties.is_iceberg_connector())
377 {
378 downstream_pk = Some(derived_pk.clone())
379 } else if is_iceberg_pk_index {
380 let (pk, promoted) = promote_iceberg_pk_index_stream_key(&input, &mut columns)?;
384
385 let pk_names = pk
386 .iter()
387 .map(|&i| columns[i].name().to_owned())
388 .collect::<Vec<_>>()
389 .join(",");
390
391 if promoted
395 && !properties
396 .get(CREATE_TABLE_IF_NOT_EXISTS)
397 .is_some_and(|v| v.eq_ignore_ascii_case("true"))
398 {
399 return Err(ErrorCode::InvalidInputSyntax(
400 "Iceberg sink with `enable_pk_index='true'` requires `create_table_if_not_exists='true'` \
401 because the planner needs to add the hidden upstream stream-key columns to the iceberg table. \
402 Existing iceberg tables cannot be extended in-place by this sink."
403 .to_owned(),
404 )
405 .into());
406 }
407
408 emit_pk_extension_notice = Some(pk_names.clone());
411 properties.insert(DOWNSTREAM_PK_KEY.to_owned(), pk_names);
412 downstream_pk = Some(pk);
413 }
414
415 if let Some(pk) = &downstream_pk
421 && pk.is_empty()
422 {
423 bail_invalid_input_syntax!(
424 "Empty primary key is not supported. \
425 Please specify the primary key in WITH options."
426 )
427 }
428
429 if let StreamKind::Upsert = input.stream_kind()
433 && let Some(downstream_pk) = &downstream_pk
434 && !downstream_pk.iter().all(|i| derived_pk.contains(i))
435 {
436 let unsafe_allow_pk_mismatch = input
437 .ctx()
438 .session_ctx()
439 .config()
440 .streaming_unsafe_allow_upsert_sink_pk_mismatch();
441 if !unsafe_allow_pk_mismatch {
442 bail_bind_error!(
443 "When sinking from an upsert stream, \
444 the downstream primary key must be the same as or a subset of the one derived from the stream."
445 )
446 }
447 input.ctx().session_ctx().notice_to_user(
448 "Unsafe upsert sink primary-key mismatch is allowed by session variable \
449 `streaming_unsafe_allow_upsert_sink_pk_mismatch`. This may leave stale rows in \
450 the downstream system if a downstream primary-key column changes without its \
451 old value being emitted.",
452 );
453 }
454
455 if let Some(upstream_table) = &auto_refresh_schema_from_table
456 && let Some(downstream_pk) = &downstream_pk
457 {
458 let upstream_table_pk_col_names = upstream_table
459 .pk
460 .iter()
461 .map(|order| {
462 upstream_table.columns[order.column_index]
463 .column_desc
464 .name()
465 })
466 .collect_vec();
467 let sink_pk_col_names = downstream_pk
468 .iter()
469 .map(|&column_index| columns[column_index].name())
470 .collect_vec();
471 if upstream_table_pk_col_names != sink_pk_col_names {
472 let is_iceberg_row_id_alias = properties.is_iceberg_connector()
473 && upstream_table_pk_col_names.len() == 1
474 && upstream_table_pk_col_names[0] == ROW_ID_COLUMN_NAME
475 && sink_pk_col_names.len() == 1
476 && sink_pk_col_names[0] == RISINGWAVE_ICEBERG_ROW_ID;
477 if !is_iceberg_row_id_alias {
478 return Err(ErrorCode::InvalidInputSyntax(format!(
479 "sink with auto schema change should have same pk as upstream table {:?}, but got {:?}",
480 upstream_table_pk_col_names, sink_pk_col_names
481 ))
482 .into());
483 }
484 }
485 }
486
487 let mut extra_partition_col_idx = None;
488
489 let required_dist = match input.distribution() {
490 Distribution::Single => RequiredDist::single(),
491 _ => {
492 match properties.get("connector") {
493 Some(s) if s == "jdbc" && sink_type == SinkType::Upsert => {
494 let Some(downstream_pk) = &downstream_pk else {
495 return Err(ErrorCode::InvalidInputSyntax(format!(
496 "Primary key must be defined for upsert JDBC sink. Please specify the \"{key}='pk1,pk2,...'\" in WITH options.",
497 key = DOWNSTREAM_PK_KEY
498 )).into());
499 };
500 RequiredDist::hash_shard(downstream_pk)
503 }
504 Some(s) if s == ICEBERG_SINK => {
505 let partition_info = if is_iceberg_pk_index {
509 None
510 } else {
511 partition_info
512 };
513 let (default_dist, new_input, partition_col_idx) =
514 Self::derive_iceberg_sink_distribution(
515 input,
516 partition_info,
517 &columns,
518 )?;
519 input = new_input;
520 extra_partition_col_idx = partition_col_idx;
521 if is_iceberg_pk_index && let Some(pk) = &downstream_pk {
525 RequiredDist::hash_shard(pk)
526 } else {
527 default_dist
528 }
529 }
530 _ => {
531 assert_matches!(user_distributed_by, RequiredDist::Any);
532 if let Some(downstream_pk) = &downstream_pk {
533 RequiredDist::shard_by_key(input.schema().len(), downstream_pk)
536 } else {
537 RequiredDist::shard_by_key(
538 input.schema().len(),
539 input.expect_stream_key(),
540 )
541 }
542 }
543 }
544 }
545 };
546 let input = required_dist.streaming_enforce_if_not_satisfies(input)?;
547 let input = if input.ctx().session_ctx().config().streaming_separate_sink()
548 && input.as_stream_exchange().is_none()
549 {
550 StreamExchange::new_no_shuffle(input).into()
551 } else {
552 input
553 };
554
555 let distribution_key = input.distribution().dist_column_indices().to_vec();
556 let create_type = if input.ctx().session_ctx().config().background_ddl()
557 && plan_can_use_background_ddl(&input)
558 {
559 CreateType::Background
560 } else {
561 CreateType::Foreground
562 };
563 let (mut properties, secret_refs) = properties.into_parts();
564 if let Some(target_table) = &target_table
565 && target_table_requires_row_level_conflict_handling(target_table)
566 {
567 properties.insert(
568 SINK_USER_PRESERVE_ROW_LEVEL_CHANGES.to_owned(),
569 "true".to_owned(),
570 );
571 }
572 let is_exactly_once = properties
573 .get("is_exactly_once")
574 .map(|v| v.to_lowercase() == "true");
575
576 let mut sink_desc = SinkDesc {
577 id: SinkId::placeholder(),
578 name,
579 db_name,
580 sink_from_name: sink_from_table_name,
581 definition,
582 columns,
583 plan_pk: pk,
584 downstream_pk,
585 distribution_key,
586 properties,
587 secret_refs,
588 sink_type,
589 ignore_delete,
590 format_desc,
591 target_table: target_table.as_ref().map(|catalog| catalog.id()),
592 extra_partition_col_idx,
593 create_type,
594 is_exactly_once,
595 auto_refresh_schema_from_table: auto_refresh_schema_from_table
596 .as_ref()
597 .map(|table| table.id),
598 };
599
600 let unsupported_sink = |sink: &str| -> Result<_> {
601 Err(ErrorCode::InvalidInputSyntax(format!("unsupported sink type {}", sink)).into())
602 };
603
604 let sink_decouple = match sink_desc.properties.get(CONNECTOR_TYPE_KEY) {
606 Some(connector) => {
607 let connector_type = connector.to_lowercase();
608 match_sink_name_str!(
609 connector_type.as_str(),
610 SinkType,
611 {
612 if connector == TABLE_SINK && sink_desc.target_table.is_none() {
614 unsupported_sink(TABLE_SINK)
615 } else {
616 sink_desc.properties.remove(AUTO_SCHEMA_CHANGE_KEY);
617 SinkType::set_default_commit_checkpoint_interval(
618 &mut sink_desc,
619 &input.ctx().session_ctx().config().sink_decouple(),
620 )?;
621 let support_schema_change = SinkType::support_schema_change();
622 if !support_schema_change && auto_refresh_schema_from_table.is_some() {
623 return Err(ErrorCode::InvalidInputSyntax(format!(
624 "{} sink does not support schema change",
625 connector_type
626 ))
627 .into());
628 }
629 SinkType::is_sink_decouple(
630 &input.ctx().session_ctx().config().sink_decouple(),
631 )
632 .map_err(Into::into)
633 }
634 },
635 |other: &str| unsupported_sink(other)
636 )?
637 }
638 None => {
639 return Err(ErrorCode::InvalidInputSyntax(
640 "connector not specified when create sink".to_owned(),
641 )
642 .into());
643 }
644 };
645 if !sink_decouple
646 && sink_desc.is_exactly_once.is_none()
647 && let Some(connector) = sink_desc.properties.get(CONNECTOR_TYPE_KEY)
648 {
649 let connector_type = connector.to_lowercase();
650 if connector_type == ICEBERG_SINK {
651 sink_desc
654 .properties
655 .insert("is_exactly_once".to_owned(), "false".to_owned());
656 }
657 }
658 let log_store_type = if sink_decouple {
659 SinkLogStoreType::KvLogStore
660 } else {
661 SinkLogStoreType::InMemoryLogStore
662 };
663
664 let input = if sink_decouple && target_table.is_some() {
666 StreamSyncLogStore::new(input).into()
667 } else {
668 input
669 };
670
671 let sink = Self::new(input, sink_desc, log_store_type);
672 if let Some(pk_names) = emit_pk_extension_notice {
673 sink.base.ctx().session_ctx().notice_to_user(format!(
674 "Iceberg pk-index sink `{}`: the iceberg primary key was automatically derived from \
675 the upstream stream key as ({}).",
676 sink.sink_desc.name, pk_names,
677 ));
678 }
679 Ok(sink)
680 }
681
682 fn sink_type_in_prop(properties: &WithOptionsSecResolved) -> Result<Option<SinkType>> {
683 if let Some(sink_type) = properties.get(SINK_TYPE_OPTION) {
684 let sink_type = match sink_type.as_str() {
685 SINK_TYPE_APPEND_ONLY => SinkType::AppendOnly,
686 SINK_TYPE_UPSERT => {
687 if properties.is_iceberg_connector() {
688 SinkType::Retract
690 } else {
691 SinkType::Upsert
692 }
693 }
694 SINK_TYPE_RETRACT | SINK_TYPE_DEBEZIUM => SinkType::Retract,
695 _ => {
696 return Err(ErrorCode::InvalidInputSyntax(format!(
697 "`{}` must be {}, {}, {}, or {}",
698 SINK_TYPE_OPTION,
699 SINK_TYPE_APPEND_ONLY,
700 SINK_TYPE_RETRACT,
701 SINK_TYPE_UPSERT,
702 SINK_TYPE_DEBEZIUM,
703 ))
704 .into());
705 }
706 };
707 return Ok(Some(sink_type));
708 }
709 Ok(None)
710 }
711
712 fn is_user_ignore_delete(properties: &WithOptionsSecResolved) -> Result<bool> {
714 let has_ignore_delete = properties.contains_key(SINK_USER_IGNORE_DELETE_OPTION);
715 let has_force_append_only = properties.contains_key(SINK_USER_FORCE_APPEND_ONLY_OPTION);
716
717 if has_ignore_delete && has_force_append_only {
718 return Err(ErrorCode::InvalidInputSyntax(format!(
719 "`{}` is an alias of `{}`, only one of them can be specified.",
720 SINK_USER_FORCE_APPEND_ONLY_OPTION, SINK_USER_IGNORE_DELETE_OPTION
721 ))
722 .into());
723 }
724
725 let key = if has_ignore_delete {
726 SINK_USER_IGNORE_DELETE_OPTION
727 } else if has_force_append_only {
728 SINK_USER_FORCE_APPEND_ONLY_OPTION
729 } else {
730 return Ok(false);
731 };
732
733 if properties.value_eq_ignore_case(key, "true") {
734 Ok(true)
735 } else if properties.value_eq_ignore_case(key, "false") {
736 Ok(false)
737 } else {
738 Err(ErrorCode::InvalidInputSyntax(format!("`{key}` must be true or false")).into())
739 }
740 }
741
742 fn derive_sink_type(
751 derived_stream_kind: StreamKind,
752 properties: &WithOptionsSecResolved,
753 format_desc: Option<&SinkFormatDesc>,
754 ) -> Result<(SinkType, bool)> {
755 let (user_defined_sink_type, user_ignore_delete, syntax_legacy) = match format_desc {
756 Some(f) => (
757 Some(match f.format {
758 SinkFormat::AppendOnly => SinkType::AppendOnly,
759 SinkFormat::Upsert => SinkType::Upsert,
760 SinkFormat::Debezium => SinkType::Retract,
761 }),
762 Self::is_user_ignore_delete(&WithOptionsSecResolved::without_secrets(
763 f.options.clone(),
764 ))?,
765 false,
766 ),
767 None => (
768 Self::sink_type_in_prop(properties)?,
769 Self::is_user_ignore_delete(properties)?,
770 true,
771 ),
772 };
773
774 if let Some(user_defined_sink_type) = user_defined_sink_type {
775 match user_defined_sink_type {
776 SinkType::AppendOnly => {
777 if derived_stream_kind != StreamKind::AppendOnly && !user_ignore_delete {
778 return Err(ErrorCode::InvalidInputSyntax(format!(
779 "The sink of {} stream cannot be append-only. Please add \"force_append_only='true'\" in {} options to force the sink to be append-only. \
780 Notice that this will cause the sink executor to drop DELETE messages and convert UPDATE messages to INSERT.",
781 derived_stream_kind,
782 if syntax_legacy { "WITH" } else { "FORMAT ENCODE" }
783 ))
784 .into());
785 }
786 }
787 SinkType::Upsert => { }
788 SinkType::Retract => {
789 if user_ignore_delete {
790 bail_invalid_input_syntax!(
791 "Retract sink type does not support `ignore_delete`. \
792 Please use `type = 'append-only'` or `type = 'upsert'` instead.",
793 );
794 }
795 if derived_stream_kind == StreamKind::Upsert {
796 bail_invalid_input_syntax!(
797 "The sink of upsert stream cannot be retract. \
798 Please create a materialized view or sink-into-table with this query before sinking it.",
799 );
800 }
801 }
802 }
803 Ok((user_defined_sink_type, user_ignore_delete))
804 } else {
805 let sink_type = match derived_stream_kind {
808 StreamKind::Retract | StreamKind::Upsert => SinkType::Upsert,
811 StreamKind::AppendOnly => SinkType::AppendOnly,
812 };
813 Ok((sink_type, user_ignore_delete))
814 }
815 }
816
817 fn parse_downstream_pk(
823 downstream_pk_str: &str,
824 columns: &[ColumnCatalog],
825 ) -> Result<Vec<usize>> {
826 let downstream_pk = downstream_pk_str.split(',').collect_vec();
828 let mut downstream_pk_indices = Vec::with_capacity(downstream_pk.len());
829 for key in downstream_pk {
830 let trimmed_key = key.trim();
831 if trimmed_key.is_empty() {
832 continue;
833 }
834 downstream_pk_indices.push(find_column_idx_by_name(columns, trimmed_key)?);
835 }
836 if downstream_pk_indices.is_empty() {
837 bail_invalid_input_syntax!(
838 "Specified primary key should not be empty. \
839 To use derived primary key, remove {DOWNSTREAM_PK_KEY} from WITH options instead."
840 );
841 }
842 Ok(downstream_pk_indices)
843 }
844
845 fn infer_kv_log_store_table_catalog(&self) -> TableCatalog {
848 infer_kv_log_store_table_catalog_inner(&self.input, &self.sink_desc().columns)
849 }
850
851 pub fn into_stream_plan(self) -> Result<PlanRef> {
857 use super::{StreamIcebergWithPkIndexPositionDeleteMerger, StreamIcebergWithPkIndexWriter};
858
859 if !is_iceberg_with_pk_index_sink(&self.sink_desc)? {
860 return Ok(self.into());
861 }
862
863 let writer: PlanRef = StreamIcebergWithPkIndexWriter::from_stream_sink(&self)?.into();
864 let position_delete_merger: PlanRef =
865 StreamIcebergWithPkIndexPositionDeleteMerger::new(writer, self.sink_desc).into();
866 Ok(position_delete_merger)
867 }
868}
869
870pub fn is_iceberg_with_pk_index_sink(sink_desc: &SinkDesc) -> Result<bool> {
871 if !sink_desc
872 .properties
873 .get(CONNECTOR_TYPE_KEY)
874 .is_some_and(|connector| connector.eq_ignore_ascii_case(ICEBERG_SINK))
875 {
876 return Ok(false);
877 }
878
879 let res = sink_desc
880 .properties
881 .get(ENABLE_PK_INDEX)
882 .is_some_and(|v| v.eq_ignore_ascii_case("true"));
883 Ok(res)
884}
885
886fn promote_iceberg_pk_index_stream_key(
896 input: &PlanRef,
897 columns: &mut Vec<ColumnCatalog>,
898) -> Result<(Vec<usize>, bool)> {
899 let mut promoted = false;
900 let stream_key = input.expect_stream_key();
901 if stream_key.is_empty() {
902 bail_invalid_input_syntax!(
903 "Iceberg sink with `enable_pk_index='true'` requires a non-empty upstream stream key \
904 to derive the primary key from."
905 );
906 }
907
908 for &i in stream_key {
909 if !columns[i].is_hidden {
910 continue;
911 }
912 columns[i].is_hidden = false;
914 promoted = true;
915 }
916
917 if let Some(col) = columns.iter().find(|c| c.is_hidden) {
924 return Err(ErrorCode::InternalError(format!(
925 "iceberg pk-index sink has a hidden column `{}` after stream-key promotion; \
926 all sink columns must be visible",
927 col.name()
928 ))
929 .into());
930 }
931
932 let downstream_pk = stream_key.to_vec();
933 Ok((downstream_pk, promoted))
934}
935
936impl PlanTreeNodeUnary<Stream> for StreamSink {
937 fn input(&self) -> PlanRef {
938 self.input.clone()
939 }
940
941 fn clone_with_input(&self, input: PlanRef) -> Self {
942 Self::new(input, self.sink_desc.clone(), self.log_store_type)
943 }
945}
946
947impl_plan_tree_node_for_unary! { Stream, StreamSink }
948
949impl Distill for StreamSink {
950 fn distill<'a>(&self) -> XmlNode<'a> {
951 let sink_type = if self.sink_desc.sink_type.is_append_only() {
952 "append-only"
953 } else {
954 "upsert"
955 };
956 let column_names = self
957 .sink_desc
958 .columns
959 .iter()
960 .map(|col| col.name_with_hidden().to_string())
961 .map(Pretty::from)
962 .collect();
963 let column_names = Pretty::Array(column_names);
964 let mut vec = Vec::with_capacity(3);
965 vec.push(("type", Pretty::from(sink_type)));
966 vec.push(("columns", column_names));
967 if let Some(pk) = &self.sink_desc.downstream_pk {
968 let sink_pk = IndicesDisplay {
969 indices: pk,
970 schema: self.base.schema(),
971 };
972 vec.push(("downstream_pk", sink_pk.distill()));
973 }
974 childless_record("StreamSink", vec)
975 }
976}
977
978impl StreamNode for StreamSink {
979 fn to_stream_prost_body(&self, state: &mut BuildFragmentGraphState) -> PbNodeBody {
980 use risingwave_pb::stream_plan::*;
981
982 let table = self
984 .infer_kv_log_store_table_catalog()
985 .with_id(state.gen_table_id_wrapped());
986
987 PbNodeBody::Sink(Box::new(SinkNode {
988 sink_desc: Some(self.sink_desc.to_proto()),
989 table: Some(table.to_internal_table_prost()),
990 log_store_type: self.log_store_type as i32,
991 rate_limit: self.base.ctx().overwrite_options().sink_rate_limit,
992 }))
993 }
994}
995
996impl ExprRewritable<Stream> for StreamSink {}
997
998impl ExprVisitable for StreamSink {}
999
1000#[cfg(test)]
1001mod test {
1002 use fixedbitset::FixedBitSet;
1003 use risingwave_common::catalog::{
1004 ColumnCatalog, ColumnDesc, ColumnId, ConflictBehavior, Field,
1005 };
1006 use risingwave_common::types::{DataType, StructType};
1007 use risingwave_common::util::iter_util::ZipEqDebug;
1008 use risingwave_pb::expr::expr_node::Type;
1009
1010 use super::{IcebergPartitionInfo, *};
1011 use crate::catalog::table_catalog::TableType;
1012 use crate::expr::{Expr, ExprImpl};
1013 use crate::optimizer::plan_node::utils::TableCatalogBuilder;
1014
1015 fn create_column_catalog() -> Vec<ColumnCatalog> {
1016 vec![
1017 ColumnCatalog {
1018 column_desc: ColumnDesc::named("v1", ColumnId::new(1), DataType::Int32),
1019 is_hidden: false,
1020 },
1021 ColumnCatalog {
1022 column_desc: ColumnDesc::named("v2", ColumnId::new(2), DataType::Timestamptz),
1023 is_hidden: false,
1024 },
1025 ColumnCatalog {
1026 column_desc: ColumnDesc::named("v3", ColumnId::new(2), DataType::Timestamp),
1027 is_hidden: false,
1028 },
1029 ]
1030 }
1031
1032 fn test_target_table(
1033 conflict_behavior: ConflictBehavior,
1034 version_column_indices: Vec<usize>,
1035 ) -> TableCatalog {
1036 let mut builder = TableCatalogBuilder::default();
1037 let col_idx = builder.add_column(&Field::with_name(DataType::Int32, "v1"));
1038 let mut table = builder.build(vec![], 0);
1039 table.table_type = TableType::Table;
1040 table.columns = vec![ColumnCatalog {
1041 column_desc: ColumnDesc::named("v1", ColumnId::new(col_idx as i32), DataType::Int32),
1042 is_hidden: false,
1043 }];
1044 table.conflict_behavior = conflict_behavior;
1045 table.version_column_indices = version_column_indices;
1046 table.watermark_columns = FixedBitSet::with_capacity(table.columns.len());
1047 table
1048 }
1049
1050 #[test]
1051 fn test_target_table_requires_row_level_conflict_handling() {
1052 assert!(target_table_requires_row_level_conflict_handling(
1053 &test_target_table(ConflictBehavior::DoUpdateIfNotNull, vec![])
1054 ));
1055 assert!(target_table_requires_row_level_conflict_handling(
1056 &test_target_table(ConflictBehavior::IgnoreConflict, vec![])
1057 ));
1058 assert!(target_table_requires_row_level_conflict_handling(
1059 &test_target_table(ConflictBehavior::Overwrite, vec![0])
1060 ));
1061 assert!(!target_table_requires_row_level_conflict_handling(
1062 &test_target_table(ConflictBehavior::Overwrite, vec![])
1063 ));
1064 }
1065
1066 #[test]
1067 fn test_iceberg_convert_to_expression() {
1068 let partition_type = StructType::new(vec![
1069 ("f1", DataType::Int32),
1070 ("f2", DataType::Int32),
1071 ("f3", DataType::Int32),
1072 ("f4", DataType::Int32),
1073 ("f5", DataType::Int32),
1074 ("f6", DataType::Int32),
1075 ("f7", DataType::Int32),
1076 ("f8", DataType::Int32),
1077 ("f9", DataType::Int32),
1078 ]);
1079 let partition_fields = vec![
1080 ("v1".into(), Transform::Identity),
1081 ("v1".into(), Transform::Bucket(10)),
1082 ("v1".into(), Transform::Truncate(3)),
1083 ("v2".into(), Transform::Year),
1084 ("v2".into(), Transform::Month),
1085 ("v3".into(), Transform::Day),
1086 ("v3".into(), Transform::Hour),
1087 ("v1".into(), Transform::Void),
1088 ("v3".into(), Transform::Void),
1089 ];
1090 let partition_info = IcebergPartitionInfo {
1091 partition_type: partition_type.clone(),
1092 partition_fields: partition_fields.clone(),
1093 };
1094 let catalog = create_column_catalog();
1095 let actual_expr = partition_info.convert_to_expression(&catalog).unwrap();
1096 let actual_expr = actual_expr.as_function_call().unwrap();
1097
1098 assert_eq!(
1099 actual_expr.return_type(),
1100 DataType::Struct(partition_type.clone())
1101 );
1102 assert_eq!(actual_expr.inputs().len(), partition_fields.len());
1103 assert_eq!(actual_expr.func_type(), Type::Row);
1104
1105 for ((expr, (_, transform)), (_, expect_type)) in actual_expr
1106 .inputs()
1107 .iter()
1108 .zip_eq_debug(partition_fields.iter())
1109 .zip_eq_debug(partition_type.iter())
1110 {
1111 match transform {
1112 Transform::Identity => {
1113 assert!(expr.is_input_ref());
1114 assert_eq!(expr.return_type(), *expect_type);
1115 }
1116 Transform::Void => {
1117 assert!(expr.is_literal());
1118 assert_eq!(expr.return_type(), *expect_type);
1119 }
1120 _ => {
1121 let expr = expr.as_function_call().unwrap();
1122 assert_eq!(expr.func_type(), Type::IcebergTransform);
1123 assert_eq!(expr.inputs().len(), 2);
1124 assert_eq!(
1125 expr.inputs()[0],
1126 ExprImpl::literal_varchar(transform.to_string())
1127 );
1128 }
1129 }
1130 }
1131 }
1132}