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