1use std::collections::{BTreeMap, HashMap};
16use std::fmt::Debug;
17use std::sync::Arc;
18
19use anyhow::anyhow;
20use iceberg::spec::{FormatVersion, MAIN_BRANCH};
21use iceberg::table::Table;
22use iceberg::transaction::{MANIFEST_MIN_MERGE_COUNT_DEFAULT, MANIFEST_TARGET_SIZE_BYTES_DEFAULT};
23use iceberg::{Catalog, TableIdent};
24use parquet::basic::Compression;
25use serde::de::{self, Deserializer, Visitor};
26use serde::{Deserialize, Serialize};
27use serde_with::{DisplayFromStr, serde_as};
28use with_options::WithOptions;
29
30use super::{SINK_TYPE_APPEND_ONLY, SINK_TYPE_OPTION, SINK_TYPE_UPSERT, SinkError};
31use crate::connector_common::{
32 IcebergCatalogKind, IcebergCommon, IcebergTableIdentifier, ResolvedIcebergCatalogConfig,
33 iceberg_java_catalog_props_from_options,
34};
35use crate::enforce_secret::EnforceSecret;
36use crate::sink::Result;
37use crate::sink::decouple_checkpoint_log_sink::iceberg_default_commit_checkpoint_interval;
38use crate::{deserialize_bool_from_string, deserialize_optional_string_seq_from_string};
39
40pub const ICEBERG_COW_BRANCH: &str = "ingestion";
41
42pub const ICEBERG_WRITE_MODE_MERGE_ON_READ: &str = "merge-on-read";
43pub const ICEBERG_WRITE_MODE_COPY_ON_WRITE: &str = "copy-on-write";
44pub const ICEBERG_COMPACTION_TYPE_FULL: &str = "full";
45pub const ICEBERG_COMPACTION_TYPE_SMALL_FILES: &str = "small-files";
46pub const ICEBERG_COMPACTION_TYPE_FILES_WITH_DELETE: &str = "files-with-delete";
47
48pub const PARTITION_DATA_ID_START: i32 = 1000;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
51#[serde(rename_all = "kebab-case")]
52pub enum IcebergWriteMode {
53 #[default]
54 MergeOnRead,
55 CopyOnWrite,
56}
57
58impl IcebergWriteMode {
59 pub fn as_str(self) -> &'static str {
60 match self {
61 IcebergWriteMode::MergeOnRead => ICEBERG_WRITE_MODE_MERGE_ON_READ,
62 IcebergWriteMode::CopyOnWrite => ICEBERG_WRITE_MODE_COPY_ON_WRITE,
63 }
64 }
65}
66
67impl std::str::FromStr for IcebergWriteMode {
68 type Err = SinkError;
69
70 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
71 match s {
72 ICEBERG_WRITE_MODE_MERGE_ON_READ => Ok(IcebergWriteMode::MergeOnRead),
73 ICEBERG_WRITE_MODE_COPY_ON_WRITE => Ok(IcebergWriteMode::CopyOnWrite),
74 _ => Err(SinkError::Config(anyhow!(format!(
75 "invalid write_mode: {}, must be one of: {}, {}",
76 s, ICEBERG_WRITE_MODE_MERGE_ON_READ, ICEBERG_WRITE_MODE_COPY_ON_WRITE
77 )))),
78 }
79 }
80}
81
82impl TryFrom<&str> for IcebergWriteMode {
83 type Error = <Self as std::str::FromStr>::Err;
84
85 fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
86 value.parse()
87 }
88}
89
90impl TryFrom<String> for IcebergWriteMode {
91 type Error = <Self as std::str::FromStr>::Err;
92
93 fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
94 value.as_str().parse()
95 }
96}
97
98impl std::fmt::Display for IcebergWriteMode {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 f.write_str(self.as_str())
101 }
102}
103
104pub const ENABLE_COMPACTION: &str = "enable_compaction";
106pub const COMPACTION_INTERVAL_SEC: &str = "compaction_interval_sec";
107pub const ENABLE_SNAPSHOT_EXPIRATION: &str = "enable_snapshot_expiration";
108pub const WRITE_MODE: &str = "write_mode";
109pub const FORMAT_VERSION: &str = "format_version";
110pub const SNAPSHOT_EXPIRATION_RETAIN_LAST: &str = "snapshot_expiration_retain_last";
111pub const SNAPSHOT_EXPIRATION_MAX_AGE_MILLIS: &str = "snapshot_expiration_max_age_millis";
112pub const SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_FILES: &str = "snapshot_expiration_clear_expired_files";
113pub const SNAPSHOT_EXPIRATION_CLEAR_EXPIRED_META_DATA: &str =
114 "snapshot_expiration_clear_expired_meta_data";
115pub const ENABLE_MANIFEST_REWRITE: &str = "enable_manifest_rewrite";
116pub const MANIFEST_REWRITE_TARGET_SIZE_BYTES: &str = "manifest_rewrite_target_size_bytes";
117pub const MANIFEST_REWRITE_MIN_COUNT_TO_MERGE: &str = "manifest_rewrite_min_count_to_merge";
118pub const COMPACTION_MAX_SNAPSHOTS_NUM: &str = "compaction.max_snapshots_num";
119
120pub const COMPACTION_SMALL_FILES_THRESHOLD_MB: &str = "compaction.small_files_threshold_mb";
121
122pub const COMPACTION_DELETE_FILES_COUNT_THRESHOLD: &str = "compaction.delete_files_count_threshold";
123
124pub const COMPACTION_TRIGGER_SNAPSHOT_COUNT: &str = "compaction.trigger_snapshot_count";
125
126pub const COMPACTION_TARGET_FILE_SIZE_MB: &str = "compaction.target_file_size_mb";
127
128pub const COMPACTION_TYPE: &str = "compaction.type";
129
130pub const COMPACTION_WRITE_PARQUET_COMPRESSION: &str = "compaction.write_parquet_compression";
131pub const COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_ROWS: &str =
132 "compaction.write_parquet_max_row_group_rows";
133pub const COMPACTION_WRITE_PARQUET_MAX_ROW_GROUP_BYTES: &str =
134 "compaction.write_parquet_max_row_group_bytes";
135pub const ORDER_KEY: &str = "order_key";
136pub const DEFAULT_COMPACTION_MAX_SNAPSHOTS_NUM: usize = 1000;
137pub const ICEBERG_DEFAULT_WRITE_PARQUET_MAX_ROW_GROUP_BYTES: usize = 128 * 1024 * 1024;
138pub const ENABLE_PK_INDEX: &str = "enable_pk_index";
139
140pub const PARQUET_CREATED_BY: &str = concat!("risingwave version ", env!("CARGO_PKG_VERSION"));
141
142fn default_commit_retry_num() -> u32 {
143 8
144}
145
146fn default_iceberg_write_mode() -> IcebergWriteMode {
147 IcebergWriteMode::MergeOnRead
148}
149
150fn default_iceberg_format_version() -> FormatVersion {
151 FormatVersion::V2
152}
153
154fn default_true() -> bool {
155 true
156}
157
158fn default_some_true() -> Option<bool> {
159 Some(true)
160}
161
162fn parse_format_version_str(value: &str) -> std::result::Result<FormatVersion, String> {
163 let parsed = value
164 .trim()
165 .parse::<u8>()
166 .map_err(|_| "`format-version` must be one of 1, 2, or 3".to_owned())?;
167 match parsed {
168 1 => Ok(FormatVersion::V1),
169 2 => Ok(FormatVersion::V2),
170 3 => Ok(FormatVersion::V3),
171 _ => Err("`format-version` must be one of 1, 2, or 3".to_owned()),
172 }
173}
174
175fn deserialize_format_version<'de, D>(
176 deserializer: D,
177) -> std::result::Result<FormatVersion, D::Error>
178where
179 D: Deserializer<'de>,
180{
181 struct FormatVersionVisitor;
182
183 impl<'de> Visitor<'de> for FormatVersionVisitor {
184 type Value = FormatVersion;
185
186 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187 formatter.write_str("format-version as 1, 2, or 3")
188 }
189
190 fn visit_u64<E>(self, value: u64) -> std::result::Result<Self::Value, E>
191 where
192 E: de::Error,
193 {
194 let value = u8::try_from(value)
195 .map_err(|_| E::custom("`format-version` must be one of 1, 2, or 3"))?;
196 parse_format_version_str(&value.to_string()).map_err(E::custom)
197 }
198
199 fn visit_i64<E>(self, value: i64) -> std::result::Result<Self::Value, E>
200 where
201 E: de::Error,
202 {
203 let value = u8::try_from(value)
204 .map_err(|_| E::custom("`format-version` must be one of 1, 2, or 3"))?;
205 parse_format_version_str(&value.to_string()).map_err(E::custom)
206 }
207
208 fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
209 where
210 E: de::Error,
211 {
212 parse_format_version_str(value).map_err(E::custom)
213 }
214
215 fn visit_string<E>(self, value: String) -> std::result::Result<Self::Value, E>
216 where
217 E: de::Error,
218 {
219 self.visit_str(&value)
220 }
221 }
222
223 deserializer.deserialize_any(FormatVersionVisitor)
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
228#[serde(rename_all = "kebab-case")]
229pub enum CompactionType {
230 #[default]
232 Full,
233 SmallFiles,
235 FilesWithDelete,
237}
238
239impl CompactionType {
240 pub fn as_str(&self) -> &'static str {
241 match self {
242 CompactionType::Full => ICEBERG_COMPACTION_TYPE_FULL,
243 CompactionType::SmallFiles => ICEBERG_COMPACTION_TYPE_SMALL_FILES,
244 CompactionType::FilesWithDelete => ICEBERG_COMPACTION_TYPE_FILES_WITH_DELETE,
245 }
246 }
247}
248
249impl std::str::FromStr for CompactionType {
250 type Err = SinkError;
251
252 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
253 match s {
254 ICEBERG_COMPACTION_TYPE_FULL => Ok(CompactionType::Full),
255 ICEBERG_COMPACTION_TYPE_SMALL_FILES => Ok(CompactionType::SmallFiles),
256 ICEBERG_COMPACTION_TYPE_FILES_WITH_DELETE => Ok(CompactionType::FilesWithDelete),
257 _ => Err(SinkError::Config(anyhow!(format!(
258 "invalid compaction_type: {}, must be one of: {}, {}, {}",
259 s,
260 ICEBERG_COMPACTION_TYPE_FULL,
261 ICEBERG_COMPACTION_TYPE_SMALL_FILES,
262 ICEBERG_COMPACTION_TYPE_FILES_WITH_DELETE
263 )))),
264 }
265 }
266}
267
268impl TryFrom<&str> for CompactionType {
269 type Error = <Self as std::str::FromStr>::Err;
270
271 fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
272 value.parse()
273 }
274}
275
276impl TryFrom<String> for CompactionType {
277 type Error = <Self as std::str::FromStr>::Err;
278
279 fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
280 value.as_str().parse()
281 }
282}
283
284impl std::fmt::Display for CompactionType {
285 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
286 write!(f, "{}", self.as_str())
287 }
288}
289
290#[serde_as]
291#[derive(Debug, Clone, PartialEq, Eq, Deserialize, WithOptions)]
292pub struct IcebergConfig {
293 pub r#type: String, #[serde(default, deserialize_with = "deserialize_bool_from_string")]
296 pub force_append_only: bool,
297
298 #[serde(flatten)]
299 pub(crate) common: IcebergCommon,
300
301 #[serde(flatten)]
302 pub(crate) table: IcebergTableIdentifier,
303
304 #[serde(
305 rename = "primary_key",
306 default,
307 deserialize_with = "deserialize_optional_string_seq_from_string"
308 )]
309 pub primary_key: Option<Vec<String>>,
310
311 #[serde(skip)]
313 pub java_catalog_props: HashMap<String, String>,
314
315 #[serde(default)]
316 #[with_option(iceberg_engine)]
317 pub partition_by: Option<String>,
318
319 #[serde(default)]
320 #[with_option(iceberg_engine)]
321 pub order_key: Option<String>,
322
323 #[serde(default = "iceberg_default_commit_checkpoint_interval")]
325 #[serde_as(as = "DisplayFromStr")]
326 #[with_option(allow_alter_on_fly, iceberg_engine)]
327 pub commit_checkpoint_interval: u64,
328
329 #[serde(default, deserialize_with = "deserialize_bool_from_string")]
330 pub create_table_if_not_exists: bool,
331
332 #[serde(default = "default_some_true")]
334 #[serde_as(as = "Option<DisplayFromStr>")]
335 pub is_exactly_once: Option<bool>,
336 #[serde(default = "default_commit_retry_num")]
341 pub commit_retry_num: u32,
342
343 #[serde(
345 rename = "enable_compaction",
346 default,
347 deserialize_with = "deserialize_bool_from_string"
348 )]
349 #[with_option(allow_alter_on_fly, iceberg_engine)]
350 pub enable_compaction: bool,
351
352 #[serde(rename = "compaction_interval_sec", default)]
354 #[serde_as(as = "Option<DisplayFromStr>")]
355 #[with_option(allow_alter_on_fly, iceberg_engine)]
356 pub compaction_interval_sec: Option<u64>,
357
358 #[serde(
360 rename = "enable_snapshot_expiration",
361 default = "default_true",
362 deserialize_with = "deserialize_bool_from_string"
363 )]
364 #[with_option(allow_alter_on_fly, iceberg_engine)]
365 pub enable_snapshot_expiration: bool,
366
367 #[serde(rename = "write_mode", default = "default_iceberg_write_mode")]
369 #[with_option(iceberg_engine)]
370 pub write_mode: IcebergWriteMode,
371
372 #[serde(
374 rename = "format_version",
375 default = "default_iceberg_format_version",
376 deserialize_with = "deserialize_format_version"
377 )]
378 #[with_option(iceberg_engine)]
379 pub format_version: FormatVersion,
380
381 #[serde(rename = "snapshot_expiration_max_age_millis", default)]
384 #[serde_as(as = "Option<DisplayFromStr>")]
385 #[with_option(allow_alter_on_fly, iceberg_engine)]
386 pub snapshot_expiration_max_age_millis: Option<i64>,
387
388 #[serde(rename = "snapshot_expiration_retain_last", default)]
390 #[serde_as(as = "Option<DisplayFromStr>")]
391 #[with_option(allow_alter_on_fly, iceberg_engine)]
392 pub snapshot_expiration_retain_last: Option<i32>,
393
394 #[serde(
395 rename = "snapshot_expiration_clear_expired_files",
396 default = "default_true",
397 deserialize_with = "deserialize_bool_from_string"
398 )]
399 #[with_option(allow_alter_on_fly, iceberg_engine)]
400 pub snapshot_expiration_clear_expired_files: bool,
401
402 #[serde(
403 rename = "snapshot_expiration_clear_expired_meta_data",
404 default = "default_true",
405 deserialize_with = "deserialize_bool_from_string"
406 )]
407 #[with_option(allow_alter_on_fly, iceberg_engine)]
408 pub snapshot_expiration_clear_expired_meta_data: bool,
409
410 #[serde(
412 rename = "enable_manifest_rewrite",
413 default,
414 deserialize_with = "deserialize_bool_from_string"
415 )]
416 #[with_option(allow_alter_on_fly, iceberg_engine)]
417 pub enable_manifest_rewrite: bool,
418
419 #[serde(rename = "manifest_rewrite_target_size_bytes", default)]
421 #[serde_as(as = "Option<DisplayFromStr>")]
422 #[with_option(allow_alter_on_fly, iceberg_engine)]
423 pub manifest_rewrite_target_size_bytes: Option<u64>,
424
425 #[serde(rename = "manifest_rewrite_min_count_to_merge", default)]
427 #[serde_as(as = "Option<DisplayFromStr>")]
428 #[with_option(allow_alter_on_fly, iceberg_engine)]
429 pub manifest_rewrite_min_count_to_merge: Option<usize>,
430
431 #[serde(rename = "compaction.max_snapshots_num", default)]
435 #[serde_as(as = "Option<DisplayFromStr>")]
436 #[with_option(allow_alter_on_fly, iceberg_engine)]
437 pub max_snapshots_num_before_compaction: Option<usize>,
438
439 #[serde(rename = "compaction.small_files_threshold_mb", default)]
440 #[serde_as(as = "Option<DisplayFromStr>")]
441 #[with_option(allow_alter_on_fly, iceberg_engine)]
442 pub small_files_threshold_mb: Option<u64>,
443
444 #[serde(rename = "compaction.delete_files_count_threshold", default)]
445 #[serde_as(as = "Option<DisplayFromStr>")]
446 #[with_option(allow_alter_on_fly, iceberg_engine)]
447 pub delete_files_count_threshold: Option<usize>,
448
449 #[serde(rename = "compaction.trigger_snapshot_count", default)]
450 #[serde_as(as = "Option<DisplayFromStr>")]
451 #[with_option(allow_alter_on_fly, iceberg_engine)]
452 pub trigger_snapshot_count: Option<usize>,
453
454 #[serde(rename = "compaction.target_file_size_mb", default)]
455 #[serde_as(as = "Option<DisplayFromStr>")]
456 #[with_option(allow_alter_on_fly, iceberg_engine)]
457 pub target_file_size_mb: Option<u64>,
458
459 #[serde(rename = "compaction.type", default)]
462 #[with_option(allow_alter_on_fly, iceberg_engine)]
463 pub compaction_type: Option<CompactionType>,
464
465 #[serde(rename = "compaction.write_parquet_compression", default)]
469 #[with_option(allow_alter_on_fly, iceberg_engine)]
470 pub write_parquet_compression: Option<String>,
471
472 #[serde(rename = "compaction.write_parquet_max_row_group_rows", default)]
475 #[serde_as(as = "Option<DisplayFromStr>")]
476 #[with_option(allow_alter_on_fly, iceberg_engine)]
477 pub write_parquet_max_row_group_rows: Option<usize>,
478
479 #[serde(rename = "compaction.write_parquet_max_row_group_bytes", default)]
482 #[serde_as(as = "Option<DisplayFromStr>")]
483 #[with_option(allow_alter_on_fly, iceberg_engine)]
484 pub write_parquet_max_row_group_bytes: Option<usize>,
485
486 #[serde(
490 rename = "enable_pk_index",
491 default,
492 deserialize_with = "deserialize_bool_from_string"
493 )]
494 #[with_option(iceberg_engine)]
495 pub enable_pk_index: bool,
496
497 #[serde(flatten)]
498 pub unknown_fields: std::collections::HashMap<String, String>,
499}
500
501crate::impl_sink_unknown_fields!(IcebergConfig);
502
503impl EnforceSecret for IcebergConfig {
504 fn enforce_secret<'a>(
505 prop_iter: impl Iterator<Item = &'a str>,
506 ) -> crate::error::ConnectorResult<()> {
507 for prop in prop_iter {
508 IcebergCommon::enforce_one(prop)?;
509 }
510 Ok(())
511 }
512
513 fn enforce_one(prop: &str) -> crate::error::ConnectorResult<()> {
514 IcebergCommon::enforce_one(prop)
515 }
516}
517
518impl IcebergConfig {
519 pub fn validate_append_only_write_mode(
522 sink_type: &str,
523 write_mode: IcebergWriteMode,
524 ) -> Result<()> {
525 if sink_type == SINK_TYPE_APPEND_ONLY && write_mode == IcebergWriteMode::CopyOnWrite {
526 return Err(SinkError::Config(anyhow!(
527 "'copy-on-write' mode is not supported for append-only iceberg sink. \
528 Please use 'merge-on-read' instead, which is strictly better for append-only workloads."
529 )));
530 }
531 Ok(())
532 }
533
534 pub(crate) fn validate_enable_pk_index(&self) -> Result<()> {
535 if !self.enable_pk_index {
536 return Ok(());
537 }
538
539 if self.r#type != SINK_TYPE_UPSERT {
540 return Err(SinkError::Config(anyhow!(
541 "`enable_pk_index` is only supported for upsert iceberg sink"
542 )));
543 }
544
545 if self.write_mode != IcebergWriteMode::MergeOnRead {
546 return Err(SinkError::Config(anyhow!(
547 "`enable_pk_index` is only supported for upsert iceberg sink with merge-on-read mode"
548 )));
549 }
550
551 if self.format_version < FormatVersion::V2 {
552 return Err(SinkError::Config(anyhow!(
553 "`enable_pk_index` is only supported for upsert iceberg sink with format version >= 2"
554 )));
555 }
556
557 if self.force_append_only {
558 return Err(SinkError::Config(anyhow!(
559 "`enable_pk_index` cannot be true when `force_append_only` is true"
560 )));
561 }
562
563 Ok(())
564 }
565
566 pub fn from_btreemap(values: BTreeMap<String, String>) -> Result<Self> {
567 let mut config =
568 serde_json::from_value::<IcebergConfig>(serde_json::to_value(&values).unwrap())
569 .map_err(|e| SinkError::Config(anyhow!(e)))?;
570
571 if config.enable_compaction && !values.contains_key(COMPACTION_MAX_SNAPSHOTS_NUM) {
572 config.max_snapshots_num_before_compaction = Some(DEFAULT_COMPACTION_MAX_SNAPSHOTS_NUM);
573 }
574
575 if config.r#type != SINK_TYPE_APPEND_ONLY && config.r#type != SINK_TYPE_UPSERT {
576 return Err(SinkError::Config(anyhow!(
577 "`{}` must be {}, or {}",
578 SINK_TYPE_OPTION,
579 SINK_TYPE_APPEND_ONLY,
580 SINK_TYPE_UPSERT
581 )));
582 }
583
584 if config.r#type == SINK_TYPE_UPSERT {
585 if let Some(primary_key) = &config.primary_key {
586 if primary_key.is_empty() {
587 return Err(SinkError::Config(anyhow!(
588 "`primary-key` must not be empty in {}",
589 SINK_TYPE_UPSERT
590 )));
591 }
592 } else if !config.enable_pk_index {
593 return Err(SinkError::Config(anyhow!(
598 "Must set `primary-key` in {}",
599 SINK_TYPE_UPSERT
600 )));
601 }
602 }
603
604 Self::validate_append_only_write_mode(&config.r#type, config.write_mode)?;
606 config.validate_enable_pk_index()?;
607 config.validate_manifest_rewrite_format(config.format_version)?;
608
609 config.java_catalog_props = iceberg_java_catalog_props_from_options(
611 values
612 .iter()
613 .map(|(key, value)| (key.as_str(), value.as_str())),
614 );
615 config
616 .unknown_fields
617 .retain(|key, _| key != "connector" && !key.starts_with("catalog."));
618
619 if config.commit_checkpoint_interval == 0 {
620 return Err(SinkError::Config(anyhow!(
621 "`commit-checkpoint-interval` must be greater than 0"
622 )));
623 }
624
625 if config.compaction_interval_sec == Some(0) {
626 return Err(SinkError::Config(anyhow!(
627 "`compaction_interval_sec` must be greater than 0"
628 )));
629 }
630
631 if config.trigger_snapshot_count == Some(0) {
632 return Err(SinkError::Config(anyhow!(
633 "`compaction.trigger_snapshot_count` must be greater than 0"
634 )));
635 }
636
637 if config.max_snapshots_num_before_compaction == Some(0) {
638 return Err(SinkError::Config(anyhow!(
639 "`compaction.max_snapshots_num` must be greater than 0"
640 )));
641 }
642
643 if config.small_files_threshold_mb == Some(0) {
644 return Err(SinkError::Config(anyhow!(
645 "`compaction.small_files_threshold_mb` must be greater than 0"
646 )));
647 }
648
649 if config.delete_files_count_threshold == Some(0) {
650 return Err(SinkError::Config(anyhow!(
651 "`compaction.delete_files_count_threshold` must be greater than 0"
652 )));
653 }
654
655 if config.target_file_size_mb == Some(0) {
656 return Err(SinkError::Config(anyhow!(
657 "`compaction.target_file_size_mb` must be greater than 0"
658 )));
659 }
660
661 if config.write_parquet_max_row_group_rows == Some(0) {
662 return Err(SinkError::Config(anyhow!(
663 "`compaction.write_parquet_max_row_group_rows` must be greater than 0"
664 )));
665 }
666
667 if config.write_parquet_max_row_group_bytes == Some(0) {
668 return Err(SinkError::Config(anyhow!(
669 "`compaction.write_parquet_max_row_group_bytes` must be greater than 0"
670 )));
671 }
672
673 if config.manifest_rewrite_target_size_bytes == Some(0) {
674 return Err(SinkError::Config(anyhow!(
675 "`manifest_rewrite_target_size_bytes` must be greater than 0"
676 )));
677 }
678
679 if config.manifest_rewrite_min_count_to_merge == Some(0) {
680 return Err(SinkError::Config(anyhow!(
681 "`manifest_rewrite_min_count_to_merge` must be greater than 0"
682 )));
683 }
684
685 config
687 .table
688 .validate()
689 .map_err(|e| SinkError::Config(anyhow!(e)))?;
690
691 if config.write_parquet_max_row_group_rows.is_some() {
692 tracing::warn!(
693 "`compaction.write_parquet_max_row_group_rows` is deprecated and ignored; use `compaction.write_parquet_max_row_group_bytes` instead"
694 );
695 }
696
697 Ok(config)
698 }
699
700 pub fn catalog_type(&self) -> &str {
701 self.common.catalog_type()
702 }
703
704 pub fn catalog_kind(&self) -> Result<IcebergCatalogKind> {
705 self.common
706 .resolve_catalog_kind()
707 .map_err(|err| SinkError::Config(anyhow!(err)))
708 }
709
710 fn resolved_catalog_config(&self) -> Result<ResolvedIcebergCatalogConfig<'_>> {
711 self.common
712 .resolve_catalog_config(self.java_catalog_props.clone())
713 .map_err(|err| SinkError::Config(anyhow!(err)))
714 }
715
716 pub async fn load_table(&self) -> Result<Table> {
717 #[cfg(any(test, madsim))]
718 if self.catalog_type() == "mock_v3" {
719 let catalog =
720 crate::sink::iceberg::mock_v3_catalog_registry::get().ok_or_else(|| {
721 SinkError::Config(anyhow!(
722 "mock_v3 catalog_type set but no mock catalog registered"
723 ))
724 })?;
725 let table_id = self
726 .table
727 .to_table_ident()
728 .map_err(|e| SinkError::Config(anyhow!(e).context("Unable to parse table name")))?;
729 let table = catalog
730 .load_table(&table_id)
731 .await
732 .map_err(|e| SinkError::Config(anyhow!(e).context("Failed to load mock table")))?;
733 return Ok(table);
734 }
735 self.resolved_catalog_config()?
736 .load_table(&self.table)
737 .await
738 .map_err(Into::into)
739 }
740
741 pub async fn create_catalog(&self) -> Result<Arc<dyn Catalog>> {
742 #[cfg(any(test, madsim))]
743 if self.catalog_type() == "mock_v3" {
744 return Ok(
745 crate::sink::iceberg::mock_v3_catalog_registry::get().ok_or_else(|| {
746 anyhow::anyhow!("mock_v3 catalog_type set but no mock catalog registered")
747 })?,
748 );
749 }
750 self.resolved_catalog_config()?
751 .create_catalog()
752 .await
753 .map_err(Into::into)
754 }
755
756 pub fn full_table_name(&self) -> Result<TableIdent> {
757 self.table.to_table_ident().map_err(Into::into)
758 }
759
760 pub fn catalog_name(&self) -> String {
761 self.common.catalog_name()
762 }
763
764 pub fn table_format_version(&self) -> FormatVersion {
765 self.format_version
766 }
767
768 pub fn validate_manifest_rewrite_format(&self, format_version: FormatVersion) -> Result<()> {
769 if self.enable_manifest_rewrite && format_version >= FormatVersion::V3 {
770 return Err(SinkError::Config(anyhow!(
771 "`enable_manifest_rewrite` is not supported for Iceberg format version 3 because rewrite manifests cannot preserve row lineage"
772 )));
773 }
774 Ok(())
775 }
776
777 pub fn compaction_interval_sec(&self) -> u64 {
778 self.compaction_interval_sec.unwrap_or(3600)
780 }
781
782 pub fn snapshot_expiration_timestamp_ms(&self, current_time_ms: i64) -> Option<i64> {
785 self.snapshot_expiration_max_age_millis
786 .map(|max_age_millis| current_time_ms - max_age_millis)
787 }
788
789 pub fn manifest_rewrite_target_size_bytes(&self) -> u64 {
790 self.manifest_rewrite_target_size_bytes
791 .unwrap_or(MANIFEST_TARGET_SIZE_BYTES_DEFAULT as u64)
792 }
793
794 pub fn manifest_rewrite_min_count_to_merge(&self) -> usize {
795 self.manifest_rewrite_min_count_to_merge
796 .unwrap_or(MANIFEST_MIN_MERGE_COUNT_DEFAULT as usize)
797 }
798
799 pub fn trigger_snapshot_count(&self) -> usize {
800 self.trigger_snapshot_count.unwrap_or(usize::MAX)
801 }
802
803 pub fn small_files_threshold_mb(&self) -> u64 {
804 self.small_files_threshold_mb.unwrap_or(64)
805 }
806
807 pub fn delete_files_count_threshold(&self) -> usize {
808 self.delete_files_count_threshold.unwrap_or(256)
809 }
810
811 pub fn target_file_size_mb(&self) -> u64 {
812 self.target_file_size_mb.unwrap_or(1024)
813 }
814
815 pub fn compaction_type(&self) -> CompactionType {
818 self.compaction_type.unwrap_or_default()
819 }
820
821 pub fn write_parquet_compression(&self) -> &str {
824 self.write_parquet_compression.as_deref().unwrap_or("zstd")
825 }
826
827 pub fn write_parquet_max_row_group_rows(&self) -> Option<usize> {
829 self.write_parquet_max_row_group_rows
830 }
831
832 pub fn write_parquet_max_row_group_bytes(&self) -> Option<usize> {
834 self.write_parquet_max_row_group_bytes
835 .or(Some(ICEBERG_DEFAULT_WRITE_PARQUET_MAX_ROW_GROUP_BYTES))
836 }
837
838 pub fn get_parquet_compression(&self) -> Compression {
841 parse_parquet_compression(self.write_parquet_compression())
842 }
843}
844
845pub fn parse_parquet_compression(codec: &str) -> Compression {
847 match codec.to_lowercase().as_str() {
848 "uncompressed" => Compression::UNCOMPRESSED,
849 "snappy" => Compression::SNAPPY,
850 "gzip" => Compression::GZIP(Default::default()),
851 "lzo" => Compression::LZO,
852 "brotli" => Compression::BROTLI(Default::default()),
853 "lz4" => Compression::LZ4,
854 "zstd" => Compression::ZSTD(Default::default()),
855 _ => {
856 tracing::warn!(
857 "Unknown compression codec '{}', falling back to SNAPPY",
858 codec
859 );
860 Compression::SNAPPY
861 }
862 }
863}
864
865pub fn commit_branch(sink_type: &str, write_mode: IcebergWriteMode) -> String {
868 if should_enable_iceberg_cow(sink_type, write_mode) {
869 ICEBERG_COW_BRANCH.to_owned()
870 } else {
871 MAIN_BRANCH.to_owned()
872 }
873}
874
875pub fn should_enable_iceberg_cow(sink_type: &str, write_mode: IcebergWriteMode) -> bool {
876 sink_type == SINK_TYPE_UPSERT && write_mode == IcebergWriteMode::CopyOnWrite
877}
878
879impl crate::with_options::WithOptions for IcebergWriteMode {}
880
881impl crate::with_options::WithOptions for FormatVersion {}
882
883impl crate::with_options::WithOptions for CompactionType {}