Skip to main content

mz_storage_types/
sinks.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Types and traits related to reporting changing collections out of `dataflow`.
11
12use std::borrow::Cow;
13use std::fmt::Debug;
14use std::time::Duration;
15
16use mz_dyncfg::ConfigSet;
17use mz_expr::MirScalarExpr;
18use mz_pgcopy::CopyFormatParams;
19use mz_repr::bytes::ByteSize;
20use mz_repr::{CatalogItemId, GlobalId, RelationDesc};
21#[cfg(any(test, feature = "proptest"))]
22use proptest_derive::Arbitrary;
23use serde::{Deserialize, Serialize};
24use timely::PartialOrder;
25use timely::progress::frontier::Antichain;
26
27use crate::AlterCompatible;
28use crate::connections::inline::{
29    ConnectionAccess, ConnectionResolver, InlinedConnection, IntoInlineConnection,
30    ReferencedConnection,
31};
32use crate::connections::{ConnectionContext, KafkaConnection, KafkaTopicOptions};
33use crate::controller::AlterError;
34use crate::wire_format::WireFormat;
35
36pub mod s3_oneshot_sink;
37
38/// A sink for updates to a relational collection.
39#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
40pub struct StorageSinkDesc<S, T = mz_repr::Timestamp> {
41    pub from: GlobalId,
42    pub from_desc: RelationDesc,
43    pub connection: StorageSinkConnection,
44    pub with_snapshot: bool,
45    pub version: u64,
46    pub envelope: SinkEnvelope,
47    pub as_of: Antichain<T>,
48    pub from_storage_metadata: S,
49    pub to_storage_metadata: S,
50    /// The interval at which to commit data to the sink.
51    /// This isn't universally supported by all sinks
52    /// yet, so it is optional. Even for sinks that might
53    /// support it in the future (ahem, kafka) users might
54    /// not want to set it.
55    pub commit_interval: Option<Duration>,
56}
57
58impl<S: Debug + PartialEq, T: Debug + PartialEq + PartialOrder> AlterCompatible
59    for StorageSinkDesc<S, T>
60{
61    /// Determines if `self` is compatible with another `StorageSinkDesc`, in
62    /// such a way that it is possible to turn `self` into `other` through a
63    /// valid series of transformations.
64    ///
65    /// Currently, the only "valid transformation" is the passage of time such
66    /// that the sink's as ofs may differ. However, this will change once we
67    /// support `ALTER CONNECTION` or `ALTER SINK`.
68    fn alter_compatible(
69        &self,
70        id: GlobalId,
71        other: &StorageSinkDesc<S, T>,
72    ) -> Result<(), AlterError> {
73        if self == other {
74            return Ok(());
75        }
76        let StorageSinkDesc {
77            from,
78            from_desc,
79            connection,
80            envelope,
81            version: _,
82            // The as-of of the descriptions may differ.
83            as_of: _,
84            from_storage_metadata,
85            with_snapshot,
86            to_storage_metadata,
87            commit_interval: _,
88        } = self;
89
90        let compatibility_checks = [
91            (from == &other.from, "from"),
92            (from_desc == &other.from_desc, "from_desc"),
93            (
94                connection.alter_compatible(id, &other.connection).is_ok(),
95                "connection",
96            ),
97            (envelope == &other.envelope, "envelope"),
98            // This can legally change from true to false once the snapshot has been
99            // written out.
100            (*with_snapshot || !other.with_snapshot, "with_snapshot"),
101            (
102                from_storage_metadata == &other.from_storage_metadata,
103                "from_storage_metadata",
104            ),
105            (
106                to_storage_metadata == &other.to_storage_metadata,
107                "to_storage_metadata",
108            ),
109        ];
110
111        for (compatible, field) in compatibility_checks {
112            if !compatible {
113                tracing::warn!(
114                    "StorageSinkDesc incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
115                    self,
116                    other
117                );
118
119                return Err(AlterError { id });
120            }
121        }
122
123        Ok(())
124    }
125}
126
127#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
128pub enum SinkEnvelope {
129    /// Only used for Kafka.
130    Debezium,
131    Upsert,
132    /// Only used for Iceberg.
133    Append,
134}
135
136#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
137pub enum StorageSinkConnection<C: ConnectionAccess = InlinedConnection> {
138    Kafka(KafkaSinkConnection<C>),
139    Iceberg(IcebergSinkConnection<C>),
140}
141
142impl<C: ConnectionAccess> StorageSinkConnection<C> {
143    /// Determines if `self` is compatible with another `StorageSinkConnection`,
144    /// in such a way that it is possible to turn `self` into `other` through a
145    /// valid series of transformations (e.g. no transformation or `ALTER
146    /// CONNECTION`).
147    pub fn alter_compatible(
148        &self,
149        id: GlobalId,
150        other: &StorageSinkConnection<C>,
151    ) -> Result<(), AlterError> {
152        if self == other {
153            return Ok(());
154        }
155        match (self, other) {
156            (StorageSinkConnection::Kafka(s), StorageSinkConnection::Kafka(o)) => {
157                s.alter_compatible(id, o)?
158            }
159            (StorageSinkConnection::Iceberg(s), StorageSinkConnection::Iceberg(o)) => {
160                s.alter_compatible(id, o)?
161            }
162            _ => {
163                tracing::warn!(
164                    "StorageSinkConnection incompatible:\nself:\n{:#?}\n\nother\n{:#?}",
165                    self,
166                    other
167                );
168                return Err(AlterError { id });
169            }
170        }
171
172        Ok(())
173    }
174}
175
176impl<R: ConnectionResolver> IntoInlineConnection<StorageSinkConnection, R>
177    for StorageSinkConnection<ReferencedConnection>
178{
179    fn into_inline_connection(self, r: R) -> StorageSinkConnection {
180        match self {
181            Self::Kafka(conn) => StorageSinkConnection::Kafka(conn.into_inline_connection(r)),
182            Self::Iceberg(conn) => StorageSinkConnection::Iceberg(conn.into_inline_connection(r)),
183        }
184    }
185}
186
187impl<C: ConnectionAccess> StorageSinkConnection<C> {
188    /// returns an option to not constrain ourselves in the future
189    pub fn connection_id(&self) -> Option<CatalogItemId> {
190        use StorageSinkConnection::*;
191        match self {
192            Kafka(KafkaSinkConnection { connection_id, .. }) => Some(*connection_id),
193            Iceberg(IcebergSinkConnection {
194                catalog_connection_id: connection_id,
195                ..
196            }) => Some(*connection_id),
197        }
198    }
199
200    /// Returns the name of the sink connection.
201    pub fn name(&self) -> &'static str {
202        use StorageSinkConnection::*;
203        match self {
204            Kafka(_) => "kafka",
205            Iceberg(_) => "iceberg",
206        }
207    }
208}
209
210#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
211pub enum KafkaSinkCompressionType {
212    None,
213    Gzip,
214    Snappy,
215    Lz4,
216    Zstd,
217}
218
219impl KafkaSinkCompressionType {
220    /// Format the compression type as expected by `compression.type` librdkafka
221    /// setting.
222    pub fn to_librdkafka_option(&self) -> &'static str {
223        match self {
224            KafkaSinkCompressionType::None => "none",
225            KafkaSinkCompressionType::Gzip => "gzip",
226            KafkaSinkCompressionType::Snappy => "snappy",
227            KafkaSinkCompressionType::Lz4 => "lz4",
228            KafkaSinkCompressionType::Zstd => "zstd",
229        }
230    }
231}
232
233#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
234pub struct KafkaSinkConnection<C: ConnectionAccess = InlinedConnection> {
235    pub connection_id: CatalogItemId,
236    pub connection: C::Kafka,
237    pub format: KafkaSinkFormat<C>,
238    /// A natural key of the sinked relation (view or source).
239    pub relation_key_indices: Option<Vec<usize>>,
240    /// The user-specified key for the sink.
241    pub key_desc_and_indices: Option<(RelationDesc, Vec<usize>)>,
242    /// The index of the column containing message headers value, if any.
243    pub headers_index: Option<usize>,
244    pub value_desc: RelationDesc,
245    /// An expression that, if present, computes a hash value that should be
246    /// used to determine the partition for each message.
247    pub partition_by: Option<MirScalarExpr>,
248    pub topic: String,
249    /// Options to use when creating the topic if it doesn't already exist.
250    pub topic_options: KafkaTopicOptions,
251    pub compression_type: KafkaSinkCompressionType,
252    pub progress_group_id: KafkaIdStyle,
253    pub transactional_id: KafkaIdStyle,
254    pub topic_metadata_refresh_interval: Duration,
255}
256
257impl KafkaSinkConnection {
258    /// Returns the client ID to register with librdkafka with.
259    ///
260    /// The caller is responsible for providing the sink ID as it is not known
261    /// to `KafkaSinkConnection`.
262    pub fn client_id(
263        &self,
264        configs: &ConfigSet,
265        connection_context: &ConnectionContext,
266        sink_id: GlobalId,
267    ) -> String {
268        let mut client_id =
269            KafkaConnection::id_base(connection_context, self.connection_id, sink_id);
270        self.connection.enrich_client_id(configs, &mut client_id);
271        client_id
272    }
273
274    /// Returns the name of the progress topic to use for the sink.
275    pub fn progress_topic(&self, connection_context: &ConnectionContext) -> Cow<'_, str> {
276        self.connection
277            .progress_topic(connection_context, self.connection_id)
278    }
279
280    /// Returns the ID for the consumer group the sink will use to read the
281    /// progress topic on resumption.
282    ///
283    /// The caller is responsible for providing the sink ID as it is not known
284    /// to `KafkaSinkConnection`.
285    pub fn progress_group_id(
286        &self,
287        connection_context: &ConnectionContext,
288        sink_id: GlobalId,
289    ) -> String {
290        match self.progress_group_id {
291            KafkaIdStyle::Prefix(ref prefix) => format!(
292                "{}{}",
293                prefix.as_deref().unwrap_or(""),
294                KafkaConnection::id_base(connection_context, self.connection_id, sink_id),
295            ),
296            KafkaIdStyle::Legacy => format!("materialize-bootstrap-sink-{sink_id}"),
297        }
298    }
299
300    /// Returns the transactional ID to use for the sink.
301    ///
302    /// The caller is responsible for providing the sink ID as it is not known
303    /// to `KafkaSinkConnection`.
304    pub fn transactional_id(
305        &self,
306        connection_context: &ConnectionContext,
307        sink_id: GlobalId,
308    ) -> String {
309        match self.transactional_id {
310            KafkaIdStyle::Prefix(ref prefix) => format!(
311                "{}{}",
312                prefix.as_deref().unwrap_or(""),
313                KafkaConnection::id_base(connection_context, self.connection_id, sink_id)
314            ),
315            KafkaIdStyle::Legacy => format!("mz-producer-{sink_id}-0"),
316        }
317    }
318}
319
320impl<C: ConnectionAccess> KafkaSinkConnection<C> {
321    /// Determines if `self` is compatible with another `StorageSinkConnection`,
322    /// in such a way that it is possible to turn `self` into `other` through a
323    /// valid series of transformations (e.g. no transformation or `ALTER
324    /// CONNECTION`).
325    pub fn alter_compatible(
326        &self,
327        id: GlobalId,
328        other: &KafkaSinkConnection<C>,
329    ) -> Result<(), AlterError> {
330        if self == other {
331            return Ok(());
332        }
333        let KafkaSinkConnection {
334            connection_id,
335            connection,
336            format,
337            relation_key_indices,
338            key_desc_and_indices,
339            headers_index,
340            value_desc,
341            partition_by,
342            topic,
343            compression_type,
344            progress_group_id,
345            transactional_id,
346            topic_options,
347            topic_metadata_refresh_interval,
348        } = self;
349
350        let compatibility_checks = [
351            (connection_id == &other.connection_id, "connection_id"),
352            (
353                connection.alter_compatible(id, &other.connection).is_ok(),
354                "connection",
355            ),
356            (format.alter_compatible(id, &other.format).is_ok(), "format"),
357            (
358                relation_key_indices == &other.relation_key_indices,
359                "relation_key_indices",
360            ),
361            (
362                key_desc_and_indices == &other.key_desc_and_indices,
363                "key_desc_and_indices",
364            ),
365            (headers_index == &other.headers_index, "headers_index"),
366            (value_desc == &other.value_desc, "value_desc"),
367            (partition_by == &other.partition_by, "partition_by"),
368            (topic == &other.topic, "topic"),
369            (
370                compression_type == &other.compression_type,
371                "compression_type",
372            ),
373            (
374                progress_group_id == &other.progress_group_id,
375                "progress_group_id",
376            ),
377            (
378                transactional_id == &other.transactional_id,
379                "transactional_id",
380            ),
381            (topic_options == &other.topic_options, "topic_config"),
382            (
383                topic_metadata_refresh_interval == &other.topic_metadata_refresh_interval,
384                "topic_metadata_refresh_interval",
385            ),
386        ];
387        for (compatible, field) in compatibility_checks {
388            if !compatible {
389                tracing::warn!(
390                    "KafkaSinkConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
391                    self,
392                    other
393                );
394
395                return Err(AlterError { id });
396            }
397        }
398
399        Ok(())
400    }
401}
402
403impl<R: ConnectionResolver> IntoInlineConnection<KafkaSinkConnection, R>
404    for KafkaSinkConnection<ReferencedConnection>
405{
406    fn into_inline_connection(self, r: R) -> KafkaSinkConnection {
407        let KafkaSinkConnection {
408            connection_id,
409            connection,
410            format,
411            relation_key_indices,
412            key_desc_and_indices,
413            headers_index,
414            value_desc,
415            partition_by,
416            topic,
417            compression_type,
418            progress_group_id,
419            transactional_id,
420            topic_options,
421            topic_metadata_refresh_interval,
422        } = self;
423        KafkaSinkConnection {
424            connection_id,
425            connection: r.resolve_connection(connection).unwrap_kafka(),
426            format: format.into_inline_connection(r),
427            relation_key_indices,
428            key_desc_and_indices,
429            headers_index,
430            value_desc,
431            partition_by,
432            topic,
433            compression_type,
434            progress_group_id,
435            transactional_id,
436            topic_options,
437            topic_metadata_refresh_interval,
438        }
439    }
440}
441
442#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
443pub enum KafkaIdStyle {
444    /// A new-style id that is optionally prefixed.
445    Prefix(Option<String>),
446    /// A legacy style id.
447    Legacy,
448}
449
450#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
451pub struct KafkaSinkFormat<C: ConnectionAccess = InlinedConnection> {
452    pub key_format: Option<KafkaSinkFormatType<C>>,
453    pub value_format: KafkaSinkFormatType<C>,
454}
455
456#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
457pub enum KafkaSinkFormatType<C: ConnectionAccess = InlinedConnection> {
458    Avro {
459        schema: String,
460        compatibility_level: Option<mz_ccsr::CompatibilityLevel>,
461        /// The registry schema name to publish under. `None` means derive it
462        /// from the topic (`{topic}-key` / `{topic}-value`). Only ever `Some`
463        /// for Glue, where the user may override the name. Confluent always
464        /// uses the topic-derived subject.
465        #[serde(default)]
466        schema_name: Option<String>,
467        /// Wire-format dispatch and the registry to publish to. Sinks
468        /// require a registry
469        wire_format: WireFormat<C>,
470    },
471    Json,
472    Text,
473    Bytes,
474}
475
476impl<C: ConnectionAccess> KafkaSinkFormatType<C> {
477    pub fn get_format_name(&self) -> &str {
478        match self {
479            Self::Avro { .. } => "avro",
480            Self::Json => "json",
481            Self::Text => "text",
482            Self::Bytes => "bytes",
483        }
484    }
485}
486
487impl<C: ConnectionAccess> KafkaSinkFormat<C> {
488    pub fn get_format_name<'a>(&'a self) -> Cow<'a, str> {
489        // For legacy reasons, if the key-format is none or the key & value formats are
490        // both the same (either avro or json), we return the value format name,
491        // otherwise we return a composite name.
492        match &self.key_format {
493            None => self.value_format.get_format_name().into(),
494            Some(key_format) => match (key_format, &self.value_format) {
495                (KafkaSinkFormatType::Avro { .. }, KafkaSinkFormatType::Avro { .. }) => {
496                    "avro".into()
497                }
498                (KafkaSinkFormatType::Json, KafkaSinkFormatType::Json) => "json".into(),
499                (keyf, valuef) => format!(
500                    "key-{}-value-{}",
501                    keyf.get_format_name(),
502                    valuef.get_format_name()
503                )
504                .into(),
505            },
506        }
507    }
508
509    fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
510        if self == other {
511            return Ok(());
512        }
513
514        match (&self.value_format, &other.value_format) {
515            (
516                KafkaSinkFormatType::Avro {
517                    schema,
518                    compatibility_level: _,
519                    schema_name,
520                    wire_format,
521                },
522                KafkaSinkFormatType::Avro {
523                    schema: other_schema,
524                    compatibility_level: _,
525                    schema_name: other_schema_name,
526                    wire_format: other_wire_format,
527                },
528            ) => {
529                if schema != other_schema
530                    || schema_name != other_schema_name
531                    || wire_format.alter_compatible(id, other_wire_format).is_err()
532                {
533                    tracing::warn!(
534                        "KafkaSinkFormat::Avro incompatible at value_format:\nself:\n{:#?}\n\nother\n{:#?}",
535                        self,
536                        other
537                    );
538
539                    return Err(AlterError { id });
540                }
541            }
542            (s, o) => {
543                if s != o {
544                    tracing::warn!(
545                        "KafkaSinkFormat incompatible at value_format:\nself:\n{:#?}\n\nother:{:#?}",
546                        s,
547                        o
548                    );
549                    return Err(AlterError { id });
550                }
551            }
552        }
553
554        match (&self.key_format, &other.key_format) {
555            (
556                Some(KafkaSinkFormatType::Avro {
557                    schema,
558                    compatibility_level: _,
559                    schema_name,
560                    wire_format,
561                }),
562                Some(KafkaSinkFormatType::Avro {
563                    schema: other_schema,
564                    compatibility_level: _,
565                    schema_name: other_schema_name,
566                    wire_format: other_wire_format,
567                }),
568            ) => {
569                if schema != other_schema
570                    || schema_name != other_schema_name
571                    || wire_format.alter_compatible(id, other_wire_format).is_err()
572                {
573                    tracing::warn!(
574                        "KafkaSinkFormat::Avro incompatible at key_format:\nself:\n{:#?}\n\nother\n{:#?}",
575                        self,
576                        other
577                    );
578
579                    return Err(AlterError { id });
580                }
581            }
582            (s, o) => {
583                if s != o {
584                    tracing::warn!(
585                        "KafkaSinkFormat incompatible at key_format\nself:\n{:#?}\n\nother:{:#?}",
586                        s,
587                        o
588                    );
589                    return Err(AlterError { id });
590                }
591            }
592        }
593
594        Ok(())
595    }
596}
597
598impl<R: ConnectionResolver> IntoInlineConnection<KafkaSinkFormat, R>
599    for KafkaSinkFormat<ReferencedConnection>
600{
601    fn into_inline_connection(self, r: R) -> KafkaSinkFormat {
602        KafkaSinkFormat {
603            key_format: self.key_format.map(|f| f.into_inline_connection(&r)),
604            value_format: self.value_format.into_inline_connection(&r),
605        }
606    }
607}
608
609impl<R: ConnectionResolver> IntoInlineConnection<KafkaSinkFormatType, R>
610    for KafkaSinkFormatType<ReferencedConnection>
611{
612    fn into_inline_connection(self, r: R) -> KafkaSinkFormatType {
613        match self {
614            KafkaSinkFormatType::Avro {
615                schema,
616                compatibility_level,
617                schema_name,
618                wire_format,
619            } => KafkaSinkFormatType::Avro {
620                schema,
621                compatibility_level,
622                schema_name,
623                wire_format: wire_format.into_inline_connection(r),
624            },
625            KafkaSinkFormatType::Json => KafkaSinkFormatType::Json,
626            KafkaSinkFormatType::Text => KafkaSinkFormatType::Text,
627            KafkaSinkFormatType::Bytes => KafkaSinkFormatType::Bytes,
628        }
629    }
630}
631
632#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
633pub enum S3SinkFormat {
634    /// Encoded using the PG `COPY` protocol, with one of its supported formats.
635    PgCopy(CopyFormatParams<'static>),
636    /// Encoded as Parquet.
637    Parquet,
638}
639
640/// Info required to copy the data to s3.
641#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
642pub struct S3UploadInfo {
643    /// The s3 uri path to write the data to.
644    pub uri: String,
645    /// The max file size of each file uploaded to S3.
646    pub max_file_size: u64,
647    /// The relation desc of the data to be uploaded to S3.
648    pub desc: RelationDesc,
649    /// The selected sink format.
650    pub format: S3SinkFormat,
651}
652
653pub const MIN_S3_SINK_FILE_SIZE: ByteSize = ByteSize::mb(16);
654pub const MAX_S3_SINK_FILE_SIZE: ByteSize = ByteSize::gb(4);
655
656/// Column name appended by MODE APPEND Iceberg sinks to record the diff (+1/−1).
657pub const ICEBERG_APPEND_DIFF_COLUMN: &str = "_mz_diff";
658/// Column name appended by MODE APPEND Iceberg sinks to record the logical timestamp.
659pub const ICEBERG_APPEND_TIMESTAMP_COLUMN: &str = "_mz_timestamp";
660
661/// The precision needed to store all UInt64 values in a Decimal128.
662/// UInt64 max value is 18,446,744,073,709,551,615 which has 20 digits.
663pub const ICEBERG_UINT64_DECIMAL_PRECISION: u8 = 20;
664
665/// Type overrides for Iceberg-compatible Arrow schemas.
666///
667/// Iceberg doesn't support unsigned integer types or interval natively, so we
668/// map them to compatible types:
669/// - `UInt8`, `UInt16` -> `Int32`
670/// - `UInt32` -> `Int64`
671/// - `UInt64` -> `Decimal128(20, 0)`
672/// - `MzTimestamp` (which uses UInt64) -> `Decimal128(20, 0)`
673/// - `Interval` -> string (`LargeUtf8`)
674///
675/// Pass this to `mz_arrow_util::builder::desc_to_schema_with_overrides`
676/// when producing the Arrow schema for an iceberg sink, and to
677/// `mz_arrow_util::builder::ArrowBuilder::validate_desc_for_parquet` to
678/// validate the desc before sink creation.
679pub fn iceberg_type_overrides(
680    scalar_type: &mz_repr::SqlScalarType,
681) -> Option<(arrow::datatypes::DataType, String)> {
682    use arrow::datatypes::DataType;
683    use mz_repr::SqlScalarType;
684    match scalar_type {
685        SqlScalarType::UInt16 => Some((DataType::Int32, "uint2".to_string())),
686        SqlScalarType::UInt32 => Some((DataType::Int64, "uint4".to_string())),
687        SqlScalarType::UInt64 => Some((
688            DataType::Decimal128(ICEBERG_UINT64_DECIMAL_PRECISION, 0),
689            "uint8".to_string(),
690        )),
691        SqlScalarType::MzTimestamp => Some((
692            DataType::Decimal128(ICEBERG_UINT64_DECIMAL_PRECISION, 0),
693            "mz_timestamp".to_string(),
694        )),
695        SqlScalarType::Interval => Some((DataType::LargeUtf8, "interval".to_string())),
696        _ => None,
697    }
698}
699
700#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
701#[cfg_attr(any(test, feature = "proptest"), derive(Arbitrary))]
702pub struct IcebergSinkConnection<C: ConnectionAccess = InlinedConnection> {
703    pub catalog_connection_id: CatalogItemId,
704    pub catalog_connection: C::IcebergCatalog,
705
706    /// We allow users to specify a separate (from the catalog) connection
707    /// for the storage layer, but we currently ignore it.
708    /// S3 Tables uses the same AWS connection for catalog and storage.
709    /// BigLake/Lakehouse uses the same GCP connection for catalog and storage.
710    ///
711    /// TODO(kynan): Once we need separate storage creds, make this generic.
712    ///   And check that the [`IcebergSinkConnection::alter_compatible`]
713    ///   implementation still handles `storage_connection` acceptably.
714    pub storage_connection_id: Option<CatalogItemId>,
715    pub storage_connection: Option<C::Aws>,
716
717    /// A natural key of the sinked relation (view or source).
718    pub relation_key_indices: Option<Vec<usize>>,
719    /// The user-specified key for the sink.
720    pub key_desc_and_indices: Option<(RelationDesc, Vec<usize>)>,
721    pub namespace: String,
722    pub table: String,
723}
724
725impl<C: ConnectionAccess> IcebergSinkConnection<C> {
726    /// Determines if `self` is compatible with another `StorageSinkConnection`,
727    /// in such a way that it is possible to turn `self` into `other` through a
728    /// valid series of transformations (e.g. no transformation or `ALTER
729    /// CONNECTION`).
730    pub fn alter_compatible(&self, id: GlobalId, other: &Self) -> Result<(), AlterError> {
731        if self == other {
732            return Ok(());
733        }
734        let IcebergSinkConnection {
735            catalog_connection_id: connection_id,
736            catalog_connection,
737            storage_connection_id,
738            storage_connection,
739            relation_key_indices,
740            key_desc_and_indices,
741            namespace,
742            table,
743        } = self;
744
745        let compatibility_checks = [
746            (
747                connection_id == &other.catalog_connection_id,
748                "connection_id",
749            ),
750            (
751                catalog_connection
752                    .alter_compatible(id, &other.catalog_connection)
753                    .is_ok(),
754                "catalog_connection",
755            ),
756            // We don't use `storage_connection_id` and `storage_connection`,
757            // so allow them to be removed.
758            (
759                other.storage_connection_id.is_none()
760                    || storage_connection_id == &other.storage_connection_id,
761                "storage_connection_id",
762            ),
763            (
764                match &other.storage_connection {
765                    None => true, // Removing a storage connection OR not adding a storage connection.
766                    Some(after) => {
767                        match storage_connection {
768                            None => false, // Adding a storage connection where there wasn't one before.
769                            Some(before) => before.alter_compatible(id, after).is_ok(),
770                        }
771                    }
772                },
773                "storage_connection",
774            ),
775            (
776                relation_key_indices == &other.relation_key_indices,
777                "relation_key_indices",
778            ),
779            (
780                key_desc_and_indices == &other.key_desc_and_indices,
781                "key_desc_and_indices",
782            ),
783            (namespace == &other.namespace, "namespace"),
784            (table == &other.table, "table"),
785        ];
786        for (compatible, field) in compatibility_checks {
787            if !compatible {
788                tracing::warn!(
789                    "IcebergSinkConnection incompatible at {field}:\nself:\n{:#?}\n\nother\n{:#?}",
790                    self,
791                    other
792                );
793
794                return Err(AlterError { id });
795            }
796        }
797
798        Ok(())
799    }
800}
801
802impl<R: ConnectionResolver> IntoInlineConnection<IcebergSinkConnection, R>
803    for IcebergSinkConnection<ReferencedConnection>
804{
805    fn into_inline_connection(self, r: R) -> IcebergSinkConnection {
806        let IcebergSinkConnection {
807            catalog_connection_id,
808            catalog_connection,
809            storage_connection_id,
810            storage_connection,
811            relation_key_indices,
812            key_desc_and_indices,
813            namespace,
814            table,
815        } = self;
816        IcebergSinkConnection {
817            catalog_connection_id,
818            catalog_connection: r
819                .resolve_connection(catalog_connection)
820                .unwrap_iceberg_catalog(),
821            storage_connection_id,
822            storage_connection: storage_connection.map(|c| r.resolve_connection(c).unwrap_aws()),
823            relation_key_indices,
824            key_desc_and_indices,
825            namespace,
826            table,
827        }
828    }
829}