@@ -777,11 +777,39 @@ async fn reload_table(
777777 }
778778}
779779
780+ /// What the commit operator knows about the Iceberg table in between commit attempts.
781+ ///
782+ /// A commit whose response never arrived may or may not have been applied by the catalog.
783+ /// Sending another commit while that is unknown risks writing the same data files twice:
784+ /// `Transaction::commit` re-bases a transaction whose base has gone stale onto whatever the
785+ /// table has become, so the second attempt satisfies its preconditions and lands a duplicate
786+ /// snapshot instead of being rejected. The unknown therefore has to survive across retries, and
787+ /// can only be cleared by reading the table back.
788+ enum CommitState {
789+ /// No commit of ours is outstanding, so this table is a sound base for the next one.
790+ Known ( Table ) ,
791+ /// A commit was sent and we never learned whether it landed. The table held here is the base
792+ /// it was sent against, which is no longer known to be current.
793+ Unresolved ( Table ) ,
794+ }
795+
796+ impl CommitState {
797+ /// The most recent table state. Sound as a base for a new commit only when [`Self::Known`].
798+ fn into_table ( self ) -> Table {
799+ match self {
800+ CommitState :: Known ( table) | CommitState :: Unresolved ( table) => table,
801+ }
802+ }
803+ }
804+
780805/// Attempt a single commit of a batch of data files to an Iceberg table.
781- /// On conflict or failure, reloads the table and returns a retryable error.
806+ ///
807+ /// If a previous attempt left the outcome unknown, first reload the table to establish whether
808+ /// that attempt landed, and commit again only once it is established that it did not.
809+ /// On conflict, reloads the table and returns a retryable error.
782810/// On success, returns the updated table state.
783811async fn try_commit_batch (
784- mut table : Table ,
812+ state : CommitState ,
785813 snapshot_properties : Vec < ( String , String ) > ,
786814 data_files : Vec < DataFile > ,
787815 delete_files : Vec < DataFile > ,
@@ -793,7 +821,49 @@ async fn try_commit_batch(
793821 batch_lower : & Antichain < Timestamp > ,
794822 batch_upper : & Antichain < Timestamp > ,
795823 metrics : & IcebergSinkMetrics ,
796- ) -> ( Table , RetryResult < ( ) , anyhow:: Error > ) {
824+ ) -> ( CommitState , RetryResult < ( ) , anyhow:: Error > ) {
825+ let table = match state {
826+ CommitState :: Known ( table) => table,
827+ CommitState :: Unresolved ( stale) => {
828+ let reloaded = match reload_table (
829+ catalog,
830+ conn_namespace. to_string ( ) ,
831+ conn_table. to_string ( ) ,
832+ stale. clone ( ) ,
833+ )
834+ . await
835+ {
836+ Ok ( reloaded) => reloaded,
837+ // Still unknown. Spend the retry on another read, never on another commit.
838+ Err ( e) => {
839+ return (
840+ CommitState :: Unresolved ( stale) ,
841+ RetryResult :: RetryableErr ( anyhow ! ( e) ) ,
842+ ) ;
843+ }
844+ } ;
845+
846+ let mut snapshots: Vec < _ > = reloaded. metadata ( ) . snapshots ( ) . cloned ( ) . collect ( ) ;
847+ match retrieve_upper_from_snapshots ( & mut snapshots) {
848+ // Our own commit for this batch is already on the table. It landed and we never
849+ // saw the response.
850+ Ok ( Some ( ( last_frontier, last_version) ) )
851+ if last_version == sink_version && last_frontier == * frontier =>
852+ {
853+ return ( CommitState :: Known ( reloaded) , RetryResult :: Ok ( ( ) ) ) ;
854+ }
855+ // It is not there, so it never landed and committing again is safe.
856+ Ok ( _) => reloaded,
857+ Err ( e) => {
858+ return (
859+ CommitState :: Unresolved ( stale) ,
860+ RetryResult :: RetryableErr ( anyhow ! ( e) ) ,
861+ ) ;
862+ }
863+ }
864+ }
865+ } ;
866+
797867 let tx = Transaction :: new ( & table) ;
798868 let mut action = tx
799869 . row_delta ( )
@@ -812,21 +882,24 @@ async fn try_commit_batch(
812882 {
813883 Ok ( tx) => tx,
814884 Err ( e) => {
815- match reload_table (
885+ let reloaded = match reload_table (
816886 catalog,
817887 conn_namespace. to_string ( ) ,
818888 conn_table. to_string ( ) ,
819889 table. clone ( ) ,
820890 )
821891 . await
822892 {
823- Ok ( reloaded) => table = reloaded,
893+ Ok ( reloaded) => reloaded,
824894 Err ( reload_err) => {
825- return ( table, RetryResult :: RetryableErr ( anyhow ! ( reload_err) ) ) ;
895+ return (
896+ CommitState :: Known ( table) ,
897+ RetryResult :: RetryableErr ( anyhow ! ( reload_err) ) ,
898+ ) ;
826899 }
827- }
900+ } ;
828901 return (
829- table ,
902+ CommitState :: Known ( reloaded ) ,
830903 RetryResult :: RetryableErr ( anyhow ! (
831904 "Failed to apply data file addition to iceberg table transaction: {}" ,
832905 e
@@ -839,17 +912,20 @@ async fn try_commit_batch(
839912 match new_table {
840913 Err ( e) if matches ! ( e. kind( ) , ErrorKind :: CatalogCommitConflicts ) => {
841914 metrics. commit_conflicts . inc ( ) ;
842- match reload_table (
915+ let table = match reload_table (
843916 catalog,
844917 conn_namespace. to_string ( ) ,
845918 conn_table. to_string ( ) ,
846919 table. clone ( ) ,
847920 )
848921 . await
849922 {
850- Ok ( reloaded) => table = reloaded,
923+ Ok ( reloaded) => reloaded,
851924 Err ( e) => {
852- return ( table, RetryResult :: RetryableErr ( anyhow ! ( e) ) ) ;
925+ return (
926+ CommitState :: Known ( table) ,
927+ RetryResult :: RetryableErr ( anyhow ! ( e) ) ,
928+ ) ;
853929 }
854930 } ;
855931
@@ -858,15 +934,18 @@ async fn try_commit_batch(
858934 let last = match last {
859935 Ok ( val) => val,
860936 Err ( e) => {
861- return ( table, RetryResult :: RetryableErr ( anyhow ! ( e) ) ) ;
937+ return (
938+ CommitState :: Known ( table) ,
939+ RetryResult :: RetryableErr ( anyhow ! ( e) ) ,
940+ ) ;
862941 }
863942 } ;
864943
865944 // Check if another writer has advanced the frontier beyond ours (fencing check)
866945 if let Some ( ( last_frontier, last_version) ) = last {
867946 if last_version > sink_version {
868947 return (
869- table,
948+ CommitState :: Known ( table) ,
870949 RetryResult :: FatalErr ( anyhow ! (
871950 "Iceberg table '{}' has been modified by another writer \
872951 with version {}. Current sink version: {}. \
@@ -879,7 +958,7 @@ async fn try_commit_batch(
879958 }
880959 if PartialOrder :: less_equal ( frontier, & last_frontier) {
881960 return (
882- table,
961+ CommitState :: Known ( table) ,
883962 RetryResult :: FatalErr ( anyhow ! (
884963 "Iceberg table '{}' has been modified by another writer. \
885964 Current frontier: {:?}, last frontier: {:?}.",
@@ -892,7 +971,7 @@ async fn try_commit_batch(
892971 }
893972
894973 (
895- table,
974+ CommitState :: Known ( table) ,
896975 RetryResult :: RetryableErr ( anyhow ! (
897976 "Commit conflict detected when committing batch [{}, {}) \
898977 to Iceberg table '{}.{}'. Retrying...",
@@ -903,11 +982,23 @@ async fn try_commit_batch(
903982 ) ) ,
904983 )
905984 }
985+ // The catalog may have applied this commit before the response was lost. Only a read of
986+ // the table can say which, so record that the outcome is unknown and resolve it on the
987+ // next attempt, before any further commit is sent.
988+ Err ( e) if matches ! ( e. kind( ) , ErrorKind :: Unexpected ) => {
989+ metrics. commit_failures . inc ( ) ;
990+ (
991+ CommitState :: Unresolved ( table) ,
992+ RetryResult :: RetryableErr ( anyhow ! ( e) ) ,
993+ )
994+ }
995+ // Everything else is deterministic: the same batch against the same table fails the same
996+ // way however often it is retried.
906997 Err ( e) => {
907998 metrics. commit_failures . inc ( ) ;
908- ( table, RetryResult :: RetryableErr ( anyhow ! ( e) ) )
999+ ( CommitState :: Known ( table) , RetryResult :: FatalErr ( anyhow ! ( e) ) )
9091000 }
910- Ok ( new_table) => ( new_table, RetryResult :: Ok ( ( ) ) ) ,
1001+ Ok ( new_table) => ( CommitState :: Known ( new_table) , RetryResult :: Ok ( ( ) ) ) ,
9111002 }
9121003}
9131004
@@ -2538,9 +2629,9 @@ fn commit_to_iceberg<'scope>(
25382629 ( "mz-sink-version" . to_string( ) , sink_version. to_string( ) ) ,
25392630 ] ;
25402631
2541- let ( table_state , commit_result) = Retry :: default ( )
2632+ let ( commit_state , commit_result) = Retry :: default ( )
25422633 . max_tries ( 5 )
2543- . retry_async_with_state ( table, |_, table | {
2634+ . retry_async_with_state ( CommitState :: Known ( table) , |_, commit_state | {
25442635 let snapshot_properties = snapshot_properties. clone ( ) ;
25452636 let data_files = data_files. clone ( ) ;
25462637 let delete_files = delete_files. clone ( ) ;
@@ -2553,7 +2644,7 @@ fn commit_to_iceberg<'scope>(
25532644 let batch_upper = batch. 1 . clone ( ) ;
25542645 async move {
25552646 try_commit_batch (
2556- table ,
2647+ commit_state ,
25572648 snapshot_properties,
25582649 data_files,
25592650 delete_files,
@@ -2576,7 +2667,10 @@ fn commit_to_iceberg<'scope>(
25762667 connection. namespace, connection. table
25772668 )
25782669 } ) ;
2579- table = table_state;
2670+ // An unresolved state only survives the retry loop when the commit failed, in
2671+ // which case `commit_result?` below tears the sink down without reusing this
2672+ // table as a base.
2673+ table = commit_state. into_table ( ) ;
25802674 let duration = instant. elapsed ( ) ;
25812675 metrics
25822676 . commit_duration_seconds
0 commit comments