Skip to main content

risingwave_frontend/optimizer/plan_node/
stream_sink.rs

1// Copyright 2022 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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
75/// ## Why we need `PartitionComputeInfo`?
76///
77/// For some sink, it will write the data into different file based on the partition value. E.g. iceberg sink(<https://iceberg.apache.org/spec/#partitioning>)
78/// For this kind of sink, the file num can be reduced if we can shuffle the data based on the partition value. More details can be found in <https://github.com/risingwavelabs/rfcs/pull/77>.
79/// So if the `PartitionComputeInfo` provided, we will create a `StreamProject` node to compute the partition value and shuffle the data based on the partition value before the sink.
80///
81/// ## What is `PartitionComputeInfo`?
82/// The `PartitionComputeInfo` contains the information about partition compute. The stream sink will use
83/// these information to create the corresponding expression in `StreamProject` node.
84///
85/// #TODO
86/// Maybe we should move this in sink?
87pub 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    // (partition_field_name, partition_field_transform)
102    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/// [`StreamSink`] represents a table/connector sink at the very end of the graph.
180#[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        // The sink executor will transform the chunk into desired format based on the sink type
192        // before writing to the sink or emitting to the downstream. Thus, we need to derive the
193        // stream kind based on the sink type.
194        // We assert here because checks should already be done in `derive_sink_type`.
195        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            // FIXME: We may reconstruct the chunk based on user-specified downstream pk, so
222            // we should also use `downstream_pk` as the stream key of the output. Though this
223            // is unlikely to result in correctness issues:
224            // - for sink-into-table, the `Materialize` node in the downstream table will always
225            //   enforce the pk consistency
226            // - for other sinks, the output of `Sink` node is not used
227            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        // For here, we need to add the plan node to compute the partition value, and add it as a extra column.
254        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            // Add the partition compute expression to the end of the exprs
264            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        // New pk-index sinks store data file uuids in the PK index. Sinks created before the
321        // option existed have no such key and keep the legacy full-path layout.
322        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        // For Iceberg pk-index sinks, the iceberg primary key is derived entirely from the
327        // upstream stream key, so the user must not specify `primary_key` explicitly.
328        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        // Get downstream pk from user input, override and perform some checks if applicable.
337        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            // For Iceberg pk-index sinks: derive the iceberg primary key entirely from the
381            // upstream stream key. Every stream-key column becomes a pk column; hidden ones are
382            // promoted to visible so they are carried into the iceberg table verbatim.
383            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            // Promoting hidden stream-key columns adds new columns to the iceberg table, which is
392            // only possible at table-creation time. Sinking into an existing iceberg table would
393            // silently drop them and corrupt the pk index, so require `create_table_if_not_exists`.
394            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            // Inform the user of the derived iceberg primary key, since they did not (and
409            // cannot) specify it explicitly.
410            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        // Since we've already rejected empty pk in `parse_downstream_pk`, if we still get an empty pk here,
416        // it's likely that the derived stream key is used and it's empty, which is possible in cases of
417        // operators outputting at most one row (like `SimpleAgg`). This is legitimate. However, currently
418        // the sink implementation may confuse empty pk with not specifying pk, so we still reject this case
419        // for correctness.
420        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        // The "upsert" property is defined based on a specific stream key: columns other than the
430        // stream key might not be valid. We should reject the cases referencing such columns in
431        // primary key unless the user explicitly opts in to the unsafe behavior.
432        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                        // for upsert jdbc sink we align distribution to downstream to avoid
501                        // lock contentions
502                        RequiredDist::hash_shard(downstream_pk)
503                    }
504                    Some(s) if s == ICEBERG_SINK => {
505                        // pk-index sinks shard by pk for state-table locality and never use the
506                        // partition-based shuffle, so skip computing the extra partition column
507                        // entirely.
508                        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                        // Use an exact hash-shard on the full pk (not `shard_by_key`): the pk-index
522                        // state table is distributed by the whole derived pk in pk order, so the
523                        // writer fragment must hash on exactly the same columns and order.
524                        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                            // force the same primary key be written into the same sink shard to make sure the sink pk mismatch compaction effective
534                            // https://github.com/risingwavelabs/risingwave/blob/6d88344c286f250ea8a7e7ef6b9d74dea838269e/src/stream/src/executor/sink.rs#L169-L198
535                            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        // check and ensure that the sink connector is specified and supported
605        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                        // the table sink is created by with properties
613                        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                // iceberg sink defaults to exactly once
652                // However, when sink_decouple is disabled, we enforce it to false.
653                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        // sink into table should have logstore for sink_decouple
665        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                        // Iceberg sink must use retract to represent deletes
689                        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    /// `ignore_delete` option, with backward-compatible alias `force_append_only`.
713    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    /// Derive the sink type based on...
743    ///
744    /// - the derived stream kind of the plan, from the optimizer
745    /// - sink format required by [`SinkFormatDesc`], if any
746    /// - user-specified sink type in WITH options, if any
747    /// - user-specified `ignore_delete` (`force_append_only`) in WITH options, if any
748    ///
749    /// Returns the `sink_type` and `ignore_delete`.
750    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 => { /* always qualified */ }
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            // No specification at all, follow the optimizer's derivation.
806            // This is also the case for sink-into-table.
807            let sink_type = match derived_stream_kind {
808                // We downgrade `Retract` to `Upsert` unless explicitly specified the type in options,
809                // as it is well supported by most sinks and reduces the amount of data written.
810                StreamKind::Retract | StreamKind::Upsert => SinkType::Upsert,
811                StreamKind::AppendOnly => SinkType::AppendOnly,
812            };
813            Ok((sink_type, user_ignore_delete))
814        }
815    }
816
817    /// Extract user-defined downstream pk columns from with options. Return the indices of the pk
818    /// columns. An empty list of columns is not allowed.
819    ///
820    /// The format of `downstream_pk_str` should be 'col1,col2,...' (delimited by `,`) in order to
821    /// get parsed.
822    fn parse_downstream_pk(
823        downstream_pk_str: &str,
824        columns: &[ColumnCatalog],
825    ) -> Result<Vec<usize>> {
826        // If the user defines the downstream primary key, we find out their indices.
827        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    /// The table schema is: | epoch | seq id | row op | sink columns |
846    /// Pk is: | epoch | seq id |
847    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    /// Convert this `StreamSink` into a `PlanRef`.
852    ///
853    /// For Iceberg pk index sinks, this rewrites the plan into
854    /// `Upstream → Writer → Exchange(Hash) → PositionDeleteMerger` instead of a single `SinkNode`.
855    /// For all other sinks, returns `self` as-is.
856    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
886/// For Iceberg pk-index sinks, promote the `downstream_pk` and sink columns that use the
887/// full upstream stream key as the iceberg primary key.
888///
889/// Every stream-key column becomes an iceberg pk column. A stream-key column that is hidden
890/// (e.g. a join key the user did not `SELECT`, or a RisingWave-internal column such as
891/// `_row_id`) is promoted to visible (`is_hidden = false`) so it is carried into the iceberg
892/// table, keeping its type.
893///
894/// Returns `(downstream_pk, promoted)` where `promoted` is true if any hidden column was promoted
895fn 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        // Promote the hidden stream-key column to visible so it is carried into iceberg.
913        columns[i].is_hidden = false;
914        promoted = true;
915    }
916
917    // The iceberg pk-index writer writes every input column verbatim (it has no hidden-column
918    // projection). After promoting the stream-key columns, any column still hidden would be one
919    // that is neither user-selected nor part of the stream key — the optimizer is expected to have
920    // pruned such columns. A violation here is an internal planner bug rather than invalid user
921    // input, so fail loudly at planning time instead of silently writing the column into the
922    // iceberg table.
923    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        // TODO(nanderstabel): Add assertions (assert_eq!)
944    }
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        // We need to create a table for sink with a kv log store.
983        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}