Skip to main content

risingwave_connector/sink/iceberg/
mod.rs

1// Copyright 2023 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
15#[cfg(test)]
16mod test;
17
18mod commit;
19pub mod commit_retry;
20mod config;
21mod create_table;
22mod engine_options;
23mod metadata;
24#[cfg(any(test, madsim))]
25pub mod mock_v3_catalog_registry;
26mod prometheus;
27mod writer;
28
29use std::collections::BTreeMap;
30use std::fmt::Debug;
31use std::num::NonZeroU64;
32
33use anyhow::{Context, anyhow};
34pub use commit::*;
35pub use config::*;
36pub use create_table::*;
37pub use engine_options::*;
38use iceberg::table::Table;
39pub use metadata::*;
40use risingwave_common::bail;
41use tokio::sync::mpsc::UnboundedSender;
42pub use writer::*;
43
44use super::{
45    GLOBAL_SINK_METRICS, SINK_TYPE_APPEND_ONLY, SINK_TYPE_OPTION, SINK_TYPE_UPSERT, Sink,
46    SinkError, SinkWriterParam,
47};
48use crate::connector_common::{IcebergCatalogKind, IcebergSinkCompactionUpdate};
49use crate::enforce_secret::EnforceSecret;
50use crate::sink::coordinate::CoordinatedLogSinker;
51use crate::sink::{Result, SinkCommitCoordinator, SinkParam};
52
53pub const ICEBERG_SINK: &str = "iceberg";
54
55pub struct IcebergSink {
56    pub config: IcebergConfig,
57    param: SinkParam,
58    // In upsert mode, it is never None or empty.
59    upsert_primary_key_column_names: Option<Vec<String>>,
60}
61
62impl EnforceSecret for IcebergSink {
63    fn enforce_secret<'a>(
64        prop_iter: impl Iterator<Item = &'a str>,
65    ) -> crate::error::ConnectorResult<()> {
66        for prop in prop_iter {
67            IcebergConfig::enforce_one(prop)?;
68        }
69        Ok(())
70    }
71}
72
73impl TryFrom<SinkParam> for IcebergSink {
74    type Error = SinkError;
75
76    fn try_from(param: SinkParam) -> std::result::Result<Self, Self::Error> {
77        let config = IcebergConfig::from_btreemap(param.properties.clone())?;
78        IcebergSink::new(config, param)
79    }
80}
81
82impl Debug for IcebergSink {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.debug_struct("IcebergSink")
85            .field("config", &self.config)
86            .finish()
87    }
88}
89
90impl IcebergSink {
91    pub async fn create_and_validate_table(&self) -> Result<Table> {
92        create_and_validate_table_impl(&self.config, &self.param).await
93    }
94
95    pub async fn create_table_if_not_exists(&self) -> Result<()> {
96        create_table_if_not_exists_impl(&self.config, &self.param).await
97    }
98
99    pub fn new(config: IcebergConfig, param: SinkParam) -> Result<Self> {
100        if let Some(order_key) = &config.order_key {
101            validate_order_key_columns(
102                order_key,
103                param.columns.iter().map(|column| column.name.as_str()),
104            )
105            .context("invalid order_key")
106            .map_err(SinkError::Config)?;
107        }
108
109        let upsert_primary_key_column_names =
110            if config.r#type == SINK_TYPE_UPSERT && !config.force_append_only {
111                let pk_indices = param
112                    .downstream_pk
113                    .as_ref()
114                    .filter(|pk| !pk.is_empty())
115                    .ok_or_else(|| {
116                        SinkError::Config(anyhow!(
117                            "primary key must be specified for upsert iceberg sink"
118                        ))
119                    })?;
120                Some(
121                    pk_indices
122                        .iter()
123                        .map(|&idx| {
124                            param
125                                .columns
126                                .get(idx)
127                                .map(|column| column.name.clone())
128                                .ok_or_else(|| {
129                                    SinkError::Config(anyhow!(
130                                        "primary key column index {} out of range in sink schema",
131                                        idx
132                                    ))
133                                })
134                        })
135                        .collect::<Result<Vec<_>>>()?,
136                )
137            } else {
138                None
139            };
140        Ok(Self {
141            config,
142            param,
143            upsert_primary_key_column_names,
144        })
145    }
146}
147
148impl Sink for IcebergSink {
149    type LogSinker = CoordinatedLogSinker<IcebergSinkWriter>;
150
151    const SINK_NAME: &'static str = ICEBERG_SINK;
152
153    crate::impl_validate_sink_unknown_fields!();
154
155    async fn validate(&self) -> Result<()> {
156        let catalog_kind = self.config.catalog_kind()?;
157        if matches!(catalog_kind, IcebergCatalogKind::Snowflake) {
158            bail!("Snowflake catalog only supports iceberg sources");
159        }
160
161        if matches!(catalog_kind, IcebergCatalogKind::Glue(_)) {
162            risingwave_common::license::Feature::IcebergSinkWithGlue
163                .check_available()
164                .map_err(|e| anyhow::anyhow!(e))?;
165        }
166
167        // Enforce merge-on-read for append-only tables
168        IcebergConfig::validate_append_only_write_mode(
169            &self.config.r#type,
170            self.config.write_mode,
171        )?;
172
173        // Validate compaction type configuration
174        let compaction_type = self.config.compaction_type();
175
176        // Check COW mode constraints
177        // COW mode only supports 'full' compaction type
178        if self.config.write_mode == IcebergWriteMode::CopyOnWrite
179            && compaction_type != CompactionType::Full
180        {
181            bail!(
182                "'copy-on-write' mode only supports 'full' compaction type, got: '{}'",
183                compaction_type
184            );
185        }
186
187        match compaction_type {
188            CompactionType::SmallFiles => {
189                // 1. check license
190                risingwave_common::license::Feature::IcebergCompaction
191                    .check_available()
192                    .map_err(|e| anyhow::anyhow!(e))?;
193
194                // 2. check write mode
195                if self.config.write_mode != IcebergWriteMode::MergeOnRead {
196                    bail!(
197                        "'small-files' compaction type only supports 'merge-on-read' write mode, got: '{}'",
198                        self.config.write_mode
199                    );
200                }
201
202                // 3. check conflicting parameters
203                if self.config.delete_files_count_threshold.is_some() {
204                    bail!(
205                        "`compaction.delete-files-count-threshold` is not supported for 'small-files' compaction type"
206                    );
207                }
208            }
209            CompactionType::FilesWithDelete => {
210                // 1. check license
211                risingwave_common::license::Feature::IcebergCompaction
212                    .check_available()
213                    .map_err(|e| anyhow::anyhow!(e))?;
214
215                // 2. check write mode
216                if self.config.write_mode != IcebergWriteMode::MergeOnRead {
217                    bail!(
218                        "'files-with-delete' compaction type only supports 'merge-on-read' write mode, got: '{}'",
219                        self.config.write_mode
220                    );
221                }
222
223                // 3. check conflicting parameters
224                if self.config.small_files_threshold_mb.is_some() {
225                    bail!(
226                        "`compaction.small-files-threshold-mb` must not be set for 'files-with-delete' compaction type"
227                    );
228                }
229            }
230            CompactionType::Full => {
231                // Full compaction has no special requirements
232            }
233        }
234
235        let table = self.create_and_validate_table().await?;
236        self.config
237            .validate_manifest_rewrite_format(table.metadata().format_version())?;
238        Ok(())
239    }
240
241    fn support_schema_change() -> bool {
242        true
243    }
244
245    fn validate_alter_config(config: &BTreeMap<String, String>) -> Result<()> {
246        let iceberg_config = IcebergConfig::from_btreemap(config.clone())?;
247
248        // Validate compaction interval
249        if let Some(compaction_interval) = iceberg_config.compaction_interval_sec {
250            if iceberg_config.enable_compaction && compaction_interval == 0 {
251                bail!(
252                    "`compaction-interval-sec` must be greater than 0 when `enable-compaction` is true"
253                );
254            }
255
256            tracing::info!(
257                "Alter config compaction_interval set to {} seconds",
258                compaction_interval
259            );
260        }
261
262        // Validate max snapshots
263        if let Some(max_snapshots) = iceberg_config.max_snapshots_num_before_compaction
264            && max_snapshots < 1
265        {
266            bail!(
267                "`compaction.max_snapshots_num` must be greater than 0, got: {}",
268                max_snapshots
269            );
270        }
271
272        // Validate target file size
273        if let Some(target_file_size_mb) = iceberg_config.target_file_size_mb
274            && target_file_size_mb == 0
275        {
276            bail!("`compaction.target_file_size_mb` must be greater than 0");
277        }
278
279        // Validate parquet max row group rows
280        if let Some(max_row_group_rows) = iceberg_config.write_parquet_max_row_group_rows
281            && max_row_group_rows == 0
282        {
283            bail!("`compaction.write_parquet_max_row_group_rows` must be greater than 0");
284        }
285
286        // Validate parquet max row group bytes
287        if let Some(max_row_group_bytes) = iceberg_config.write_parquet_max_row_group_bytes
288            && max_row_group_bytes == 0
289        {
290            bail!("`compaction.write_parquet_max_row_group_bytes` must be greater than 0");
291        }
292
293        // Validate parquet compression codec
294        if let Some(ref compression) = iceberg_config.write_parquet_compression {
295            let valid_codecs = [
296                "uncompressed",
297                "snappy",
298                "gzip",
299                "lzo",
300                "brotli",
301                "lz4",
302                "zstd",
303            ];
304            if !valid_codecs.contains(&compression.to_lowercase().as_str()) {
305                bail!(
306                    "`compaction.write_parquet_compression` must be one of {:?}, got: {}",
307                    valid_codecs,
308                    compression
309                );
310            }
311        }
312
313        Ok(())
314    }
315
316    async fn new_log_sinker(&self, writer_param: SinkWriterParam) -> Result<Self::LogSinker> {
317        let writer = IcebergSinkWriter::new(
318            self.config.clone(),
319            self.param.clone(),
320            writer_param.clone(),
321            self.upsert_primary_key_column_names.clone(),
322        );
323
324        let commit_checkpoint_interval =
325            NonZeroU64::new(self.config.commit_checkpoint_interval).expect(
326                "commit_checkpoint_interval should be greater than 0, and it should be checked in config validation",
327            );
328        let log_sinker = CoordinatedLogSinker::new(
329            &writer_param,
330            self.param.clone(),
331            writer,
332            commit_checkpoint_interval,
333        )
334        .await?;
335
336        Ok(log_sinker)
337    }
338
339    fn is_coordinated_sink(&self) -> bool {
340        true
341    }
342
343    async fn new_coordinator(
344        &self,
345        iceberg_compact_stat_sender: Option<UnboundedSender<IcebergSinkCompactionUpdate>>,
346    ) -> Result<SinkCommitCoordinator> {
347        let catalog = self.config.create_catalog().await?;
348        let table = self.create_and_validate_table().await?;
349        let coordinator = IcebergSinkCommitter {
350            catalog,
351            table,
352            last_commit_epoch: 0,
353            sink_id: self.param.sink_id,
354            config: self.config.clone(),
355            param: self.param.clone(),
356            commit_retry_num: self.config.commit_retry_num,
357            iceberg_compact_stat_sender,
358        };
359        if self.config.is_exactly_once.unwrap_or_default() {
360            Ok(SinkCommitCoordinator::TwoPhase(Box::new(coordinator)))
361        } else {
362            Ok(SinkCommitCoordinator::SinglePhase(Box::new(coordinator)))
363        }
364    }
365}