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