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 pk_index_file;
27mod position_delete;
28mod prometheus;
29mod writer;
30
31use std::collections::BTreeMap;
32use std::fmt::Debug;
33use std::num::NonZeroU64;
34
35use anyhow::{Context, anyhow};
36pub use commit::*;
37pub use config::*;
38pub use create_table::*;
39pub use engine_options::*;
40use iceberg::table::Table;
41pub use metadata::*;
42pub use pk_index_file::*;
43pub use position_delete::*;
44use risingwave_common::bail;
45use risingwave_common::license::Feature;
46use tokio::sync::mpsc::UnboundedSender;
47pub use writer::*;
48
49use super::{
50    GLOBAL_SINK_METRICS, SINK_TYPE_APPEND_ONLY, SINK_TYPE_OPTION, SINK_TYPE_UPSERT, Sink,
51    SinkError, SinkWriterParam,
52};
53use crate::connector_common::{IcebergCatalogKind, IcebergSinkCompactionUpdate};
54use crate::enforce_secret::EnforceSecret;
55use crate::sink::coordinate::CoordinatedLogSinker;
56use crate::sink::{Result, SinkCommitCoordinator, SinkParam};
57
58pub const ICEBERG_SINK: &str = "iceberg";
59
60pub struct IcebergSink {
61    pub config: IcebergConfig,
62    param: SinkParam,
63    // In upsert mode, it is never None or empty.
64    upsert_primary_key_column_names: Option<Vec<String>>,
65}
66
67impl EnforceSecret for IcebergSink {
68    fn enforce_secret<'a>(
69        prop_iter: impl Iterator<Item = &'a str>,
70    ) -> crate::error::ConnectorResult<()> {
71        for prop in prop_iter {
72            IcebergConfig::enforce_one(prop)?;
73        }
74        Ok(())
75    }
76}
77
78impl TryFrom<SinkParam> for IcebergSink {
79    type Error = SinkError;
80
81    fn try_from(param: SinkParam) -> std::result::Result<Self, Self::Error> {
82        let config = IcebergConfig::from_btreemap(param.properties.clone())?;
83        IcebergSink::new(config, param)
84    }
85}
86
87impl Debug for IcebergSink {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        f.debug_struct("IcebergSink")
90            .field("config", &self.config)
91            .finish()
92    }
93}
94
95fn validate_explicit_compaction_type(config: &IcebergConfig) -> Result<()> {
96    let Some(compaction_type) = config.compaction_type else {
97        return Ok(());
98    };
99
100    if config.write_mode == IcebergWriteMode::CopyOnWrite {
101        bail!(
102            "`compaction.type` must not be set when `write_mode` is `copy-on-write`; \
103             copy-on-write selects its compaction policy automatically"
104        );
105    }
106
107    if !matches!(compaction_type, CompactionType::Full) {
108        Feature::IcebergCompaction
109            .check_available()
110            .map_err(|e| anyhow!(e))?;
111    }
112
113    Ok(())
114}
115
116fn validate_compaction_option_compatibility(config: &IcebergConfig) -> Result<()> {
117    // Value ranges are validated by `IcebergConfig::from_btreemap`. This only rejects
118    // options that are incompatible with the selected merge-on-read strategy.
119    // COW ignores legacy persisted types, so merge-on-read type-option compatibility does not apply.
120    if config.write_mode == IcebergWriteMode::CopyOnWrite {
121        return Ok(());
122    }
123
124    let Some(compaction_type) = config.compaction_type else {
125        return Ok(());
126    };
127
128    let unsupported_option = match compaction_type {
129        // Auto uses both selection thresholds.
130        CompactionType::Auto => None,
131        // Keep accepting strategy-specific thresholds that Full ignores.
132        CompactionType::Full => None,
133        CompactionType::SmallFiles => config
134            .delete_files_count_threshold
135            .is_some()
136            .then_some(COMPACTION_DELETE_FILES_COUNT_THRESHOLD),
137        CompactionType::FilesWithDelete => config
138            .small_files_threshold_mb
139            .is_some()
140            .then_some(COMPACTION_SMALL_FILES_THRESHOLD_MB),
141    };
142    if let Some(option) = unsupported_option {
143        bail!(
144            "`{option}` is not supported for '{}' compaction type",
145            compaction_type.as_str()
146        );
147    }
148
149    Ok(())
150}
151
152impl IcebergSink {
153    pub async fn create_and_validate_table(&self) -> Result<Table> {
154        create_and_validate_table_impl(&self.config, &self.param).await
155    }
156
157    async fn create_and_load_table(&self) -> Result<Table> {
158        create_and_load_table_impl(&self.config, &self.param).await
159    }
160
161    /// Returns `true` if this call created the table, `false` if it already existed.
162    pub async fn create_table_if_not_exists(&self) -> Result<bool> {
163        create_table_if_not_exists_impl(&self.config, &self.param).await
164    }
165
166    pub fn new(config: IcebergConfig, param: SinkParam) -> Result<Self> {
167        if let Some(order_key) = &config.order_key {
168            validate_order_key_columns(
169                order_key,
170                param.columns.iter().map(|column| column.name.as_str()),
171            )
172            .context("invalid order_key")
173            .map_err(SinkError::Config)?;
174        }
175
176        let upsert_primary_key_column_names =
177            if config.r#type == SINK_TYPE_UPSERT && !config.force_append_only {
178                let pk_indices = param
179                    .downstream_pk
180                    .as_ref()
181                    .filter(|pk| !pk.is_empty())
182                    .ok_or_else(|| {
183                        SinkError::Config(anyhow!(
184                            "primary key must be specified for upsert iceberg sink"
185                        ))
186                    })?;
187                Some(
188                    pk_indices
189                        .iter()
190                        .map(|&idx| {
191                            param
192                                .columns
193                                .get(idx)
194                                .map(|column| column.name.clone())
195                                .ok_or_else(|| {
196                                    SinkError::Config(anyhow!(
197                                        "primary key column index {} out of range in sink schema",
198                                        idx
199                                    ))
200                                })
201                        })
202                        .collect::<Result<Vec<_>>>()?,
203                )
204            } else {
205                None
206            };
207        Ok(Self {
208            config,
209            param,
210            upsert_primary_key_column_names,
211        })
212    }
213}
214
215impl Sink for IcebergSink {
216    type LogSinker = CoordinatedLogSinker<IcebergSinkWriter>;
217
218    const SINK_NAME: &'static str = ICEBERG_SINK;
219
220    crate::impl_validate_sink_unknown_fields!();
221
222    fn is_exactly_once(properties: &BTreeMap<String, String>) -> Result<bool> {
223        let Some(value) = properties.get("is_exactly_once") else {
224            return Ok(true);
225        };
226        value.parse::<bool>().map_err(|_| {
227            SinkError::Config(anyhow!(
228                "invalid value for `is_exactly_once`: expected `true` or `false`, got `{value}`"
229            ))
230        })
231    }
232
233    async fn validate(&self) -> Result<()> {
234        if self.config.enable_pk_index {
235            Feature::IcebergSinkWithPkIndex
236                .check_available()
237                .map_err(|e| anyhow!(e))?;
238        }
239
240        let catalog_kind = self.config.catalog_kind()?;
241        if matches!(catalog_kind, IcebergCatalogKind::Snowflake) {
242            bail!("Snowflake catalog only supports iceberg sources");
243        }
244
245        if matches!(catalog_kind, IcebergCatalogKind::Glue(_)) {
246            risingwave_common::license::Feature::IcebergSinkWithGlue
247                .check_available()
248                .map_err(|e| anyhow::anyhow!(e))?;
249        }
250
251        // Enforce merge-on-read for append-only tables
252        IcebergConfig::validate_append_only_write_mode(
253            &self.config.r#type,
254            self.config.write_mode,
255        )?;
256        validate_explicit_compaction_type(&self.config)?;
257        validate_compaction_option_compatibility(&self.config)?;
258
259        // VARIANT is not comparable, so it can never be an equality-delete key.
260        if self.config.r#type == SINK_TYPE_UPSERT
261            && !self.config.force_append_only
262            && let Some(pk_indices) = self
263                .param
264                .downstream_pk
265                .as_ref()
266                .filter(|pk| !pk.is_empty())
267        {
268            for &idx in pk_indices {
269                if let Some(column) = self.param.columns.get(idx)
270                    && column.data_type.contains_variant()
271                {
272                    bail!(
273                        "VARIANT column `{}` cannot be used as the primary key of an upsert iceberg sink",
274                        column.name
275                    );
276                }
277            }
278        }
279
280        let table = self.create_and_validate_table().await?;
281        self.config
282            .validate_manifest_rewrite_format(table.metadata().format_version())?;
283        Ok(())
284    }
285
286    fn support_schema_change() -> bool {
287        true
288    }
289
290    fn validate_alter_config_change(
291        config: &BTreeMap<String, String>,
292        alter_props: &BTreeMap<String, String>,
293    ) -> Result<()> {
294        let iceberg_config = IcebergConfig::from_btreemap(config.clone())?;
295        if iceberg_config.enable_pk_index {
296            // PK-index executors do not rebuild their writers on ConnectorPropsChange.
297            // Maintenance options and commit intervals are consumed by Meta, but accepting
298            // writer options here would leave the writer and persisted configuration out of sync.
299            // The ordinary sink's backlog and delete-file-count knobs also have no consumer
300            // in the PK-index path, which selects by delete ratio.
301            for key in [
302                COMPACTION_MAX_SNAPSHOTS_NUM,
303                COMPACTION_DELETE_FILES_COUNT_THRESHOLD,
304                COMPACTION_WRITE_PARQUET_COMPRESSION,
305                COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_ROWS,
306                COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_BYTES,
307            ] {
308                if alter_props.contains_key(key) {
309                    bail!("altering `{key}` is not supported for Iceberg sinks with PK index");
310                }
311            }
312        }
313        let compaction_type_changed = alter_props.contains_key(COMPACTION_TYPE);
314        let compaction_options_changed = compaction_type_changed
315            || alter_props.contains_key(COMPACTION_SMALL_FILES_THRESHOLD_MB)
316            || alter_props.contains_key(COMPACTION_DELETE_FILES_COUNT_THRESHOLD);
317        let enabling_compaction = alter_props
318            .get(ENABLE_COMPACTION)
319            .is_some_and(|value| value.eq_ignore_ascii_case("true"));
320
321        if compaction_options_changed || enabling_compaction {
322            let validate_explicit_type = compaction_type_changed
323                || (enabling_compaction
324                    && iceberg_config.write_mode == IcebergWriteMode::MergeOnRead);
325
326            // Persisted COW types are legacy-only and must not block compaction activation.
327            if validate_explicit_type {
328                validate_explicit_compaction_type(&iceberg_config)?;
329            }
330            validate_compaction_option_compatibility(&iceberg_config)?;
331        }
332
333        Self::validate_alter_config(config)
334    }
335
336    fn validate_alter_config(config: &BTreeMap<String, String>) -> Result<()> {
337        let iceberg_config = IcebergConfig::from_btreemap(config.clone())?;
338
339        // Validate compaction interval
340        if let Some(compaction_interval) = iceberg_config.compaction_interval_sec {
341            if iceberg_config.enable_compaction && compaction_interval == 0 {
342                bail!(
343                    "`compaction-interval-sec` must be greater than 0 when `enable-compaction` is true"
344                );
345            }
346
347            tracing::info!(
348                "Alter config compaction_interval set to {} seconds",
349                compaction_interval
350            );
351        }
352
353        // Validate max snapshots
354        if let Some(max_snapshots) = iceberg_config.max_snapshots_num_before_compaction
355            && max_snapshots < 1
356        {
357            bail!(
358                "`compaction.max_snapshots_num` must be greater than 0, got: {}",
359                max_snapshots
360            );
361        }
362
363        // Validate target file size
364        if let Some(target_file_size_mb) = iceberg_config.target_file_size_mb
365            && target_file_size_mb == 0
366        {
367            bail!("`compaction.target_file_size_mb` must be greater than 0");
368        }
369
370        // Validate parquet max row group rows
371        if let Some(max_row_group_rows) = iceberg_config.write_parquet_max_row_group_rows
372            && max_row_group_rows == 0
373        {
374            bail!("`compaction.write_parquet_max_row_group_rows` must be greater than 0");
375        }
376
377        // Validate parquet max row group bytes
378        if let Some(max_row_group_bytes) = iceberg_config.write_parquet_max_row_group_bytes
379            && max_row_group_bytes == 0
380        {
381            bail!("`compaction.write_parquet_max_row_group_bytes` must be greater than 0");
382        }
383
384        // Validate parquet compression codec
385        if let Some(ref compression) = iceberg_config.write_parquet_compression {
386            let valid_codecs = [
387                "uncompressed",
388                "snappy",
389                "gzip",
390                "lzo",
391                "brotli",
392                "lz4",
393                "zstd",
394            ];
395            if !valid_codecs.contains(&compression.to_lowercase().as_str()) {
396                bail!(
397                    "`compaction.write_parquet_compression` must be one of {:?}, got: {}",
398                    valid_codecs,
399                    compression
400                );
401            }
402        }
403
404        Ok(())
405    }
406
407    async fn new_log_sinker(&self, writer_param: SinkWriterParam) -> Result<Self::LogSinker> {
408        let writer = IcebergSinkWriter::new(
409            self.config.clone(),
410            self.param.clone(),
411            writer_param.clone(),
412            self.upsert_primary_key_column_names.clone(),
413        );
414
415        let commit_checkpoint_interval =
416            NonZeroU64::new(self.config.commit_checkpoint_interval).expect(
417                "commit_checkpoint_interval should be greater than 0, and it should be checked in config validation",
418            );
419        let log_sinker = CoordinatedLogSinker::new(
420            &writer_param,
421            self.param.clone(),
422            writer,
423            commit_checkpoint_interval,
424        )
425        .await?;
426
427        Ok(log_sinker)
428    }
429
430    fn is_coordinated_sink(&self) -> bool {
431        true
432    }
433
434    async fn new_coordinator(
435        &self,
436        iceberg_compact_stat_sender: Option<UnboundedSender<IcebergSinkCompactionUpdate>>,
437    ) -> Result<SinkCommitCoordinator> {
438        let catalog = self.config.create_catalog().await?;
439        // Do not require the current Iceberg schema to match `self.param` here. During recovery,
440        // `self.param` may already include a sink schema change that is still pending in the meta
441        // store. The coordinator commits that change before it starts writer handles, and writers
442        // validate the table schema against their own params in `begin_epoch`.
443        let table = self.create_and_load_table().await?;
444        let coordinator = IcebergSinkCommitter {
445            catalog,
446            table,
447            last_commit_epoch: 0,
448            sink_id: self.param.sink_id,
449            config: self.config.clone(),
450            param: self.param.clone(),
451            commit_retry_num: self.config.commit_retry_num,
452            iceberg_compact_stat_sender,
453        };
454        if Self::is_exactly_once(&self.param.properties)? {
455            Ok(SinkCommitCoordinator::TwoPhase(Box::new(coordinator)))
456        } else {
457            Ok(SinkCommitCoordinator::SinglePhase(Box::new(coordinator)))
458        }
459    }
460}