1use std::future::Future;
21use std::sync::Arc;
22use std::time::Duration;
23
24use anyhow::{Result, anyhow, bail};
25use iceberg::spec::FormatVersion;
26use iceberg::table::Table;
27use iceberg::{Catalog, TableIdent};
28use risingwave_common::util::retry::exponential_backoff;
29use thiserror_ext::AsReport;
30use tokio_retry::RetryIf;
31use tokio_retry::strategy::jitter;
32
33#[derive(Clone, Debug)]
34pub struct CommitRetryLogContext {
35 pub iceberg_component: &'static str,
36 pub iceberg_operation: &'static str,
37 pub table: String,
38 pub branch: String,
39 pub sink_id: Option<String>,
40 pub epoch: Option<u64>,
41 pub snapshot_id: Option<i64>,
42}
43
44impl CommitRetryLogContext {
45 pub fn new(
46 iceberg_component: &'static str,
47 iceberg_operation: &'static str,
48 table: impl Into<String>,
49 branch: impl Into<String>,
50 ) -> Self {
51 Self {
52 iceberg_component,
53 iceberg_operation,
54 table: table.into(),
55 branch: branch.into(),
56 sink_id: None,
57 epoch: None,
58 snapshot_id: None,
59 }
60 }
61
62 pub fn with_sink_id(mut self, sink_id: impl ToString) -> Self {
63 self.sink_id = Some(sink_id.to_string());
64 self
65 }
66
67 pub fn with_epoch(mut self, epoch: u64) -> Self {
68 self.epoch = Some(epoch);
69 self
70 }
71
72 pub fn with_snapshot_id(mut self, snapshot_id: i64) -> Self {
73 self.snapshot_id = Some(snapshot_id);
74 self
75 }
76}
77
78pub enum CommitError {
80 ReloadTable(anyhow::Error),
83 Commit(anyhow::Error),
86}
87
88pub async fn reload_table(
93 catalog: &dyn Catalog,
94 table_ident: &TableIdent,
95 schema_id: i32,
96 partition_spec_id: i32,
97 format_version: Option<FormatVersion>,
98) -> Result<Table> {
99 let table = catalog
100 .load_table(table_ident)
101 .await
102 .map_err(|e| anyhow!(e).context("reload iceberg table"))?;
103 validate_table_metadata(&table, schema_id, partition_spec_id, format_version)?;
104 Ok(table)
105}
106
107fn validate_table_metadata(
108 table: &Table,
109 schema_id: i32,
110 partition_spec_id: i32,
111 format_version: Option<FormatVersion>,
112) -> Result<()> {
113 if table.metadata().current_schema_id() != schema_id {
114 bail!(
115 "iceberg sink: schema evolution not supported; expect schema id {}, got {}",
116 schema_id,
117 table.metadata().current_schema_id(),
118 );
119 }
120 if table.metadata().default_partition_spec_id() != partition_spec_id {
121 bail!(
122 "iceberg sink: partition evolution not supported; expect partition spec id {}, got {}",
123 partition_spec_id,
124 table.metadata().default_partition_spec_id(),
125 );
126 }
127 if let Some(format_version) = format_version
128 && table.metadata().format_version() != format_version
129 {
130 bail!(
131 "iceberg sink: format evolution not supported; expect format version {}, got {}",
132 format_version,
133 table.metadata().format_version(),
134 );
135 }
136 Ok(())
137}
138
139pub async fn run_with_retry<F, Fut, Out>(
146 catalog: Arc<dyn Catalog>,
147 table_ident: TableIdent,
148 schema_id: i32,
149 partition_spec_id: i32,
150 format_version: Option<FormatVersion>,
151 retry_num: usize,
152 log_context: CommitRetryLogContext,
153 commit_action: F,
154) -> Result<Out>
155where
156 F: Fn(Table) -> Fut + Send + Sync,
157 Fut: Future<Output = Result<Out, CommitError>> + Send,
158{
159 run_with_retry_from_table(
160 None,
161 catalog,
162 table_ident,
163 schema_id,
164 partition_spec_id,
165 format_version,
166 retry_num,
167 log_context,
168 commit_action,
169 )
170 .await
171}
172
173pub async fn run_with_retry_from_table<F, Fut, Out>(
178 initial_table: Option<Table>,
179 catalog: Arc<dyn Catalog>,
180 table_ident: TableIdent,
181 schema_id: i32,
182 partition_spec_id: i32,
183 format_version: Option<FormatVersion>,
184 retry_num: usize,
185 log_context: CommitRetryLogContext,
186 commit_action: F,
187) -> Result<Out>
188where
189 F: Fn(Table) -> Fut + Send + Sync,
190 Fut: Future<Output = Result<Out, CommitError>> + Send,
191{
192 if let Some(table) = &initial_table
193 && table.identifier() != &table_ident
194 {
195 bail!(
196 "iceberg commit: initial table {} does not match commit target {}",
197 table.identifier(),
198 table_ident
199 );
200 }
201 let retry_strategy =
202 exponential_backoff(Duration::from_millis(10), 10, Duration::from_secs(60))
203 .map(jitter)
204 .take(retry_num);
205 let mut initial_table = initial_table;
206
207 RetryIf::spawn(
208 retry_strategy,
209 || {
210 let catalog = catalog.clone();
211 let table_ident = table_ident.clone();
212 let commit_action = &commit_action;
213 let initial_table = initial_table.take();
214 async move {
215 let table = match initial_table {
216 Some(table) => {
217 validate_table_metadata(
218 &table,
219 schema_id,
220 partition_spec_id,
221 format_version,
222 )
223 .map_err(CommitError::ReloadTable)?;
224 table
225 }
226 None => reload_table(
227 catalog.as_ref(),
228 &table_ident,
229 schema_id,
230 partition_spec_id,
231 format_version,
232 )
233 .await
234 .map_err(CommitError::ReloadTable)?,
235 };
236 commit_action(table).await
237 }
238 },
239 |err: &CommitError| match err {
240 CommitError::Commit(e) => {
241 tracing::warn!(
242 iceberg_component = log_context.iceberg_component,
243 iceberg_operation = log_context.iceberg_operation,
244 sink_id = log_context.sink_id.as_deref().unwrap_or("unknown"),
245 epoch = ?log_context.epoch,
246 snapshot_id = ?log_context.snapshot_id,
247 table = %log_context.table,
248 branch = %log_context.branch,
249 schema_id,
250 partition_spec_id,
251 error = %e.as_report(),
252 "iceberg_commit_retryable_error",
253 );
254 true
255 }
256 CommitError::ReloadTable(e) => {
257 tracing::error!(
258 iceberg_component = log_context.iceberg_component,
259 iceberg_operation = log_context.iceberg_operation,
260 sink_id = log_context.sink_id.as_deref().unwrap_or("unknown"),
261 epoch = ?log_context.epoch,
262 snapshot_id = ?log_context.snapshot_id,
263 table = %log_context.table,
264 branch = %log_context.branch,
265 schema_id,
266 partition_spec_id,
267 error = %e.as_report(),
268 "iceberg_commit_reload_table_non_retryable_error",
269 );
270 false
271 }
272 },
273 )
274 .await
275 .map_err(|e| match e {
276 CommitError::ReloadTable(e) | CommitError::Commit(e) => e,
277 })
278}
279
280#[cfg(all(test, not(madsim)))]
281mod tests {
282 use std::collections::HashMap;
283 use std::sync::atomic::{AtomicUsize, Ordering};
284
285 use async_trait::async_trait;
286 use iceberg::io::FileIO;
287 use iceberg::spec::{
288 NestedField, PrimitiveType, Schema, SortOrder, TableMetadataBuilder, Type,
289 UnboundPartitionSpec,
290 };
291 use iceberg::{Namespace, NamespaceIdent, Runtime, TableCommit, TableCreation};
292
293 use super::*;
294
295 fn test_table() -> Table {
296 let schema = Schema::builder()
297 .with_schema_id(0)
298 .with_fields(vec![
299 NestedField::required(1, "id", Type::Primitive(PrimitiveType::Long)).into(),
300 ])
301 .build()
302 .unwrap();
303 let metadata = TableMetadataBuilder::new(
304 schema,
305 UnboundPartitionSpec::builder().build(),
306 SortOrder::unsorted_order(),
307 "memory://commit-retry".to_owned(),
308 FormatVersion::V2,
309 HashMap::new(),
310 )
311 .unwrap()
312 .build()
313 .unwrap()
314 .metadata;
315 Table::builder()
316 .identifier(TableIdent::new(
317 NamespaceIdent::new("db".to_owned()),
318 "table".to_owned(),
319 ))
320 .file_io(FileIO::new_with_fs())
321 .runtime(Runtime::try_current().unwrap())
322 .metadata(metadata)
323 .build()
324 .unwrap()
325 }
326
327 #[derive(Debug)]
328 struct CountingCatalog {
329 table: Table,
330 loads: AtomicUsize,
331 }
332
333 #[async_trait]
334 impl Catalog for CountingCatalog {
335 async fn load_table(&self, _table: &TableIdent) -> iceberg::Result<Table> {
336 self.loads.fetch_add(1, Ordering::SeqCst);
337 Ok(self.table.clone())
338 }
339
340 async fn list_namespaces(
341 &self,
342 _parent: Option<&NamespaceIdent>,
343 ) -> iceberg::Result<Vec<NamespaceIdent>> {
344 unreachable!()
345 }
346
347 async fn create_namespace(
348 &self,
349 _namespace: &NamespaceIdent,
350 _properties: HashMap<String, String>,
351 ) -> iceberg::Result<Namespace> {
352 unreachable!()
353 }
354
355 async fn get_namespace(&self, _namespace: &NamespaceIdent) -> iceberg::Result<Namespace> {
356 unreachable!()
357 }
358
359 async fn namespace_exists(&self, _namespace: &NamespaceIdent) -> iceberg::Result<bool> {
360 unreachable!()
361 }
362
363 async fn update_namespace(
364 &self,
365 _namespace: &NamespaceIdent,
366 _properties: HashMap<String, String>,
367 ) -> iceberg::Result<()> {
368 unreachable!()
369 }
370
371 async fn drop_namespace(&self, _namespace: &NamespaceIdent) -> iceberg::Result<()> {
372 unreachable!()
373 }
374
375 async fn list_tables(
376 &self,
377 _namespace: &NamespaceIdent,
378 ) -> iceberg::Result<Vec<TableIdent>> {
379 unreachable!()
380 }
381
382 async fn create_table(
383 &self,
384 _namespace: &NamespaceIdent,
385 _creation: TableCreation,
386 ) -> iceberg::Result<Table> {
387 unreachable!()
388 }
389
390 async fn drop_table(&self, _table: &TableIdent) -> iceberg::Result<()> {
391 unreachable!()
392 }
393
394 async fn purge_table(&self, _table: &TableIdent) -> iceberg::Result<()> {
395 unreachable!()
396 }
397
398 async fn table_exists(&self, _table: &TableIdent) -> iceberg::Result<bool> {
399 unreachable!()
400 }
401
402 async fn rename_table(&self, _src: &TableIdent, _dest: &TableIdent) -> iceberg::Result<()> {
403 unreachable!()
404 }
405
406 async fn register_table(
407 &self,
408 _table: &TableIdent,
409 _metadata_location: String,
410 ) -> iceberg::Result<Table> {
411 unreachable!()
412 }
413
414 async fn update_table(&self, _commit: TableCommit) -> iceberg::Result<Table> {
415 unreachable!()
416 }
417 }
418
419 fn log_context() -> CommitRetryLogContext {
420 CommitRetryLogContext::new("test", "commit", "db.table", "main")
421 }
422
423 #[tokio::test]
424 async fn first_attempt_uses_initial_table_and_retries_reload() {
425 let table = test_table();
426 let table_ident = table.identifier().clone();
427 let catalog = Arc::new(CountingCatalog {
428 table: table.clone(),
429 loads: AtomicUsize::new(0),
430 });
431 let attempts = AtomicUsize::new(0);
432
433 run_with_retry_from_table(
434 Some(table),
435 catalog.clone(),
436 table_ident,
437 0,
438 0,
439 Some(FormatVersion::V2),
440 3,
441 log_context(),
442 |_table| {
443 let attempt = attempts.fetch_add(1, Ordering::SeqCst);
444 async move {
445 if attempt == 0 {
446 Err(CommitError::Commit(anyhow!("injected conflict")))
447 } else {
448 Ok(())
449 }
450 }
451 },
452 )
453 .await
454 .unwrap();
455
456 assert_eq!(attempts.load(Ordering::SeqCst), 2);
457 assert_eq!(
458 catalog.loads.load(Ordering::SeqCst),
459 1,
460 "only the retry reloads the table"
461 );
462 }
463
464 #[tokio::test]
465 async fn initial_table_is_validated_without_retry() {
466 let table = test_table();
467 let table_ident = table.identifier().clone();
468 let catalog = Arc::new(CountingCatalog {
469 table: table.clone(),
470 loads: AtomicUsize::new(0),
471 });
472
473 let error = run_with_retry_from_table(
474 Some(table),
475 catalog.clone(),
476 table_ident,
477 7,
478 0,
479 None,
480 3,
481 log_context(),
482 |_table| async { Ok::<_, CommitError>(()) },
483 )
484 .await
485 .expect_err("schema mismatch must fail");
486
487 assert!(error.to_string().contains("schema evolution not supported"));
488 assert_eq!(catalog.loads.load(Ordering::SeqCst), 0);
489 }
490
491 #[tokio::test]
492 async fn initial_table_must_match_commit_target() {
493 let table = test_table();
494 let catalog = Arc::new(CountingCatalog {
495 table: table.clone(),
496 loads: AtomicUsize::new(0),
497 });
498 let attempts = AtomicUsize::new(0);
499
500 let error = run_with_retry_from_table(
501 Some(table),
502 catalog.clone(),
503 TableIdent::new(NamespaceIdent::new("db".to_owned()), "other".to_owned()),
504 0,
505 0,
506 None,
507 3,
508 log_context(),
509 |_table| {
510 attempts.fetch_add(1, Ordering::SeqCst);
511 async { Ok::<_, CommitError>(()) }
512 },
513 )
514 .await
515 .expect_err("a table with another identifier must be rejected");
516
517 assert!(error.to_string().contains("does not match commit target"));
518 assert_eq!(attempts.load(Ordering::SeqCst), 0);
519 assert_eq!(catalog.loads.load(Ordering::SeqCst), 0);
520 }
521}