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::{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
73/// ## Why we need `PartitionComputeInfo`?
74///
75/// 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>)
76/// 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>.
77/// 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.
78///
79/// ## What is `PartitionComputeInfo`?
80/// The `PartitionComputeInfo` contains the information about partition compute. The stream sink will use
81/// these information to create the corresponding expression in `StreamProject` node.
82///
83/// #TODO
84/// Maybe we should move this in sink?
85pub 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    // (partition_field_name, partition_field_transform)
100    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/// [`StreamSink`] represents a table/connector sink at the very end of the graph.
178#[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        // The sink executor will transform the chunk into desired format based on the sink type
190        // before writing to the sink or emitting to the downstream. Thus, we need to derive the
191        // stream kind based on the sink type.
192        // We assert here because checks should already be done in `derive_sink_type`.
193        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            // FIXME: We may reconstruct the chunk based on user-specified downstream pk, so
220            // we should also use `downstream_pk` as the stream key of the output. Though this
221            // is unlikely to result in correctness issues:
222            // - for sink-into-table, the `Materialize` node in the downstream table will always
223            //   enforce the pk consistency
224            // - for other sinks, the output of `Sink` node is not used
225            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        // For here, we need to add the plan node to compute the partition value, and add it as a extra column.
252        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            // Add the partition compute expression to the end of the exprs
262            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        // For Iceberg pk-index sinks, the iceberg primary key is derived entirely from the
319        // upstream stream key, so the user must not specify `primary_key` explicitly.
320        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        // Get downstream pk from user input, override and perform some checks if applicable.
329        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            // For Iceberg pk-index sinks: derive the iceberg primary key entirely from the
373            // upstream stream key. Every stream-key column becomes a pk column; hidden ones are
374            // promoted to visible so they are carried into the iceberg table verbatim.
375            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            // Promoting hidden stream-key columns adds new columns to the iceberg table, which is
384            // only possible at table-creation time. Sinking into an existing iceberg table would
385            // silently drop them and corrupt the pk index, so require `create_table_if_not_exists`.
386            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            // Inform the user of the derived iceberg primary key, since they did not (and
401            // cannot) specify it explicitly.
402            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        // Since we've already rejected empty pk in `parse_downstream_pk`, if we still get an empty pk here,
408        // it's likely that the derived stream key is used and it's empty, which is possible in cases of
409        // operators outputting at most one row (like `SimpleAgg`). This is legitimate. However, currently
410        // the sink implementation may confuse empty pk with not specifying pk, so we still reject this case
411        // for correctness.
412        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        // The "upsert" property is defined based on a specific stream key: columns other than the
422        // stream key might not be valid. We should reject the cases referencing such columns in
423        // primary key unless the user explicitly opts in to the unsafe behavior.
424        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                        // for upsert jdbc sink we align distribution to downstream to avoid
493                        // lock contentions
494                        RequiredDist::hash_shard(downstream_pk)
495                    }
496                    Some(s) if s == ICEBERG_SINK => {
497                        // pk-index sinks shard by pk for state-table locality and never use the
498                        // partition-based shuffle, so skip computing the extra partition column
499                        // entirely.
500                        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                        // Use an exact hash-shard on the full pk (not `shard_by_key`): the pk-index
514                        // state table is distributed by the whole derived pk in pk order, so the
515                        // writer fragment must hash on exactly the same columns and order.
516                        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                            // force the same primary key be written into the same sink shard to make sure the sink pk mismatch compaction effective
526                            // https://github.com/risingwavelabs/risingwave/blob/6d88344c286f250ea8a7e7ef6b9d74dea838269e/src/stream/src/executor/sink.rs#L169-L198
527                            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        // check and ensure that the sink connector is specified and supported
597        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                        // the table sink is created by with properties
605                        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        let hint_string =
638            |expected: bool| format!("Please run `set sink_decouple = {}` first.", expected);
639        if !sink_decouple {
640            // For file sink, it must have sink_decouple turned on.
641            if sink_desc.is_file_sink() {
642                return Err(ErrorCode::NotSupported(
643                    "File sink can only be created with sink_decouple enabled.".to_owned(),
644                    hint_string(true),
645                )
646                .into());
647            }
648
649            if sink_desc.is_exactly_once.is_none()
650                && let Some(connector) = sink_desc.properties.get(CONNECTOR_TYPE_KEY)
651            {
652                let connector_type = connector.to_lowercase();
653                if connector_type == ICEBERG_SINK {
654                    // iceberg sink defaults to exactly once
655                    // However, when sink_decouple is disabled, we enforce it to false.
656                    sink_desc
657                        .properties
658                        .insert("is_exactly_once".to_owned(), "false".to_owned());
659                }
660            }
661        }
662        let log_store_type = if sink_decouple {
663            SinkLogStoreType::KvLogStore
664        } else {
665            SinkLogStoreType::InMemoryLogStore
666        };
667
668        // sink into table should have logstore for sink_decouple
669        let input = if sink_decouple && target_table.is_some() {
670            StreamSyncLogStore::new(input).into()
671        } else {
672            input
673        };
674
675        let sink = Self::new(input, sink_desc, log_store_type);
676        if let Some(pk_names) = emit_pk_extension_notice {
677            sink.base.ctx().session_ctx().notice_to_user(format!(
678                "Iceberg pk-index sink `{}`: the iceberg primary key was automatically derived from \
679                 the upstream stream key as ({}).",
680                sink.sink_desc.name, pk_names,
681            ));
682        }
683        Ok(sink)
684    }
685
686    fn sink_type_in_prop(properties: &WithOptionsSecResolved) -> Result<Option<SinkType>> {
687        if let Some(sink_type) = properties.get(SINK_TYPE_OPTION) {
688            let sink_type = match sink_type.as_str() {
689                SINK_TYPE_APPEND_ONLY => SinkType::AppendOnly,
690                SINK_TYPE_UPSERT => {
691                    if properties.is_iceberg_connector() {
692                        // Iceberg sink must use retract to represent deletes
693                        SinkType::Retract
694                    } else {
695                        SinkType::Upsert
696                    }
697                }
698                SINK_TYPE_RETRACT | SINK_TYPE_DEBEZIUM => SinkType::Retract,
699                _ => {
700                    return Err(ErrorCode::InvalidInputSyntax(format!(
701                        "`{}` must be {}, {}, {}, or {}",
702                        SINK_TYPE_OPTION,
703                        SINK_TYPE_APPEND_ONLY,
704                        SINK_TYPE_RETRACT,
705                        SINK_TYPE_UPSERT,
706                        SINK_TYPE_DEBEZIUM,
707                    ))
708                    .into());
709                }
710            };
711            return Ok(Some(sink_type));
712        }
713        Ok(None)
714    }
715
716    /// `ignore_delete` option, with backward-compatible alias `force_append_only`.
717    fn is_user_ignore_delete(properties: &WithOptionsSecResolved) -> Result<bool> {
718        let has_ignore_delete = properties.contains_key(SINK_USER_IGNORE_DELETE_OPTION);
719        let has_force_append_only = properties.contains_key(SINK_USER_FORCE_APPEND_ONLY_OPTION);
720
721        if has_ignore_delete && has_force_append_only {
722            return Err(ErrorCode::InvalidInputSyntax(format!(
723                "`{}` is an alias of `{}`, only one of them can be specified.",
724                SINK_USER_FORCE_APPEND_ONLY_OPTION, SINK_USER_IGNORE_DELETE_OPTION
725            ))
726            .into());
727        }
728
729        let key = if has_ignore_delete {
730            SINK_USER_IGNORE_DELETE_OPTION
731        } else if has_force_append_only {
732            SINK_USER_FORCE_APPEND_ONLY_OPTION
733        } else {
734            return Ok(false);
735        };
736
737        if properties.value_eq_ignore_case(key, "true") {
738            Ok(true)
739        } else if properties.value_eq_ignore_case(key, "false") {
740            Ok(false)
741        } else {
742            Err(ErrorCode::InvalidInputSyntax(format!("`{key}` must be true or false")).into())
743        }
744    }
745
746    /// Derive the sink type based on...
747    ///
748    /// - the derived stream kind of the plan, from the optimizer
749    /// - sink format required by [`SinkFormatDesc`], if any
750    /// - user-specified sink type in WITH options, if any
751    /// - user-specified `ignore_delete` (`force_append_only`) in WITH options, if any
752    ///
753    /// Returns the `sink_type` and `ignore_delete`.
754    fn derive_sink_type(
755        derived_stream_kind: StreamKind,
756        properties: &WithOptionsSecResolved,
757        format_desc: Option<&SinkFormatDesc>,
758    ) -> Result<(SinkType, bool)> {
759        let (user_defined_sink_type, user_ignore_delete, syntax_legacy) = match format_desc {
760            Some(f) => (
761                Some(match f.format {
762                    SinkFormat::AppendOnly => SinkType::AppendOnly,
763                    SinkFormat::Upsert => SinkType::Upsert,
764                    SinkFormat::Debezium => SinkType::Retract,
765                }),
766                Self::is_user_ignore_delete(&WithOptionsSecResolved::without_secrets(
767                    f.options.clone(),
768                ))?,
769                false,
770            ),
771            None => (
772                Self::sink_type_in_prop(properties)?,
773                Self::is_user_ignore_delete(properties)?,
774                true,
775            ),
776        };
777
778        if let Some(user_defined_sink_type) = user_defined_sink_type {
779            match user_defined_sink_type {
780                SinkType::AppendOnly => {
781                    if derived_stream_kind != StreamKind::AppendOnly && !user_ignore_delete {
782                        return Err(ErrorCode::InvalidInputSyntax(format!(
783                            "The sink of {} stream cannot be append-only. Please add \"force_append_only='true'\" in {} options to force the sink to be append-only. \
784                             Notice that this will cause the sink executor to drop DELETE messages and convert UPDATE messages to INSERT.",
785                            derived_stream_kind,
786                            if syntax_legacy { "WITH" } else { "FORMAT ENCODE" }
787                    ))
788                        .into());
789                    }
790                }
791                SinkType::Upsert => { /* always qualified */ }
792                SinkType::Retract => {
793                    if user_ignore_delete {
794                        bail_invalid_input_syntax!(
795                            "Retract sink type does not support `ignore_delete`. \
796                             Please use `type = 'append-only'` or `type = 'upsert'` instead.",
797                        );
798                    }
799                    if derived_stream_kind == StreamKind::Upsert {
800                        bail_invalid_input_syntax!(
801                            "The sink of upsert stream cannot be retract. \
802                             Please create a materialized view or sink-into-table with this query before sinking it.",
803                        );
804                    }
805                }
806            }
807            Ok((user_defined_sink_type, user_ignore_delete))
808        } else {
809            // No specification at all, follow the optimizer's derivation.
810            // This is also the case for sink-into-table.
811            let sink_type = match derived_stream_kind {
812                // We downgrade `Retract` to `Upsert` unless explicitly specified the type in options,
813                // as it is well supported by most sinks and reduces the amount of data written.
814                StreamKind::Retract | StreamKind::Upsert => SinkType::Upsert,
815                StreamKind::AppendOnly => SinkType::AppendOnly,
816            };
817            Ok((sink_type, user_ignore_delete))
818        }
819    }
820
821    /// Extract user-defined downstream pk columns from with options. Return the indices of the pk
822    /// columns. An empty list of columns is not allowed.
823    ///
824    /// The format of `downstream_pk_str` should be 'col1,col2,...' (delimited by `,`) in order to
825    /// get parsed.
826    fn parse_downstream_pk(
827        downstream_pk_str: &str,
828        columns: &[ColumnCatalog],
829    ) -> Result<Vec<usize>> {
830        // If the user defines the downstream primary key, we find out their indices.
831        let downstream_pk = downstream_pk_str.split(',').collect_vec();
832        let mut downstream_pk_indices = Vec::with_capacity(downstream_pk.len());
833        for key in downstream_pk {
834            let trimmed_key = key.trim();
835            if trimmed_key.is_empty() {
836                continue;
837            }
838            downstream_pk_indices.push(find_column_idx_by_name(columns, trimmed_key)?);
839        }
840        if downstream_pk_indices.is_empty() {
841            bail_invalid_input_syntax!(
842                "Specified primary key should not be empty. \
843                To use derived primary key, remove {DOWNSTREAM_PK_KEY} from WITH options instead."
844            );
845        }
846        Ok(downstream_pk_indices)
847    }
848
849    /// The table schema is: | epoch | seq id | row op | sink columns |
850    /// Pk is: | epoch | seq id |
851    fn infer_kv_log_store_table_catalog(&self) -> TableCatalog {
852        infer_kv_log_store_table_catalog_inner(&self.input, &self.sink_desc().columns)
853    }
854
855    /// Convert this `StreamSink` into a `PlanRef`.
856    ///
857    /// For Iceberg pk index sinks, this rewrites the plan into
858    /// `Upstream → Writer → Exchange(Hash) → PositionDeleteMerger` instead of a single `SinkNode`.
859    /// For all other sinks, returns `self` as-is.
860    pub fn into_stream_plan(self) -> Result<PlanRef> {
861        use super::{StreamIcebergWithPkIndexPositionDeleteMerger, StreamIcebergWithPkIndexWriter};
862
863        if !is_iceberg_with_pk_index_sink(&self.sink_desc)? {
864            return Ok(self.into());
865        }
866
867        let writer: PlanRef = StreamIcebergWithPkIndexWriter::from_stream_sink(&self)?.into();
868        let position_delete_merger: PlanRef =
869            StreamIcebergWithPkIndexPositionDeleteMerger::new(writer, self.sink_desc).into();
870        Ok(position_delete_merger)
871    }
872}
873
874pub fn is_iceberg_with_pk_index_sink(sink_desc: &SinkDesc) -> Result<bool> {
875    if !sink_desc
876        .properties
877        .get(CONNECTOR_TYPE_KEY)
878        .is_some_and(|connector| connector.eq_ignore_ascii_case(ICEBERG_SINK))
879    {
880        return Ok(false);
881    }
882
883    let res = sink_desc
884        .properties
885        .get(ENABLE_PK_INDEX)
886        .is_some_and(|v| v.eq_ignore_ascii_case("true"));
887    Ok(res)
888}
889
890/// For Iceberg pk-index sinks, promote the `downstream_pk` and sink columns that use the
891/// full upstream stream key as the iceberg primary key.
892///
893/// Every stream-key column becomes an iceberg pk column. A stream-key column that is hidden
894/// (e.g. a join key the user did not `SELECT`, or a RisingWave-internal column such as
895/// `_row_id`) is promoted to visible (`is_hidden = false`) so it is carried into the iceberg
896/// table, keeping its type.
897///
898/// Returns `(downstream_pk, promoted)` where `promoted` is true if any hidden column was promoted
899fn promote_iceberg_pk_index_stream_key(
900    input: &PlanRef,
901    columns: &mut Vec<ColumnCatalog>,
902) -> Result<(Vec<usize>, bool)> {
903    let mut promoted = false;
904    let stream_key = input.expect_stream_key();
905    if stream_key.is_empty() {
906        bail_invalid_input_syntax!(
907            "Iceberg sink with `enable_pk_index='true'` requires a non-empty upstream stream key \
908             to derive the primary key from."
909        );
910    }
911
912    for &i in stream_key {
913        if !columns[i].is_hidden {
914            continue;
915        }
916        // Promote the hidden stream-key column to visible so it is carried into iceberg.
917        columns[i].is_hidden = false;
918        promoted = true;
919    }
920
921    // The iceberg pk-index writer writes every input column verbatim (it has no hidden-column
922    // projection). After promoting the stream-key columns, any column still hidden would be one
923    // that is neither user-selected nor part of the stream key — the optimizer is expected to have
924    // pruned such columns. A violation here is an internal planner bug rather than invalid user
925    // input, so fail loudly at planning time instead of silently writing the column into the
926    // iceberg table.
927    if let Some(col) = columns.iter().find(|c| c.is_hidden) {
928        return Err(ErrorCode::InternalError(format!(
929            "iceberg pk-index sink has a hidden column `{}` after stream-key promotion; \
930             all sink columns must be visible",
931            col.name()
932        ))
933        .into());
934    }
935
936    let downstream_pk = stream_key.to_vec();
937    Ok((downstream_pk, promoted))
938}
939
940impl PlanTreeNodeUnary<Stream> for StreamSink {
941    fn input(&self) -> PlanRef {
942        self.input.clone()
943    }
944
945    fn clone_with_input(&self, input: PlanRef) -> Self {
946        Self::new(input, self.sink_desc.clone(), self.log_store_type)
947        // TODO(nanderstabel): Add assertions (assert_eq!)
948    }
949}
950
951impl_plan_tree_node_for_unary! { Stream, StreamSink }
952
953impl Distill for StreamSink {
954    fn distill<'a>(&self) -> XmlNode<'a> {
955        let sink_type = if self.sink_desc.sink_type.is_append_only() {
956            "append-only"
957        } else {
958            "upsert"
959        };
960        let column_names = self
961            .sink_desc
962            .columns
963            .iter()
964            .map(|col| col.name_with_hidden().to_string())
965            .map(Pretty::from)
966            .collect();
967        let column_names = Pretty::Array(column_names);
968        let mut vec = Vec::with_capacity(3);
969        vec.push(("type", Pretty::from(sink_type)));
970        vec.push(("columns", column_names));
971        if let Some(pk) = &self.sink_desc.downstream_pk {
972            let sink_pk = IndicesDisplay {
973                indices: pk,
974                schema: self.base.schema(),
975            };
976            vec.push(("downstream_pk", sink_pk.distill()));
977        }
978        childless_record("StreamSink", vec)
979    }
980}
981
982impl StreamNode for StreamSink {
983    fn to_stream_prost_body(&self, state: &mut BuildFragmentGraphState) -> PbNodeBody {
984        use risingwave_pb::stream_plan::*;
985
986        // We need to create a table for sink with a kv log store.
987        let table = self
988            .infer_kv_log_store_table_catalog()
989            .with_id(state.gen_table_id_wrapped());
990
991        PbNodeBody::Sink(Box::new(SinkNode {
992            sink_desc: Some(self.sink_desc.to_proto()),
993            table: Some(table.to_internal_table_prost()),
994            log_store_type: self.log_store_type as i32,
995            rate_limit: self.base.ctx().overwrite_options().sink_rate_limit,
996        }))
997    }
998}
999
1000impl ExprRewritable<Stream> for StreamSink {}
1001
1002impl ExprVisitable for StreamSink {}
1003
1004#[cfg(test)]
1005mod test {
1006    use fixedbitset::FixedBitSet;
1007    use risingwave_common::catalog::{
1008        ColumnCatalog, ColumnDesc, ColumnId, ConflictBehavior, Field,
1009    };
1010    use risingwave_common::types::{DataType, StructType};
1011    use risingwave_common::util::iter_util::ZipEqDebug;
1012    use risingwave_pb::expr::expr_node::Type;
1013
1014    use super::{IcebergPartitionInfo, *};
1015    use crate::catalog::table_catalog::TableType;
1016    use crate::expr::{Expr, ExprImpl};
1017    use crate::optimizer::plan_node::utils::TableCatalogBuilder;
1018
1019    fn create_column_catalog() -> Vec<ColumnCatalog> {
1020        vec![
1021            ColumnCatalog {
1022                column_desc: ColumnDesc::named("v1", ColumnId::new(1), DataType::Int32),
1023                is_hidden: false,
1024            },
1025            ColumnCatalog {
1026                column_desc: ColumnDesc::named("v2", ColumnId::new(2), DataType::Timestamptz),
1027                is_hidden: false,
1028            },
1029            ColumnCatalog {
1030                column_desc: ColumnDesc::named("v3", ColumnId::new(2), DataType::Timestamp),
1031                is_hidden: false,
1032            },
1033        ]
1034    }
1035
1036    fn test_target_table(
1037        conflict_behavior: ConflictBehavior,
1038        version_column_indices: Vec<usize>,
1039    ) -> TableCatalog {
1040        let mut builder = TableCatalogBuilder::default();
1041        let col_idx = builder.add_column(&Field::with_name(DataType::Int32, "v1"));
1042        let mut table = builder.build(vec![], 0);
1043        table.table_type = TableType::Table;
1044        table.columns = vec![ColumnCatalog {
1045            column_desc: ColumnDesc::named("v1", ColumnId::new(col_idx as i32), DataType::Int32),
1046            is_hidden: false,
1047        }];
1048        table.conflict_behavior = conflict_behavior;
1049        table.version_column_indices = version_column_indices;
1050        table.watermark_columns = FixedBitSet::with_capacity(table.columns.len());
1051        table
1052    }
1053
1054    #[test]
1055    fn test_target_table_requires_row_level_conflict_handling() {
1056        assert!(target_table_requires_row_level_conflict_handling(
1057            &test_target_table(ConflictBehavior::DoUpdateIfNotNull, vec![])
1058        ));
1059        assert!(target_table_requires_row_level_conflict_handling(
1060            &test_target_table(ConflictBehavior::IgnoreConflict, vec![])
1061        ));
1062        assert!(target_table_requires_row_level_conflict_handling(
1063            &test_target_table(ConflictBehavior::Overwrite, vec![0])
1064        ));
1065        assert!(!target_table_requires_row_level_conflict_handling(
1066            &test_target_table(ConflictBehavior::Overwrite, vec![])
1067        ));
1068    }
1069
1070    #[test]
1071    fn test_iceberg_convert_to_expression() {
1072        let partition_type = StructType::new(vec![
1073            ("f1", DataType::Int32),
1074            ("f2", DataType::Int32),
1075            ("f3", DataType::Int32),
1076            ("f4", DataType::Int32),
1077            ("f5", DataType::Int32),
1078            ("f6", DataType::Int32),
1079            ("f7", DataType::Int32),
1080            ("f8", DataType::Int32),
1081            ("f9", DataType::Int32),
1082        ]);
1083        let partition_fields = vec![
1084            ("v1".into(), Transform::Identity),
1085            ("v1".into(), Transform::Bucket(10)),
1086            ("v1".into(), Transform::Truncate(3)),
1087            ("v2".into(), Transform::Year),
1088            ("v2".into(), Transform::Month),
1089            ("v3".into(), Transform::Day),
1090            ("v3".into(), Transform::Hour),
1091            ("v1".into(), Transform::Void),
1092            ("v3".into(), Transform::Void),
1093        ];
1094        let partition_info = IcebergPartitionInfo {
1095            partition_type: partition_type.clone(),
1096            partition_fields: partition_fields.clone(),
1097        };
1098        let catalog = create_column_catalog();
1099        let actual_expr = partition_info.convert_to_expression(&catalog).unwrap();
1100        let actual_expr = actual_expr.as_function_call().unwrap();
1101
1102        assert_eq!(
1103            actual_expr.return_type(),
1104            DataType::Struct(partition_type.clone())
1105        );
1106        assert_eq!(actual_expr.inputs().len(), partition_fields.len());
1107        assert_eq!(actual_expr.func_type(), Type::Row);
1108
1109        for ((expr, (_, transform)), (_, expect_type)) in actual_expr
1110            .inputs()
1111            .iter()
1112            .zip_eq_debug(partition_fields.iter())
1113            .zip_eq_debug(partition_type.iter())
1114        {
1115            match transform {
1116                Transform::Identity => {
1117                    assert!(expr.is_input_ref());
1118                    assert_eq!(expr.return_type(), *expect_type);
1119                }
1120                Transform::Void => {
1121                    assert!(expr.is_literal());
1122                    assert_eq!(expr.return_type(), *expect_type);
1123                }
1124                _ => {
1125                    let expr = expr.as_function_call().unwrap();
1126                    assert_eq!(expr.func_type(), Type::IcebergTransform);
1127                    assert_eq!(expr.inputs().len(), 2);
1128                    assert_eq!(
1129                        expr.inputs()[0],
1130                        ExprImpl::literal_varchar(transform.to_string())
1131                    );
1132                }
1133            }
1134        }
1135    }
1136}