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