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