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