Skip to main content

risingwave_connector/sink/iceberg/
commit_retry.rs

1// Copyright 2026 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Shared iceberg commit-with-retry primitive used by both the V1/V2 sink
16//! committer and the V3 sink coordinator worker. Wraps the standard pattern
17//! of "reload the table, build an action against the freshly-loaded snapshot,
18//! commit, retry on transient errors only".
19
20use 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
78/// Distinguishes retriable from non-retriable errors inside [`run_with_retry`].
79pub enum CommitError {
80    /// `reload_table` failed (table not found, schema mismatch, partition or requested format
81    /// evolution). Non-retriable — the call site's invariants no longer hold.
82    ReloadTable(anyhow::Error),
83    /// `Transaction::commit` (or its `apply`) failed. Retriable — likely a
84    /// commit conflict or transient network error.
85    Commit(anyhow::Error),
86}
87
88/// Reload the iceberg table from the catalog and assert that its current
89/// `schema_id` and `default_partition_spec_id` still match the values the
90/// caller computed against. Schema, partition, or requested format evolution mid-commit is
91/// surfaced as a non-retriable error by the call sites.
92pub 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
139/// Run a commit-action against the given iceberg table with retry.
140/// 1. Calls `reload_table` before each commit attempt to get the latest metadata
141/// 2. If `reload_table` fails (table not exists/schema/partition/requested format mismatch), stops retrying immediately
142/// 3. If commit fails, retries with backoff up to `retry_num` times.
143///
144/// Strategy: exponential backoff 10ms→60s with jitter, up to `retry_num` retries.
145pub 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
173/// Same as [`run_with_retry`], but when `initial_table` is set the first attempt runs against it
174/// (after the same metadata validation) instead of reloading it from the catalog. Use this when
175/// the caller loaded the table right before the commit: a stale table only costs a commit
176/// conflict, and every retry reloads. `initial_table` must be the table named by `table_ident`.
177pub 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}