risingwave_connector/sink/iceberg/
mod.rs1#[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 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 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 CompactionType::Auto => None,
131 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 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 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 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 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 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 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 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 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 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 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 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 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}