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::{AUTO_SCHEMA_CHANGE_KEY, 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 sink_desc.properties.remove(AUTO_SCHEMA_CHANGE_KEY);
609 SinkType::set_default_commit_checkpoint_interval(
610 &mut sink_desc,
611 &input.ctx().session_ctx().config().sink_decouple(),
612 )?;
613 let support_schema_change = SinkType::support_schema_change();
614 if !support_schema_change && auto_refresh_schema_from_table.is_some() {
615 return Err(ErrorCode::InvalidInputSyntax(format!(
616 "{} sink does not support schema change",
617 connector_type
618 ))
619 .into());
620 }
621 SinkType::is_sink_decouple(
622 &input.ctx().session_ctx().config().sink_decouple(),
623 )
624 .map_err(Into::into)
625 }
626 },
627 |other: &str| unsupported_sink(other)
628 )?
629 }
630 None => {
631 return Err(ErrorCode::InvalidInputSyntax(
632 "connector not specified when create sink".to_owned(),
633 )
634 .into());
635 }
636 };
637 if !sink_decouple
638 && sink_desc.is_exactly_once.is_none()
639 && let Some(connector) = sink_desc.properties.get(CONNECTOR_TYPE_KEY)
640 {
641 let connector_type = connector.to_lowercase();
642 if connector_type == ICEBERG_SINK {
643 sink_desc
646 .properties
647 .insert("is_exactly_once".to_owned(), "false".to_owned());
648 }
649 }
650 let log_store_type = if sink_decouple {
651 SinkLogStoreType::KvLogStore
652 } else {
653 SinkLogStoreType::InMemoryLogStore
654 };
655
656 let input = if sink_decouple && target_table.is_some() {
658 StreamSyncLogStore::new(input).into()
659 } else {
660 input
661 };
662
663 let sink = Self::new(input, sink_desc, log_store_type);
664 if let Some(pk_names) = emit_pk_extension_notice {
665 sink.base.ctx().session_ctx().notice_to_user(format!(
666 "Iceberg pk-index sink `{}`: the iceberg primary key was automatically derived from \
667 the upstream stream key as ({}).",
668 sink.sink_desc.name, pk_names,
669 ));
670 }
671 Ok(sink)
672 }
673
674 fn sink_type_in_prop(properties: &WithOptionsSecResolved) -> Result<Option<SinkType>> {
675 if let Some(sink_type) = properties.get(SINK_TYPE_OPTION) {
676 let sink_type = match sink_type.as_str() {
677 SINK_TYPE_APPEND_ONLY => SinkType::AppendOnly,
678 SINK_TYPE_UPSERT => {
679 if properties.is_iceberg_connector() {
680 SinkType::Retract
682 } else {
683 SinkType::Upsert
684 }
685 }
686 SINK_TYPE_RETRACT | SINK_TYPE_DEBEZIUM => SinkType::Retract,
687 _ => {
688 return Err(ErrorCode::InvalidInputSyntax(format!(
689 "`{}` must be {}, {}, {}, or {}",
690 SINK_TYPE_OPTION,
691 SINK_TYPE_APPEND_ONLY,
692 SINK_TYPE_RETRACT,
693 SINK_TYPE_UPSERT,
694 SINK_TYPE_DEBEZIUM,
695 ))
696 .into());
697 }
698 };
699 return Ok(Some(sink_type));
700 }
701 Ok(None)
702 }
703
704 fn is_user_ignore_delete(properties: &WithOptionsSecResolved) -> Result<bool> {
706 let has_ignore_delete = properties.contains_key(SINK_USER_IGNORE_DELETE_OPTION);
707 let has_force_append_only = properties.contains_key(SINK_USER_FORCE_APPEND_ONLY_OPTION);
708
709 if has_ignore_delete && has_force_append_only {
710 return Err(ErrorCode::InvalidInputSyntax(format!(
711 "`{}` is an alias of `{}`, only one of them can be specified.",
712 SINK_USER_FORCE_APPEND_ONLY_OPTION, SINK_USER_IGNORE_DELETE_OPTION
713 ))
714 .into());
715 }
716
717 let key = if has_ignore_delete {
718 SINK_USER_IGNORE_DELETE_OPTION
719 } else if has_force_append_only {
720 SINK_USER_FORCE_APPEND_ONLY_OPTION
721 } else {
722 return Ok(false);
723 };
724
725 if properties.value_eq_ignore_case(key, "true") {
726 Ok(true)
727 } else if properties.value_eq_ignore_case(key, "false") {
728 Ok(false)
729 } else {
730 Err(ErrorCode::InvalidInputSyntax(format!("`{key}` must be true or false")).into())
731 }
732 }
733
734 fn derive_sink_type(
743 derived_stream_kind: StreamKind,
744 properties: &WithOptionsSecResolved,
745 format_desc: Option<&SinkFormatDesc>,
746 ) -> Result<(SinkType, bool)> {
747 let (user_defined_sink_type, user_ignore_delete, syntax_legacy) = match format_desc {
748 Some(f) => (
749 Some(match f.format {
750 SinkFormat::AppendOnly => SinkType::AppendOnly,
751 SinkFormat::Upsert => SinkType::Upsert,
752 SinkFormat::Debezium => SinkType::Retract,
753 }),
754 Self::is_user_ignore_delete(&WithOptionsSecResolved::without_secrets(
755 f.options.clone(),
756 ))?,
757 false,
758 ),
759 None => (
760 Self::sink_type_in_prop(properties)?,
761 Self::is_user_ignore_delete(properties)?,
762 true,
763 ),
764 };
765
766 if let Some(user_defined_sink_type) = user_defined_sink_type {
767 match user_defined_sink_type {
768 SinkType::AppendOnly => {
769 if derived_stream_kind != StreamKind::AppendOnly && !user_ignore_delete {
770 return Err(ErrorCode::InvalidInputSyntax(format!(
771 "The sink of {} stream cannot be append-only. Please add \"force_append_only='true'\" in {} options to force the sink to be append-only. \
772 Notice that this will cause the sink executor to drop DELETE messages and convert UPDATE messages to INSERT.",
773 derived_stream_kind,
774 if syntax_legacy { "WITH" } else { "FORMAT ENCODE" }
775 ))
776 .into());
777 }
778 }
779 SinkType::Upsert => { }
780 SinkType::Retract => {
781 if user_ignore_delete {
782 bail_invalid_input_syntax!(
783 "Retract sink type does not support `ignore_delete`. \
784 Please use `type = 'append-only'` or `type = 'upsert'` instead.",
785 );
786 }
787 if derived_stream_kind == StreamKind::Upsert {
788 bail_invalid_input_syntax!(
789 "The sink of upsert stream cannot be retract. \
790 Please create a materialized view or sink-into-table with this query before sinking it.",
791 );
792 }
793 }
794 }
795 Ok((user_defined_sink_type, user_ignore_delete))
796 } else {
797 let sink_type = match derived_stream_kind {
800 StreamKind::Retract | StreamKind::Upsert => SinkType::Upsert,
803 StreamKind::AppendOnly => SinkType::AppendOnly,
804 };
805 Ok((sink_type, user_ignore_delete))
806 }
807 }
808
809 fn parse_downstream_pk(
815 downstream_pk_str: &str,
816 columns: &[ColumnCatalog],
817 ) -> Result<Vec<usize>> {
818 let downstream_pk = downstream_pk_str.split(',').collect_vec();
820 let mut downstream_pk_indices = Vec::with_capacity(downstream_pk.len());
821 for key in downstream_pk {
822 let trimmed_key = key.trim();
823 if trimmed_key.is_empty() {
824 continue;
825 }
826 downstream_pk_indices.push(find_column_idx_by_name(columns, trimmed_key)?);
827 }
828 if downstream_pk_indices.is_empty() {
829 bail_invalid_input_syntax!(
830 "Specified primary key should not be empty. \
831 To use derived primary key, remove {DOWNSTREAM_PK_KEY} from WITH options instead."
832 );
833 }
834 Ok(downstream_pk_indices)
835 }
836
837 fn infer_kv_log_store_table_catalog(&self) -> TableCatalog {
840 infer_kv_log_store_table_catalog_inner(&self.input, &self.sink_desc().columns)
841 }
842
843 pub fn into_stream_plan(self) -> Result<PlanRef> {
849 use super::{StreamIcebergWithPkIndexPositionDeleteMerger, StreamIcebergWithPkIndexWriter};
850
851 if !is_iceberg_with_pk_index_sink(&self.sink_desc)? {
852 return Ok(self.into());
853 }
854
855 let writer: PlanRef = StreamIcebergWithPkIndexWriter::from_stream_sink(&self)?.into();
856 let position_delete_merger: PlanRef =
857 StreamIcebergWithPkIndexPositionDeleteMerger::new(writer, self.sink_desc).into();
858 Ok(position_delete_merger)
859 }
860}
861
862pub fn is_iceberg_with_pk_index_sink(sink_desc: &SinkDesc) -> Result<bool> {
863 if !sink_desc
864 .properties
865 .get(CONNECTOR_TYPE_KEY)
866 .is_some_and(|connector| connector.eq_ignore_ascii_case(ICEBERG_SINK))
867 {
868 return Ok(false);
869 }
870
871 let res = sink_desc
872 .properties
873 .get(ENABLE_PK_INDEX)
874 .is_some_and(|v| v.eq_ignore_ascii_case("true"));
875 Ok(res)
876}
877
878fn promote_iceberg_pk_index_stream_key(
888 input: &PlanRef,
889 columns: &mut Vec<ColumnCatalog>,
890) -> Result<(Vec<usize>, bool)> {
891 let mut promoted = false;
892 let stream_key = input.expect_stream_key();
893 if stream_key.is_empty() {
894 bail_invalid_input_syntax!(
895 "Iceberg sink with `enable_pk_index='true'` requires a non-empty upstream stream key \
896 to derive the primary key from."
897 );
898 }
899
900 for &i in stream_key {
901 if !columns[i].is_hidden {
902 continue;
903 }
904 columns[i].is_hidden = false;
906 promoted = true;
907 }
908
909 if let Some(col) = columns.iter().find(|c| c.is_hidden) {
916 return Err(ErrorCode::InternalError(format!(
917 "iceberg pk-index sink has a hidden column `{}` after stream-key promotion; \
918 all sink columns must be visible",
919 col.name()
920 ))
921 .into());
922 }
923
924 let downstream_pk = stream_key.to_vec();
925 Ok((downstream_pk, promoted))
926}
927
928impl PlanTreeNodeUnary<Stream> for StreamSink {
929 fn input(&self) -> PlanRef {
930 self.input.clone()
931 }
932
933 fn clone_with_input(&self, input: PlanRef) -> Self {
934 Self::new(input, self.sink_desc.clone(), self.log_store_type)
935 }
937}
938
939impl_plan_tree_node_for_unary! { Stream, StreamSink }
940
941impl Distill for StreamSink {
942 fn distill<'a>(&self) -> XmlNode<'a> {
943 let sink_type = if self.sink_desc.sink_type.is_append_only() {
944 "append-only"
945 } else {
946 "upsert"
947 };
948 let column_names = self
949 .sink_desc
950 .columns
951 .iter()
952 .map(|col| col.name_with_hidden().to_string())
953 .map(Pretty::from)
954 .collect();
955 let column_names = Pretty::Array(column_names);
956 let mut vec = Vec::with_capacity(3);
957 vec.push(("type", Pretty::from(sink_type)));
958 vec.push(("columns", column_names));
959 if let Some(pk) = &self.sink_desc.downstream_pk {
960 let sink_pk = IndicesDisplay {
961 indices: pk,
962 schema: self.base.schema(),
963 };
964 vec.push(("downstream_pk", sink_pk.distill()));
965 }
966 childless_record("StreamSink", vec)
967 }
968}
969
970impl StreamNode for StreamSink {
971 fn to_stream_prost_body(&self, state: &mut BuildFragmentGraphState) -> PbNodeBody {
972 use risingwave_pb::stream_plan::*;
973
974 let table = self
976 .infer_kv_log_store_table_catalog()
977 .with_id(state.gen_table_id_wrapped());
978
979 PbNodeBody::Sink(Box::new(SinkNode {
980 sink_desc: Some(self.sink_desc.to_proto()),
981 table: Some(table.to_internal_table_prost()),
982 log_store_type: self.log_store_type as i32,
983 rate_limit: self.base.ctx().overwrite_options().sink_rate_limit,
984 }))
985 }
986}
987
988impl ExprRewritable<Stream> for StreamSink {}
989
990impl ExprVisitable for StreamSink {}
991
992#[cfg(test)]
993mod test {
994 use fixedbitset::FixedBitSet;
995 use risingwave_common::catalog::{
996 ColumnCatalog, ColumnDesc, ColumnId, ConflictBehavior, Field,
997 };
998 use risingwave_common::types::{DataType, StructType};
999 use risingwave_common::util::iter_util::ZipEqDebug;
1000 use risingwave_pb::expr::expr_node::Type;
1001
1002 use super::{IcebergPartitionInfo, *};
1003 use crate::catalog::table_catalog::TableType;
1004 use crate::expr::{Expr, ExprImpl};
1005 use crate::optimizer::plan_node::utils::TableCatalogBuilder;
1006
1007 fn create_column_catalog() -> Vec<ColumnCatalog> {
1008 vec![
1009 ColumnCatalog {
1010 column_desc: ColumnDesc::named("v1", ColumnId::new(1), DataType::Int32),
1011 is_hidden: false,
1012 },
1013 ColumnCatalog {
1014 column_desc: ColumnDesc::named("v2", ColumnId::new(2), DataType::Timestamptz),
1015 is_hidden: false,
1016 },
1017 ColumnCatalog {
1018 column_desc: ColumnDesc::named("v3", ColumnId::new(2), DataType::Timestamp),
1019 is_hidden: false,
1020 },
1021 ]
1022 }
1023
1024 fn test_target_table(
1025 conflict_behavior: ConflictBehavior,
1026 version_column_indices: Vec<usize>,
1027 ) -> TableCatalog {
1028 let mut builder = TableCatalogBuilder::default();
1029 let col_idx = builder.add_column(&Field::with_name(DataType::Int32, "v1"));
1030 let mut table = builder.build(vec![], 0);
1031 table.table_type = TableType::Table;
1032 table.columns = vec![ColumnCatalog {
1033 column_desc: ColumnDesc::named("v1", ColumnId::new(col_idx as i32), DataType::Int32),
1034 is_hidden: false,
1035 }];
1036 table.conflict_behavior = conflict_behavior;
1037 table.version_column_indices = version_column_indices;
1038 table.watermark_columns = FixedBitSet::with_capacity(table.columns.len());
1039 table
1040 }
1041
1042 #[test]
1043 fn test_target_table_requires_row_level_conflict_handling() {
1044 assert!(target_table_requires_row_level_conflict_handling(
1045 &test_target_table(ConflictBehavior::DoUpdateIfNotNull, vec![])
1046 ));
1047 assert!(target_table_requires_row_level_conflict_handling(
1048 &test_target_table(ConflictBehavior::IgnoreConflict, vec![])
1049 ));
1050 assert!(target_table_requires_row_level_conflict_handling(
1051 &test_target_table(ConflictBehavior::Overwrite, vec![0])
1052 ));
1053 assert!(!target_table_requires_row_level_conflict_handling(
1054 &test_target_table(ConflictBehavior::Overwrite, vec![])
1055 ));
1056 }
1057
1058 #[test]
1059 fn test_iceberg_convert_to_expression() {
1060 let partition_type = StructType::new(vec![
1061 ("f1", DataType::Int32),
1062 ("f2", DataType::Int32),
1063 ("f3", DataType::Int32),
1064 ("f4", DataType::Int32),
1065 ("f5", DataType::Int32),
1066 ("f6", DataType::Int32),
1067 ("f7", DataType::Int32),
1068 ("f8", DataType::Int32),
1069 ("f9", DataType::Int32),
1070 ]);
1071 let partition_fields = vec![
1072 ("v1".into(), Transform::Identity),
1073 ("v1".into(), Transform::Bucket(10)),
1074 ("v1".into(), Transform::Truncate(3)),
1075 ("v2".into(), Transform::Year),
1076 ("v2".into(), Transform::Month),
1077 ("v3".into(), Transform::Day),
1078 ("v3".into(), Transform::Hour),
1079 ("v1".into(), Transform::Void),
1080 ("v3".into(), Transform::Void),
1081 ];
1082 let partition_info = IcebergPartitionInfo {
1083 partition_type: partition_type.clone(),
1084 partition_fields: partition_fields.clone(),
1085 };
1086 let catalog = create_column_catalog();
1087 let actual_expr = partition_info.convert_to_expression(&catalog).unwrap();
1088 let actual_expr = actual_expr.as_function_call().unwrap();
1089
1090 assert_eq!(
1091 actual_expr.return_type(),
1092 DataType::Struct(partition_type.clone())
1093 );
1094 assert_eq!(actual_expr.inputs().len(), partition_fields.len());
1095 assert_eq!(actual_expr.func_type(), Type::Row);
1096
1097 for ((expr, (_, transform)), (_, expect_type)) in actual_expr
1098 .inputs()
1099 .iter()
1100 .zip_eq_debug(partition_fields.iter())
1101 .zip_eq_debug(partition_type.iter())
1102 {
1103 match transform {
1104 Transform::Identity => {
1105 assert!(expr.is_input_ref());
1106 assert_eq!(expr.return_type(), *expect_type);
1107 }
1108 Transform::Void => {
1109 assert!(expr.is_literal());
1110 assert_eq!(expr.return_type(), *expect_type);
1111 }
1112 _ => {
1113 let expr = expr.as_function_call().unwrap();
1114 assert_eq!(expr.func_type(), Type::IcebergTransform);
1115 assert_eq!(expr.inputs().len(), 2);
1116 assert_eq!(
1117 expr.inputs()[0],
1118 ExprImpl::literal_varchar(transform.to_string())
1119 );
1120 }
1121 }
1122 }
1123 }
1124}