Skip to main content

risingwave_connector/sink/iceberg/
commit.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
15use std::collections::HashMap;
16use std::sync::Arc;
17use std::time::Duration;
18
19use anyhow::{Context, anyhow};
20use async_trait::async_trait;
21use iceberg::Catalog;
22use iceberg::arrow::schema_to_arrow_schema;
23use iceberg::spec::{DataFile, Operation, SerializedDataFile, TableMetadata};
24use iceberg::table::Table;
25use iceberg::transaction::{AddColumn, ApplyTransactionAction, FastAppendAction, Transaction};
26use itertools::Itertools;
27use risingwave_common::array::arrow::arrow_schema_iceberg::{
28    DataType as ArrowDataType, Field as ArrowField, Fields as ArrowFields,
29};
30use risingwave_common::array::arrow::{IcebergArrowConvert, IcebergCreateTableArrowConvert};
31use risingwave_common::bail;
32use risingwave_common::catalog::{Field, RISINGWAVE_ICEBERG_COMMIT_EPOCH};
33use risingwave_common::error::IcebergError;
34use risingwave_pb::connector_service::SinkMetadata;
35use risingwave_pb::connector_service::sink_metadata::Metadata::Serialized;
36use risingwave_pb::connector_service::sink_metadata::SerializedMetadata;
37use risingwave_pb::stream_plan::PbSinkSchemaChange;
38use serde::{Deserialize, Serialize};
39use serde_json::from_value;
40use thiserror_ext::AsReport;
41use tokio::sync::mpsc::UnboundedSender;
42use tracing::warn;
43
44use super::commit_retry::{self, CommitError, CommitRetryLogContext};
45use super::{GLOBAL_SINK_METRICS, IcebergConfig, SinkError, commit_branch, resolve_partition_type};
46use crate::connector_common::{IcebergCommittedSnapshot, IcebergSinkCompactionUpdate};
47use crate::sink::catalog::SinkId;
48use crate::sink::{Result, SinglePhaseCommitCoordinator, SinkParam, TwoPhaseCommitCoordinator};
49
50const SCHEMA_ID: &str = "schema_id";
51const PARTITION_SPEC_ID: &str = "partition_spec_id";
52const DATA_FILES: &str = "data_files";
53
54#[derive(Default, Clone)]
55pub struct IcebergCommitResult {
56    pub schema_id: i32,
57    pub partition_spec_id: i32,
58    pub data_files: Vec<SerializedDataFile>,
59}
60
61impl IcebergCommitResult {
62    pub fn try_from(value: &SinkMetadata) -> Result<Self> {
63        let Some(Serialized(value)) = &value.metadata else {
64            bail!("Can't create iceberg sink write result from empty data!");
65        };
66
67        Self::try_from_serialized_bytes(&value.metadata)
68    }
69
70    pub fn try_from_serialized_bytes(value: &[u8]) -> Result<Self> {
71        let mut values = if let serde_json::Value::Object(value) =
72            serde_json::from_slice::<serde_json::Value>(value)
73                .context("Can't parse iceberg sink metadata")?
74        {
75            value
76        } else {
77            bail!("iceberg sink metadata should be an object");
78        };
79
80        let schema_id;
81        if let Some(serde_json::Value::Number(value)) = values.remove(SCHEMA_ID) {
82            schema_id = value
83                .as_u64()
84                .ok_or_else(|| anyhow!("schema_id should be a u64"))?;
85        } else {
86            bail!("iceberg sink metadata should have schema_id");
87        }
88
89        let partition_spec_id;
90        if let Some(serde_json::Value::Number(value)) = values.remove(PARTITION_SPEC_ID) {
91            partition_spec_id = value
92                .as_u64()
93                .ok_or_else(|| anyhow!("partition_spec_id should be a u64"))?;
94        } else {
95            bail!("iceberg sink metadata should have partition_spec_id");
96        }
97
98        let data_files: Vec<SerializedDataFile>;
99        if let serde_json::Value::Array(values) = values
100            .remove(DATA_FILES)
101            .ok_or_else(|| anyhow!("iceberg sink metadata should have data_files object"))?
102        {
103            data_files = values
104                .into_iter()
105                .map(from_value::<SerializedDataFile>)
106                .collect::<std::result::Result<_, _>>()
107                .unwrap();
108        } else {
109            bail!("iceberg sink metadata should have data_files object");
110        }
111
112        Ok(Self {
113            schema_id: schema_id as i32,
114            partition_spec_id: partition_spec_id as i32,
115            data_files,
116        })
117    }
118}
119
120impl<'a> TryFrom<&'a IcebergCommitResult> for SinkMetadata {
121    type Error = SinkError;
122
123    fn try_from(value: &'a IcebergCommitResult) -> std::result::Result<SinkMetadata, Self::Error> {
124        let bytes = <Vec<u8>>::try_from(value)?;
125        Ok(SinkMetadata {
126            metadata: Some(Serialized(SerializedMetadata { metadata: bytes })),
127        })
128    }
129}
130
131impl<'a> TryFrom<&'a IcebergCommitResult> for Vec<u8> {
132    type Error = SinkError;
133
134    fn try_from(value: &'a IcebergCommitResult) -> std::result::Result<Vec<u8>, Self::Error> {
135        let json_data_files = serde_json::Value::Array(
136            value
137                .data_files
138                .iter()
139                .map(serde_json::to_value)
140                .collect::<std::result::Result<Vec<serde_json::Value>, _>>()
141                .context("Can't serialize data files to json")?,
142        );
143        let json_value = serde_json::Value::Object(
144            vec![
145                (
146                    SCHEMA_ID.to_owned(),
147                    serde_json::Value::Number(value.schema_id.into()),
148                ),
149                (
150                    PARTITION_SPEC_ID.to_owned(),
151                    serde_json::Value::Number(value.partition_spec_id.into()),
152                ),
153                (DATA_FILES.to_owned(), json_data_files),
154            ]
155            .into_iter()
156            .collect(),
157        );
158        Ok(serde_json::to_vec(&json_value).context("Can't serialize iceberg sink metadata")?)
159    }
160}
161
162#[derive(Default, Clone, Serialize, Deserialize)]
163pub struct IcebergPositionDeleteCommitResult {
164    pub schema_id: i32,
165    pub partition_spec_id: i32,
166    pub delete_files: Vec<SerializedDataFile>,
167    pub overwrite_files: Vec<SerializedDataFile>,
168}
169
170impl<'a> TryFrom<&'a SinkMetadata> for IcebergPositionDeleteCommitResult {
171    type Error = SinkError;
172
173    fn try_from(value: &'a SinkMetadata) -> Result<Self> {
174        let Some(Serialized(value)) = &value.metadata else {
175            bail!("Can't create iceberg dv merger commit result from empty data!");
176        };
177        let value = serde_json::from_slice(&value.metadata)
178            .context("Can't deserialize iceberg dv merger commit result from metadata")?;
179        Ok(value)
180    }
181}
182
183impl<'a> TryFrom<&'a IcebergPositionDeleteCommitResult> for SinkMetadata {
184    type Error = SinkError;
185
186    fn try_from(value: &'a IcebergPositionDeleteCommitResult) -> Result<SinkMetadata> {
187        let bytes = serde_json::to_vec(value)
188            .context("Can't serialize iceberg dv merger commit result to metadata")?;
189        Ok(SinkMetadata {
190            metadata: Some(Serialized(SerializedMetadata { metadata: bytes })),
191        })
192    }
193}
194
195fn arrow_data_type_compatible(current: &ArrowDataType, expected: &ArrowDataType) -> bool {
196    use ArrowDataType::*;
197
198    match (current, expected) {
199        // RW Decimal has no precision/scale. Sink creation already accepts any
200        // table Decimal128 precision/scale, while expected schemas here are
201        // generated from RW columns using the same canonical mapping. Ignoring
202        // both values prevents false schema-state ambiguity and cannot mask an
203        // auto schema change, which only supports add/drop columns.
204        (Decimal128(_, _), Decimal128(_, _)) => true,
205        (Binary, LargeBinary) | (LargeBinary, Binary) => true,
206        (List(current_field), List(expected_field)) => {
207            arrow_data_type_compatible(current_field.data_type(), expected_field.data_type())
208        }
209        (Map(current_field, current_sorted), Map(expected_field, expected_sorted)) => {
210            current_sorted == expected_sorted
211                && arrow_data_type_compatible(current_field.data_type(), expected_field.data_type())
212        }
213        (Struct(current_fields), Struct(expected_fields)) => {
214            let expected_fields = expected_fields
215                .iter()
216                .map(|field| field.as_ref().clone())
217                .collect_vec();
218            schema_contains_same_fields(current_fields, &expected_fields)
219        }
220        _ => current == expected,
221    }
222}
223
224fn schema_contains_same_fields(current: &ArrowFields, expected: &[ArrowField]) -> bool {
225    if current.len() != expected.len() {
226        return false;
227    }
228
229    let mut unmatched_current = current.iter().collect_vec();
230    expected.iter().all(|expected_field| {
231        let Some(pos) = unmatched_current.iter().position(|current_field| {
232            current_field.name() == expected_field.name()
233                && arrow_data_type_compatible(current_field.data_type(), expected_field.data_type())
234        }) else {
235            return false;
236        };
237        unmatched_current.swap_remove(pos);
238        true
239    })
240}
241
242pub struct IcebergSinkCommitter {
243    pub(super) catalog: Arc<dyn Catalog>,
244    pub(super) table: Table,
245    pub last_commit_epoch: u64,
246    pub(crate) sink_id: SinkId,
247    pub(crate) config: IcebergConfig,
248    pub(crate) param: SinkParam,
249    pub(super) commit_retry_num: u32,
250    pub(crate) iceberg_compact_stat_sender: Option<UnboundedSender<IcebergSinkCompactionUpdate>>,
251}
252
253impl IcebergSinkCommitter {
254    fn latest_observed_snapshot(&self) -> Option<IcebergCommittedSnapshot> {
255        let branch = commit_branch(self.config.r#type.as_str(), self.config.write_mode);
256        IcebergCommittedSnapshot::from_branch_head(self.table.metadata(), &branch)
257    }
258
259    fn notify_iceberg_compaction_scheduler(&self, force_compaction: bool) {
260        let Some(iceberg_compact_stat_sender) = &self.iceberg_compact_stat_sender else {
261            return;
262        };
263        let branch = commit_branch(self.config.r#type.as_str(), self.config.write_mode);
264
265        let Some(observed_snapshot) = self.latest_observed_snapshot() else {
266            warn!(
267                iceberg_component = "sink_committer",
268                iceberg_operation = "notify_compaction",
269                sink_id = %self.sink_id,
270                sink_name = %self.param.sink_name,
271                table = %self.table.identifier(),
272                branch = %branch,
273                force_compaction,
274                "iceberg_sink_compaction_update_skipped",
275            );
276            return;
277        };
278
279        let observed_snapshot_id = observed_snapshot.snapshot_id;
280        let observed_snapshot_timestamp_ms = observed_snapshot.timestamp_ms;
281        let observed_snapshot_branch = observed_snapshot.branch.clone();
282
283        if iceberg_compact_stat_sender
284            .send(IcebergSinkCompactionUpdate {
285                sink_id: self.sink_id,
286                force_compaction,
287                has_new_data: true,
288                observed_snapshot,
289            })
290            .is_err()
291        {
292            warn!(
293                iceberg_component = "sink_committer",
294                iceberg_operation = "notify_compaction",
295                sink_id = %self.sink_id,
296                sink_name = %self.param.sink_name,
297                table = %self.table.identifier(),
298                force_compaction,
299                observed_snapshot_id,
300                observed_snapshot_timestamp_ms,
301                observed_snapshot_branch = %observed_snapshot_branch,
302                "iceberg_sink_compaction_update_send_failed",
303            );
304        }
305    }
306}
307
308#[async_trait]
309impl SinglePhaseCommitCoordinator for IcebergSinkCommitter {
310    async fn init(&mut self) -> Result<()> {
311        tracing::info!(
312            iceberg_component = "sink_committer",
313            iceberg_operation = "init",
314            sink_id = %self.param.sink_id,
315            sink_name = %self.param.sink_name,
316            table = %self.table.identifier(),
317            "iceberg_sink_committer_initialized",
318        );
319
320        Ok(())
321    }
322
323    async fn commit_data(&mut self, epoch: u64, metadata: Vec<SinkMetadata>) -> Result<()> {
324        tracing::debug!(
325            iceberg_component = "sink_committer",
326            iceberg_operation = "direct_commit",
327            sink_id = %self.sink_id,
328            sink_name = %self.param.sink_name,
329            table = %self.table.identifier(),
330            epoch,
331            metadata_count = metadata.len(),
332            "iceberg_sink_commit_started",
333        );
334
335        if metadata.is_empty() {
336            tracing::debug!(
337                iceberg_component = "sink_committer",
338                iceberg_operation = "direct_commit",
339                sink_id = %self.sink_id,
340                table = %self.table.identifier(),
341                epoch,
342                "iceberg_sink_commit_skipped_empty_metadata",
343            );
344            return Ok(());
345        }
346
347        // Commit data if present
348        if let Some((write_results, snapshot_id)) = self.pre_commit_inner(epoch, metadata)? {
349            self.commit_data_impl(epoch, write_results, snapshot_id)
350                .await?;
351        }
352
353        Ok(())
354    }
355
356    async fn commit_schema_change(
357        &mut self,
358        epoch: u64,
359        schema_change: PbSinkSchemaChange,
360    ) -> Result<()> {
361        tracing::info!(
362            iceberg_component = "sink_committer",
363            iceberg_operation = "schema_change",
364            sink_id = %self.sink_id,
365            sink_name = %self.param.sink_name,
366            table = %self.table.identifier(),
367            epoch,
368            schema_change = ?schema_change,
369            "iceberg_sink_schema_change_commit_started",
370        );
371        self.commit_schema_change_impl(schema_change).await?;
372        tracing::info!(
373            iceberg_component = "sink_committer",
374            iceberg_operation = "schema_change",
375            sink_id = %self.sink_id,
376            table = %self.table.identifier(),
377            epoch,
378            "iceberg_sink_schema_change_commit_succeeded",
379        );
380
381        Ok(())
382    }
383}
384
385#[async_trait]
386impl TwoPhaseCommitCoordinator for IcebergSinkCommitter {
387    async fn init(&mut self) -> Result<()> {
388        tracing::info!(
389            iceberg_component = "sink_committer",
390            iceberg_operation = "init",
391            sink_id = %self.param.sink_id,
392            sink_name = %self.param.sink_name,
393            table = %self.table.identifier(),
394            "iceberg_sink_committer_initialized",
395        );
396
397        Ok(())
398    }
399
400    async fn pre_commit(
401        &mut self,
402        epoch: u64,
403        metadata: Vec<SinkMetadata>,
404        _schema_change: Option<PbSinkSchemaChange>,
405    ) -> Result<Option<Vec<u8>>> {
406        tracing::debug!(
407            iceberg_component = "sink_committer",
408            iceberg_operation = "pre_commit",
409            sink_id = %self.sink_id,
410            sink_name = %self.param.sink_name,
411            table = %self.table.identifier(),
412            epoch,
413            metadata_count = metadata.len(),
414            "iceberg_sink_pre_commit_started",
415        );
416
417        let (write_results, snapshot_id) = match self.pre_commit_inner(epoch, metadata)? {
418            Some((write_results, snapshot_id)) => (write_results, snapshot_id),
419            None => {
420                tracing::debug!(
421                    iceberg_component = "sink_committer",
422                    iceberg_operation = "pre_commit",
423                    sink_id = %self.sink_id,
424                    table = %self.table.identifier(),
425                    epoch,
426                    "iceberg_sink_pre_commit_skipped_no_data",
427                );
428                return Ok(None);
429            }
430        };
431
432        let mut write_results_bytes = Vec::new();
433        for each_parallelism_write_result in write_results {
434            let each_parallelism_write_result_bytes =
435                <Vec<u8>>::try_from(&each_parallelism_write_result)?;
436            write_results_bytes.push(each_parallelism_write_result_bytes);
437        }
438
439        let snapshot_id_bytes: Vec<u8> = snapshot_id.to_le_bytes().to_vec();
440        write_results_bytes.push(snapshot_id_bytes);
441
442        let pre_commit_metadata_bytes: Vec<u8> = serialize_metadata(write_results_bytes);
443        tracing::debug!(
444            iceberg_component = "sink_committer",
445            iceberg_operation = "pre_commit",
446            sink_id = %self.sink_id,
447            table = %self.table.identifier(),
448            epoch,
449            snapshot_id,
450            pre_commit_metadata_bytes = pre_commit_metadata_bytes.len(),
451            "iceberg_sink_pre_commit_metadata_encoded",
452        );
453        Ok(Some(pre_commit_metadata_bytes))
454    }
455
456    async fn commit_data(&mut self, epoch: u64, commit_metadata: Vec<u8>) -> Result<()> {
457        tracing::debug!(
458            iceberg_component = "sink_committer",
459            iceberg_operation = "commit",
460            sink_id = %self.sink_id,
461            sink_name = %self.param.sink_name,
462            table = %self.table.identifier(),
463            epoch,
464            commit_metadata_bytes = commit_metadata.len(),
465            "iceberg_sink_commit_started",
466        );
467
468        if commit_metadata.is_empty() {
469            tracing::debug!(
470                iceberg_component = "sink_committer",
471                iceberg_operation = "commit",
472                sink_id = %self.sink_id,
473                table = %self.table.identifier(),
474                epoch,
475                "iceberg_sink_commit_skipped_empty_metadata",
476            );
477            return Ok(());
478        }
479
480        // Deserialize commit metadata
481        let mut payload = deserialize_metadata(commit_metadata);
482        if payload.is_empty() {
483            return Err(SinkError::Iceberg(anyhow!(
484                "Invalid commit metadata: empty payload"
485            )));
486        }
487
488        // Last element is snapshot_id
489        let snapshot_id_bytes = payload.pop().ok_or_else(|| {
490            SinkError::Iceberg(anyhow!("Invalid commit metadata: missing snapshot_id"))
491        })?;
492        let snapshot_id = i64::from_le_bytes(
493            snapshot_id_bytes
494                .try_into()
495                .map_err(|_| SinkError::Iceberg(anyhow!("Invalid snapshot id bytes")))?,
496        );
497
498        // Remaining elements are write_results
499        let write_results = payload
500            .into_iter()
501            .map(|p| IcebergCommitResult::try_from_serialized_bytes(&p))
502            .collect::<Result<Vec<_>>>()?;
503
504        let snapshot_committed = self.is_snapshot_id_in_iceberg(snapshot_id).await?;
505
506        if snapshot_committed {
507            tracing::info!(
508                iceberg_component = "sink_committer",
509                iceberg_operation = "commit",
510                sink_id = %self.sink_id,
511                sink_name = %self.param.sink_name,
512                table = %self.table.identifier(),
513                epoch,
514                snapshot_id,
515                "iceberg_sink_commit_skipped_snapshot_already_committed",
516            );
517            return Ok(());
518        }
519
520        self.commit_data_impl(epoch, write_results, snapshot_id)
521            .await
522    }
523
524    async fn commit_schema_change(
525        &mut self,
526        epoch: u64,
527        schema_change: PbSinkSchemaChange,
528    ) -> Result<()> {
529        let schema_updated = self.check_schema_change_applied(&schema_change)?;
530        if schema_updated {
531            tracing::info!(
532                iceberg_component = "sink_committer",
533                iceberg_operation = "schema_change",
534                sink_id = %self.sink_id,
535                table = %self.table.identifier(),
536                epoch,
537                "iceberg_sink_schema_change_skipped_already_applied",
538            );
539            return Ok(());
540        }
541
542        tracing::info!(
543            iceberg_component = "sink_committer",
544            iceberg_operation = "schema_change",
545            sink_id = %self.sink_id,
546            sink_name = %self.param.sink_name,
547            table = %self.table.identifier(),
548            epoch,
549            schema_change = ?schema_change,
550            "iceberg_sink_schema_change_commit_started",
551        );
552        self.commit_schema_change_impl(schema_change).await?;
553        tracing::info!(
554            iceberg_component = "sink_committer",
555            iceberg_operation = "schema_change",
556            sink_id = %self.sink_id,
557            table = %self.table.identifier(),
558            epoch,
559            "iceberg_sink_schema_change_commit_succeeded",
560        );
561
562        Ok(())
563    }
564
565    async fn abort(&mut self, epoch: u64, _commit_metadata: Vec<u8>) {
566        // TODO: Files that have been written but not committed should be deleted.
567        tracing::debug!(
568            iceberg_component = "sink_committer",
569            iceberg_operation = "abort",
570            sink_id = %self.sink_id,
571            table = %self.table.identifier(),
572            epoch,
573            "iceberg_sink_commit_abort_unimplemented",
574        );
575    }
576}
577
578/// Methods Required to Achieve Exactly Once Semantics
579impl IcebergSinkCommitter {
580    fn pre_commit_inner(
581        &mut self,
582        epoch: u64,
583        metadata: Vec<SinkMetadata>,
584    ) -> Result<Option<(Vec<IcebergCommitResult>, i64)>> {
585        let write_results: Vec<IcebergCommitResult> = metadata
586            .iter()
587            .map(IcebergCommitResult::try_from)
588            .collect::<Result<Vec<IcebergCommitResult>>>()?;
589        let data_file_count: usize = write_results.iter().map(|r| r.data_files.len()).sum();
590        tracing::debug!(
591            iceberg_component = "sink_committer",
592            iceberg_operation = "pre_commit",
593            sink_id = %self.sink_id,
594            table = %self.table.identifier(),
595            epoch,
596            writer_result_count = write_results.len(),
597            data_file_count,
598            "iceberg_sink_pre_commit_metadata_decoded",
599        );
600
601        // Skip if no data to commit
602        if write_results.is_empty() || write_results.iter().all(|r| r.data_files.is_empty()) {
603            return Ok(None);
604        }
605
606        let expect_schema_id = write_results[0].schema_id;
607        let expect_partition_spec_id = write_results[0].partition_spec_id;
608
609        // guarantee that all write results has same schema_id and partition_spec_id
610        if write_results
611            .iter()
612            .any(|r| r.schema_id != expect_schema_id)
613            || write_results
614                .iter()
615                .any(|r| r.partition_spec_id != expect_partition_spec_id)
616        {
617            return Err(SinkError::Iceberg(anyhow!(
618                "schema_id and partition_spec_id should be the same in all write results"
619            )));
620        }
621
622        let snapshot_id = FastAppendAction::generate_snapshot_id(&self.table);
623        tracing::debug!(
624            iceberg_component = "sink_committer",
625            iceberg_operation = "pre_commit",
626            sink_id = %self.sink_id,
627            table = %self.table.identifier(),
628            epoch,
629            snapshot_id,
630            schema_id = expect_schema_id,
631            partition_spec_id = expect_partition_spec_id,
632            data_file_count,
633            "iceberg_sink_pre_commit_snapshot_assigned",
634        );
635
636        Ok(Some((write_results, snapshot_id)))
637    }
638
639    async fn commit_data_impl(
640        &mut self,
641        epoch: u64,
642        write_results: Vec<IcebergCommitResult>,
643        snapshot_id: i64,
644    ) -> Result<()> {
645        // Empty write results should be handled before calling this function.
646        assert!(
647            !write_results.is_empty() && !write_results.iter().all(|r| r.data_files.is_empty())
648        );
649
650        // Check snapshot limit before proceeding with commit
651        self.wait_for_snapshot_limit().await?;
652
653        let expect_schema_id = write_results[0].schema_id;
654        let expect_partition_spec_id = write_results[0].partition_spec_id;
655        let data_file_count: usize = write_results.iter().map(|r| r.data_files.len()).sum();
656        let target_branch = commit_branch(self.config.r#type.as_str(), self.config.write_mode);
657        tracing::info!(
658            iceberg_component = "sink_committer",
659            iceberg_operation = "commit",
660            sink_id = %self.sink_id,
661            sink_name = %self.param.sink_name,
662            table = %self.table.identifier(),
663            branch = %target_branch,
664            epoch,
665            snapshot_id,
666            schema_id = expect_schema_id,
667            partition_spec_id = expect_partition_spec_id,
668            data_file_count,
669            retry_num = self.commit_retry_num,
670            "iceberg_sink_commit_applying",
671        );
672
673        // Load the latest table to avoid concurrent modification with the best effort.
674        self.table = commit_retry::reload_table(
675            self.catalog.as_ref(),
676            self.table.identifier(),
677            expect_schema_id,
678            expect_partition_spec_id,
679            None,
680        )
681        .await
682        .map_err(SinkError::Iceberg)?;
683
684        let Some(schema) = self.table.metadata().schema_by_id(expect_schema_id) else {
685            return Err(SinkError::Iceberg(anyhow!(
686                "Can't find schema by id {}",
687                expect_schema_id
688            )));
689        };
690        let partition_type = resolve_partition_type(&self.table, expect_partition_spec_id, schema)?;
691
692        let data_files = write_results
693            .into_iter()
694            .flat_map(|r| {
695                r.data_files.into_iter().map(|f| {
696                    f.try_into(expect_partition_spec_id, &partition_type, schema)
697                        .map_err(|err| SinkError::Iceberg(anyhow!(err)))
698                })
699            })
700            .collect::<Result<Vec<DataFile>>>()?;
701
702        // # TODO:
703        // This retry behavior should be revert and do in iceberg-rust when it supports retry(Track in: https://github.com/apache/iceberg-rust/issues/964)
704        // because retry logic involved reapply the commit metadata.
705        // For now, we just retry the commit operation.
706        let catalog = self.catalog.clone();
707        let sink_id = self.sink_id;
708        let table_ident = self.table.identifier().clone();
709        let table_name = table_ident.to_string();
710        let retry_log_context = CommitRetryLogContext::new(
711            "sink_committer",
712            "commit",
713            table_name.clone(),
714            target_branch.clone(),
715        )
716        .with_sink_id(sink_id)
717        .with_epoch(epoch)
718        .with_snapshot_id(snapshot_id);
719
720        let table = commit_retry::run_with_retry(
721            catalog.clone(),
722            table_ident.clone(),
723            expect_schema_id,
724            expect_partition_spec_id,
725            None,
726            self.commit_retry_num as usize,
727            retry_log_context,
728            |table| {
729                let table_name = table_name.clone();
730                let target_branch = target_branch.clone();
731                let data_files = data_files.clone();
732                let catalog = catalog.clone();
733                async move {
734                    let txn = Transaction::new(&table);
735                    let append_action = txn
736                        .fast_append()
737                        .set_snapshot_id(snapshot_id)
738                        .set_target_branch(target_branch.clone())
739                        .set_snapshot_properties(HashMap::from([(
740                            RISINGWAVE_ICEBERG_COMMIT_EPOCH.to_owned(),
741                            epoch.to_string(),
742                        )]))
743                        .add_data_files(data_files);
744
745                    let tx = append_action.apply(txn).map_err(|err| {
746                        let err: IcebergError = err.into();
747                        tracing::error!(
748                            iceberg_component = "sink_committer",
749                            iceberg_operation = "commit",
750                            sink_id = %sink_id,
751                            table = %table_name,
752                            epoch,
753                            snapshot_id,
754                            branch = %target_branch,
755                            data_file_count,
756                            error = %err.as_report(),
757                            "iceberg_sink_commit_fast_append_apply_failed",
758                        );
759                        CommitError::Commit(anyhow!(err).context("apply iceberg fast_append"))
760                    })?;
761
762                    let table = tx.commit(catalog.as_ref()).await.map_err(|err| {
763                        let err: IcebergError = err.into();
764                        tracing::error!(
765                            iceberg_component = "sink_committer",
766                            iceberg_operation = "commit",
767                            sink_id = %sink_id,
768                            table = %table_name,
769                            epoch,
770                            snapshot_id,
771                            branch = %target_branch,
772                            data_file_count,
773                            error = %err.as_report(),
774                            "iceberg_sink_commit_transaction_failed",
775                        );
776                        CommitError::Commit(anyhow!(err).context("commit iceberg transaction"))
777                    })?;
778                    Ok(table)
779                }
780            },
781        )
782        .await
783        .map_err(SinkError::Iceberg)?;
784        self.table = table;
785
786        let snapshot_num = self.table.metadata().snapshots().count();
787        let catalog_name = self.config.common.catalog_name();
788        let table_name = self.table.identifier().to_string();
789        let metrics_labels = [&self.param.sink_name, &catalog_name, &table_name];
790        GLOBAL_SINK_METRICS
791            .iceberg_snapshot_num
792            .with_guarded_label_values(&metrics_labels)
793            .set(snapshot_num as i64);
794
795        tracing::debug!(
796            iceberg_component = "sink_committer",
797            iceberg_operation = "commit",
798            sink_id = %self.sink_id,
799            sink_name = %self.param.sink_name,
800            table = %self.table.identifier(),
801            branch = %target_branch,
802            epoch,
803            snapshot_id,
804            snapshot_num,
805            data_file_count,
806            "iceberg_sink_commit_succeeded",
807        );
808
809        self.notify_iceberg_compaction_scheduler(false);
810
811        Ok(())
812    }
813
814    /// During pre-commit metadata, we record the `snapshot_id` corresponding to each batch of files.
815    /// Therefore, the logic for checking whether all files in this batch are present in Iceberg
816    /// has been changed to verifying if their corresponding `snapshot_id` exists in Iceberg.
817    async fn is_snapshot_id_in_iceberg(&self, snapshot_id: i64) -> Result<bool> {
818        let table = self
819            .catalog
820            .load_table(self.table.identifier())
821            .await
822            .map_err(|err| SinkError::Iceberg(anyhow!(err).context("reload iceberg table")))?;
823        if table.metadata().snapshot_by_id(snapshot_id).is_some() {
824            Ok(true)
825        } else {
826            Ok(false)
827        }
828    }
829
830    /// Check if the specified columns already exist in the iceberg table's current schema.
831    /// This is used to determine if schema change has already been applied.
832    fn check_schema_change_applied(&self, schema_change: &PbSinkSchemaChange) -> Result<bool> {
833        let current_schema = self.table.metadata().current_schema();
834        let current_arrow_schema = schema_to_arrow_schema(current_schema.as_ref())
835            .context("Failed to convert schema")
836            .map_err(SinkError::Iceberg)?;
837
838        let iceberg_arrow_convert = IcebergArrowConvert;
839
840        let schema_matches = |expected: &[ArrowField]| {
841            schema_contains_same_fields(current_arrow_schema.fields(), expected)
842        };
843
844        let original_arrow_fields: Vec<ArrowField> = schema_change
845            .original_schema
846            .iter()
847            .map(|pb_field| {
848                let field = Field::from(pb_field);
849                iceberg_arrow_convert
850                    .to_arrow_field(&field.name, &field.data_type)
851                    .context("Failed to convert field to arrow")
852                    .map_err(SinkError::Iceberg)
853            })
854            .collect::<Result<_>>()?;
855
856        // If current schema equals original_schema, then schema change is NOT applied.
857        if schema_matches(&original_arrow_fields) {
858            tracing::debug!(
859                "Current iceberg schema matches original_schema ({} columns); schema change not applied",
860                original_arrow_fields.len()
861            );
862            return Ok(false);
863        }
864
865        let expected_after_change = match schema_change.op.as_ref() {
866            Some(risingwave_pb::stream_plan::sink_schema_change::Op::AddColumns(
867                add_columns_op,
868            )) => {
869                let add_arrow_fields: Vec<ArrowField> = add_columns_op
870                    .fields
871                    .iter()
872                    .map(|pb_field| {
873                        let field = Field::from(pb_field);
874                        iceberg_arrow_convert
875                            .to_arrow_field(&field.name, &field.data_type)
876                            .context("Failed to convert field to arrow")
877                            .map_err(SinkError::Iceberg)
878                    })
879                    .collect::<Result<_>>()?;
880
881                let mut expected_after_change = original_arrow_fields;
882                expected_after_change.extend(add_arrow_fields);
883                expected_after_change
884            }
885            Some(risingwave_pb::stream_plan::sink_schema_change::Op::DropColumns(
886                drop_columns_op,
887            )) => original_arrow_fields
888                .into_iter()
889                .filter(|field| {
890                    !drop_columns_op
891                        .column_names
892                        .iter()
893                        .any(|name| name == field.name())
894                })
895                .collect_vec(),
896            _ => {
897                return Err(SinkError::Iceberg(anyhow!(
898                    "Unsupported sink schema change op in iceberg sink: {:?}",
899                    schema_change.op
900                )));
901            }
902        };
903
904        // If current schema equals the changed schema, then schema change is applied.
905        if schema_matches(&expected_after_change) {
906            tracing::debug!(
907                "Current iceberg schema matches changed schema ({} columns); schema change already applied",
908                expected_after_change.len()
909            );
910            return Ok(true);
911        }
912
913        Err(SinkError::Iceberg(anyhow!(
914            "Current iceberg schema does not match either original_schema ({} cols) or changed schema; cannot determine whether schema change is applied",
915            schema_change.original_schema.len()
916        )))
917    }
918
919    /// Commit schema changes (e.g., add columns) to the iceberg table.
920    /// This function uses Transaction API to atomically update the table schema
921    /// with optimistic locking to prevent concurrent conflicts.
922    async fn commit_schema_change_impl(&mut self, schema_change: PbSinkSchemaChange) -> Result<()> {
923        // Step 1: Build new fields to add
924        let iceberg_create_table_arrow_convert = IcebergCreateTableArrowConvert::default();
925        let mut new_fields = Vec::new();
926
927        let mut drop_column_names = Vec::new();
928        match schema_change.op.as_ref() {
929            Some(risingwave_pb::stream_plan::sink_schema_change::Op::AddColumns(
930                add_columns_op,
931            )) => {
932                let add_columns = add_columns_op.fields.iter().map(Field::from).collect_vec();
933                for field in &add_columns {
934                    // Convert RisingWave Field to Arrow Field using IcebergCreateTableArrowConvert
935                    let arrow_field = iceberg_create_table_arrow_convert
936                        .to_arrow_field(&field.name, &field.data_type)
937                        .with_context(|| {
938                            format!("Failed to convert field '{}' to arrow", field.name)
939                        })
940                        .map_err(SinkError::Iceberg)?;
941
942                    // Convert Arrow DataType to Iceberg Type
943                    let iceberg_type = iceberg::arrow::arrow_type_to_type(arrow_field.data_type())
944                        .map_err(|err| {
945                            SinkError::Iceberg(
946                                anyhow!(err)
947                                    .context("Failed to convert Arrow type to Iceberg type"),
948                            )
949                        })?;
950
951                    new_fields.push(AddColumn::optional(&field.name, iceberg_type));
952                    tracing::info!("Prepared field '{}' for schema change", field.name);
953                }
954            }
955            Some(risingwave_pb::stream_plan::sink_schema_change::Op::DropColumns(
956                drop_columns_op,
957            )) => {
958                drop_column_names = drop_columns_op.column_names.clone();
959            }
960            _ => {
961                return Err(SinkError::Iceberg(anyhow!(
962                    "Unsupported sink schema change op in iceberg sink: {:?}",
963                    schema_change.op
964                )));
965            }
966        }
967
968        // Step 2: Create Transaction with UpdateSchemaAction
969        tracing::info!(
970            "Committing schema change to catalog for table {}",
971            self.table.identifier()
972        );
973
974        let txn = Transaction::new(&self.table);
975        let action_fields_added = new_fields.len();
976        let mut action = txn.update_schema();
977        for field in new_fields {
978            action = action.add_column(field);
979        }
980        for column_name in &drop_column_names {
981            action = action.delete_column(column_name);
982        }
983
984        let updated_table = action
985            .apply(txn)
986            .context("Failed to apply schema update action")
987            .map_err(SinkError::Iceberg)?
988            .commit(self.catalog.as_ref())
989            .await
990            .context("Failed to commit table schema change")
991            .map_err(SinkError::Iceberg)?;
992
993        self.table = updated_table;
994
995        tracing::info!(
996            "Successfully committed schema change, added {} columns and dropped {} columns from iceberg table",
997            action_fields_added,
998            drop_column_names.len()
999        );
1000
1001        Ok(())
1002    }
1003
1004    /// Check the number of snapshots on the given branch lineage since the last rewrite operation.
1005    /// Returns the number of snapshots since the last rewrite.
1006    fn count_snapshots_since_rewrite_in_metadata(metadata: &TableMetadata, branch: &str) -> usize {
1007        // Start from the latest snapshot of the commit branch.
1008        let mut snapshot_id = metadata
1009            .snapshot_for_ref(branch)
1010            .map(|snapshot| snapshot.snapshot_id());
1011        let mut count = 0;
1012
1013        // Iterate through snapshots by parent lineage to find the last rewrite.
1014        while let Some(current_snapshot_id) = snapshot_id {
1015            let Some(snapshot) = metadata.snapshot_by_id(current_snapshot_id) else {
1016                break;
1017            };
1018
1019            // Check if this snapshot represents a rewrite operation.
1020            if snapshot.summary().operation == Operation::Replace {
1021                // Found a rewrite operation, stop counting.
1022                break;
1023            }
1024
1025            // Increment count for each snapshot that is not a rewrite.
1026            count += 1;
1027            snapshot_id = snapshot.parent_snapshot_id();
1028        }
1029
1030        count
1031    }
1032
1033    /// Returns the number of snapshots in the current commit branch since the last rewrite.
1034    fn count_snapshots_since_rewrite(&self) -> usize {
1035        let branch = commit_branch(self.config.r#type.as_str(), self.config.write_mode);
1036        Self::count_snapshots_since_rewrite_in_metadata(self.table.metadata(), branch.as_str())
1037    }
1038
1039    /// Wait until snapshot count since last rewrite is below the limit
1040    async fn wait_for_snapshot_limit(&mut self) -> Result<()> {
1041        if let Some(max_snapshots) = self.config.max_snapshots_num_before_compaction {
1042            loop {
1043                let current_count = self.count_snapshots_since_rewrite();
1044
1045                if current_count < max_snapshots {
1046                    tracing::info!(
1047                        "Snapshot count check passed: {} < {}",
1048                        current_count,
1049                        max_snapshots
1050                    );
1051                    break;
1052                }
1053
1054                tracing::info!(
1055                    "Snapshot count {} exceeds limit {}, waiting...",
1056                    current_count,
1057                    max_snapshots
1058                );
1059
1060                self.notify_iceberg_compaction_scheduler(true);
1061
1062                // Wait for 30 seconds before checking again
1063                tokio::time::sleep(Duration::from_secs(30)).await;
1064
1065                // Refresh table after the wait so the next check sees latest snapshots.
1066                let table_ident = self.table.identifier().clone();
1067                self.table = self.catalog.load_table(&table_ident).await.map_err(|err| {
1068                    SinkError::Iceberg(anyhow!(err).context("reload iceberg table"))
1069                })?;
1070            }
1071        }
1072        Ok(())
1073    }
1074}
1075
1076fn serialize_metadata(metadata: Vec<Vec<u8>>) -> Vec<u8> {
1077    serde_json::to_vec(&metadata).unwrap()
1078}
1079
1080fn deserialize_metadata(bytes: Vec<u8>) -> Vec<Vec<u8>> {
1081    serde_json::from_slice(&bytes).unwrap()
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086    use std::collections::HashMap;
1087
1088    use iceberg::spec::{
1089        FormatVersion, MAIN_BRANCH, NestedField, PrimitiveType, Schema, Snapshot,
1090        SnapshotReference, SnapshotRetention, SortOrder, Summary, TableMetadataBuilder, Type,
1091        UnboundPartitionSpec,
1092    };
1093    use risingwave_common::array::arrow::arrow_schema_iceberg::{
1094        DataType as ArrowDataType, Field as ArrowField, FieldRef as ArrowFieldRef,
1095        Fields as ArrowFields, Schema as ArrowSchema,
1096    };
1097
1098    use super::*;
1099
1100    #[test]
1101    fn test_schema_contains_same_fields_allows_binary_large_binary() {
1102        let current_schema = ArrowSchema::new(vec![
1103            ArrowField::new("k", ArrowDataType::Int32, true),
1104            ArrowField::new("v", ArrowDataType::LargeBinary, true),
1105        ]);
1106        let expected_fields = vec![
1107            ArrowField::new("k", ArrowDataType::Int32, true),
1108            ArrowField::new("v", ArrowDataType::Binary, true),
1109        ];
1110
1111        assert!(schema_contains_same_fields(
1112            current_schema.fields(),
1113            &expected_fields
1114        ));
1115    }
1116
1117    #[test]
1118    fn test_schema_contains_same_fields_allows_nested_binary_large_binary() {
1119        let current_schema = ArrowSchema::new(vec![
1120            ArrowField::new(
1121                "s",
1122                ArrowDataType::Struct(ArrowFields::from(vec![ArrowField::new(
1123                    "payload",
1124                    ArrowDataType::LargeBinary,
1125                    true,
1126                )])),
1127                true,
1128            ),
1129            ArrowField::new(
1130                "l",
1131                ArrowDataType::List(ArrowFieldRef::new(ArrowField::new_list_field(
1132                    ArrowDataType::LargeBinary,
1133                    true,
1134                ))),
1135                true,
1136            ),
1137            ArrowField::new_map(
1138                "m",
1139                "entries",
1140                ArrowFieldRef::new(ArrowField::new("key", ArrowDataType::Utf8, false)),
1141                ArrowFieldRef::new(ArrowField::new("value", ArrowDataType::LargeBinary, true)),
1142                false,
1143                true,
1144            ),
1145        ]);
1146        let expected_fields = vec![
1147            ArrowField::new(
1148                "s",
1149                ArrowDataType::Struct(ArrowFields::from(vec![ArrowField::new(
1150                    "payload",
1151                    ArrowDataType::Binary,
1152                    true,
1153                )])),
1154                true,
1155            ),
1156            ArrowField::new(
1157                "l",
1158                ArrowDataType::List(ArrowFieldRef::new(ArrowField::new_list_field(
1159                    ArrowDataType::Binary,
1160                    true,
1161                ))),
1162                true,
1163            ),
1164            ArrowField::new_map(
1165                "m",
1166                "entries",
1167                ArrowFieldRef::new(ArrowField::new("key", ArrowDataType::Utf8, false)),
1168                ArrowFieldRef::new(ArrowField::new("value", ArrowDataType::Binary, true)),
1169                false,
1170                true,
1171            ),
1172        ];
1173
1174        assert!(schema_contains_same_fields(
1175            current_schema.fields(),
1176            &expected_fields
1177        ));
1178    }
1179
1180    #[test]
1181    fn test_schema_contains_same_fields_allows_decimal_precision_delta() {
1182        let current_schema = ArrowSchema::new(vec![ArrowField::new(
1183            "d",
1184            ArrowDataType::Decimal128(28, 10),
1185            true,
1186        )]);
1187        let expected_fields = vec![ArrowField::new(
1188            "d",
1189            ArrowDataType::Decimal128(38, 10),
1190            true,
1191        )];
1192
1193        assert!(schema_contains_same_fields(
1194            current_schema.fields(),
1195            &expected_fields
1196        ));
1197    }
1198
1199    #[test]
1200    fn test_schema_contains_same_fields_allows_decimal_precision_and_scale_delta() {
1201        let current_schema = ArrowSchema::new(vec![ArrowField::new(
1202            "d",
1203            ArrowDataType::Decimal128(38, 2),
1204            true,
1205        )]);
1206        let expected_fields = vec![ArrowField::new(
1207            "d",
1208            ArrowDataType::Decimal128(38, 10),
1209            true,
1210        )];
1211
1212        assert!(schema_contains_same_fields(
1213            current_schema.fields(),
1214            &expected_fields
1215        ));
1216    }
1217
1218    #[test]
1219    fn test_schema_contains_same_fields_rejects_length_mismatch() {
1220        let current_schema =
1221            ArrowSchema::new(vec![ArrowField::new("k", ArrowDataType::Int32, true)]);
1222        let expected_fields = vec![
1223            ArrowField::new("k", ArrowDataType::Int32, true),
1224            ArrowField::new("v", ArrowDataType::Utf8, true),
1225        ];
1226
1227        assert!(!schema_contains_same_fields(
1228            current_schema.fields(),
1229            &expected_fields
1230        ));
1231    }
1232
1233    #[test]
1234    fn test_schema_contains_same_fields_rejects_type_mismatch() {
1235        let current_schema =
1236            ArrowSchema::new(vec![ArrowField::new("v", ArrowDataType::Utf8, true)]);
1237        let expected_fields = vec![ArrowField::new("v", ArrowDataType::Int32, true)];
1238
1239        assert!(!schema_contains_same_fields(
1240            current_schema.fields(),
1241            &expected_fields
1242        ));
1243    }
1244
1245    #[test]
1246    fn test_schema_contains_same_fields_rejects_reused_duplicate_match() {
1247        let current_schema = ArrowSchema::new(vec![
1248            ArrowField::new("v", ArrowDataType::Int32, true),
1249            ArrowField::new("v", ArrowDataType::Utf8, true),
1250        ]);
1251        let expected_fields = vec![
1252            ArrowField::new("v", ArrowDataType::Int32, true),
1253            ArrowField::new("v", ArrowDataType::Int32, true),
1254        ];
1255
1256        assert!(!schema_contains_same_fields(
1257            current_schema.fields(),
1258            &expected_fields
1259        ));
1260    }
1261
1262    #[test]
1263    fn test_schema_contains_same_fields_rejects_nested_type_mismatch() {
1264        let current_schema = ArrowSchema::new(vec![ArrowField::new(
1265            "s",
1266            ArrowDataType::Struct(ArrowFields::from(vec![ArrowField::new(
1267                "payload",
1268                ArrowDataType::Utf8,
1269                true,
1270            )])),
1271            true,
1272        )]);
1273        let expected_fields = vec![ArrowField::new(
1274            "s",
1275            ArrowDataType::Struct(ArrowFields::from(vec![ArrowField::new(
1276                "payload",
1277                ArrowDataType::Int32,
1278                true,
1279            )])),
1280            true,
1281        )];
1282
1283        assert!(!schema_contains_same_fields(
1284            current_schema.fields(),
1285            &expected_fields
1286        ));
1287    }
1288
1289    #[test]
1290    fn test_count_snapshots_since_rewrite_in_metadata_ignores_other_branches() {
1291        let mut builder = TableMetadataBuilder::new(
1292            Schema::builder()
1293                .with_fields(vec![
1294                    NestedField::new(1, "id", Type::Primitive(PrimitiveType::Long), false).into(),
1295                ])
1296                .build()
1297                .unwrap(),
1298            UnboundPartitionSpec::builder().build(),
1299            SortOrder::unsorted_order(),
1300            "s3://warehouse/db/table".to_owned(),
1301            FormatVersion::V2,
1302            HashMap::new(),
1303        )
1304        .unwrap();
1305
1306        for (snapshot_id, parent_snapshot_id, operation) in [
1307            (1, None, Operation::Append),
1308            (2, Some(1), Operation::Append),
1309            (3, Some(2), Operation::Replace),
1310            (4, Some(3), Operation::Append),
1311            // Simulate the COW publish snapshot on main. It should not affect
1312            // the ingestion branch backlog count.
1313            (5, None, Operation::Overwrite),
1314        ] {
1315            builder = builder
1316                .add_snapshot(snapshot(snapshot_id, parent_snapshot_id, operation))
1317                .unwrap();
1318        }
1319
1320        let metadata = builder
1321            .set_ref(super::super::ICEBERG_COW_BRANCH, snapshot_ref(4))
1322            .unwrap()
1323            .set_ref(MAIN_BRANCH, snapshot_ref(5))
1324            .unwrap()
1325            .build()
1326            .unwrap()
1327            .metadata;
1328
1329        let count = IcebergSinkCommitter::count_snapshots_since_rewrite_in_metadata(
1330            &metadata,
1331            super::super::ICEBERG_COW_BRANCH,
1332        );
1333
1334        assert_eq!(count, 1);
1335    }
1336
1337    fn snapshot(
1338        snapshot_id: i64,
1339        parent_snapshot_id: Option<i64>,
1340        operation: Operation,
1341    ) -> Snapshot {
1342        Snapshot::builder()
1343            .with_snapshot_id(snapshot_id)
1344            .with_parent_snapshot_id(parent_snapshot_id)
1345            .with_sequence_number(snapshot_id)
1346            .with_timestamp_ms(snapshot_id)
1347            .with_manifest_list(format!("/snap-{snapshot_id}.avro"))
1348            .with_summary(Summary {
1349                operation,
1350                additional_properties: HashMap::new(),
1351            })
1352            .with_schema_id(0)
1353            .build()
1354    }
1355
1356    fn snapshot_ref(snapshot_id: i64) -> SnapshotReference {
1357        SnapshotReference::new(snapshot_id, SnapshotRetention::branch(None, None, None))
1358    }
1359}