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