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