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