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