Skip to content

Commit 166f605

Browse files
committed
Claude: don't re-attempt commit unless we're sure it failed
1 parent bffd002 commit 166f605

1 file changed

Lines changed: 115 additions & 21 deletions

File tree

src/storage/src/sink/iceberg.rs

Lines changed: 115 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
783811
async 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

Comments
 (0)