2222#include " iceberg/expression/expressions.h"
2323#include " iceberg/expression/term.h"
2424#include " iceberg/logging/log_level.h"
25+ #include " iceberg/snapshot.h"
2526#include " iceberg/sort_order.h"
27+ #include " iceberg/table_metadata.h"
2628#include " iceberg/test/logging_test_helpers.h"
2729#include " iceberg/test/matchers.h"
2830#include " iceberg/test/mock_catalog.h"
@@ -227,9 +229,68 @@ TEST_F(TransactionRetryTest, CommitRetryEmitsRetryAndSuccessLogs) {
227229 << " expected a success INFO" ;
228230}
229231
230- // A commit that exhausts its retries emits an ERROR with the attempt count and the
231- // final error.
232- TEST_F (TransactionRetryTest, CommitRetryExhaustedEmitsErrorLog) {
232+ // A metadata-only retry must not attribute a snapshot committed concurrently by
233+ // another writer to this transaction.
234+ TEST_F (TransactionRetryTest, MetadataOnlyRetryDoesNotLogConcurrentSnapshot) {
235+ auto capturing = std::make_shared<CapturingLogger>();
236+ capturing->SetLevel (LogLevel::kTrace );
237+ ScopedDefaultLogger guard (capturing);
238+
239+ constexpr int64_t kConcurrentSnapshotId = 987654321 ;
240+ auto metadata_builder = TableMetadataBuilder::BuildFrom (mock_table_->metadata ().get ());
241+ auto concurrent_snapshot = std::make_shared<Snapshot>(Snapshot{
242+ .snapshot_id = kConcurrentSnapshotId ,
243+ .parent_snapshot_id = mock_table_->metadata ()->current_snapshot_id ,
244+ .sequence_number = mock_table_->metadata ()->last_sequence_number + 1 ,
245+ .timestamp_ms = TimePointMs{},
246+ .manifest_list = " concurrent-manifest-list.avro" ,
247+ .summary = {{SnapshotSummaryFields::kOperation , " append" }},
248+ });
249+ metadata_builder->SetBranchSnapshot (concurrent_snapshot,
250+ std::string (SnapshotRef::kMainBranch ));
251+ ICEBERG_UNWRAP_OR_FAIL (auto concurrent_metadata, metadata_builder->Build ());
252+ auto concurrent_metadata_ptr =
253+ std::shared_ptr<TableMetadata>(std::move (concurrent_metadata));
254+ const std::string concurrent_metadata_location = " concurrent.metadata.json" ;
255+
256+ ON_CALL (*mock_catalog_, LoadTable (::testing::_))
257+ .WillByDefault ([this , concurrent_metadata_ptr, &concurrent_metadata_location](
258+ const TableIdentifier&) -> Result<std::shared_ptr<Table>> {
259+ return Table::Make (mock_table_->name (), concurrent_metadata_ptr,
260+ concurrent_metadata_location, mock_table_->io (),
261+ mock_catalog_);
262+ });
263+
264+ int update_call_count = 0 ;
265+ ON_CALL (*mock_catalog_, UpdateTable (::testing::_, ::testing::_, ::testing::_))
266+ .WillByDefault (
267+ [this , concurrent_metadata_ptr, &concurrent_metadata_location,
268+ &update_call_count](const TableIdentifier&,
269+ const std::vector<std::unique_ptr<TableRequirement>>&,
270+ const std::vector<std::unique_ptr<TableUpdate>>&)
271+ -> Result<std::shared_ptr<Table>> {
272+ if (++update_call_count == 1 ) {
273+ return CommitFailed (" conflict on first attempt" );
274+ }
275+ return Table::Make (mock_table_->name (), concurrent_metadata_ptr,
276+ concurrent_metadata_location, mock_table_->io (),
277+ mock_catalog_);
278+ });
279+
280+ ICEBERG_UNWRAP_OR_FAIL (auto txn, mock_table_->NewTransaction ());
281+ ICEBERG_UNWRAP_OR_FAIL (auto update, txn->NewUpdateProperties ());
282+ update->Set (" retry.test" , " value" );
283+ ASSERT_THAT (update->Commit (), IsOk ());
284+ ASSERT_THAT (txn->Commit (), IsOk ());
285+
286+ EXPECT_FALSE (HasRecord (capturing->records (), LogLevel::kInfo ,
287+ std::to_string (kConcurrentSnapshotId )))
288+ << " metadata-only commit attributed the concurrent snapshot to itself" ;
289+ }
290+
291+ // A commit that exhausts its retries returns the final error without emitting a
292+ // generic ERROR log. Genuine retry attempts still emit WARN records.
293+ TEST_F (TransactionRetryTest, CommitRetryExhaustedDoesNotEmitErrorLog) {
233294 auto capturing = std::make_shared<CapturingLogger>();
234295 capturing->SetLevel (LogLevel::kTrace );
235296 ScopedDefaultLogger guard (capturing);
@@ -249,10 +310,8 @@ TEST_F(TransactionRetryTest, CommitRetryExhaustedEmitsErrorLog) {
249310 EXPECT_THAT (txn->Commit (), IsError (ErrorKind::kCommitFailed ));
250311
251312 auto records = capturing->records ();
252- EXPECT_TRUE (HasRecord (records, LogLevel::kError , " failed after 5 attempt(s)" ))
253- << " expected a final ERROR with the attempt count" ;
254- EXPECT_TRUE (HasRecord (records, LogLevel::kError , " always conflicts" ))
255- << " final ERROR should carry the last error" ;
313+ EXPECT_FALSE (HasRecord (records, LogLevel::kError , " " ))
314+ << " the final commit error should be propagated without a generic ERROR log" ;
256315 // Retries 2..5 each log a WARN.
257316 EXPECT_TRUE (
258317 HasRecord (records, LogLevel::kWarn , " Retrying transaction commit (attempt 5)" ));
@@ -288,7 +347,11 @@ TEST_F(TransactionRetryTest, CommitSuccessEmitsInfoLog) {
288347 EXPECT_FALSE (HasRecord (records, LogLevel::kWarn , " Retrying transaction commit" ));
289348}
290349
291- TEST_F (TransactionRetryTest, CommitNonRetryableErrorStopsImmediately) {
350+ TEST_F (TransactionRetryTest, CommitStateUnknownStopsImmediatelyWithoutErrorLog) {
351+ auto capturing = std::make_shared<CapturingLogger>();
352+ capturing->SetLevel (LogLevel::kTrace );
353+ ScopedDefaultLogger guard (capturing);
354+
292355 int update_call_count = 0 ;
293356 ON_CALL (*mock_catalog_, UpdateTable (::testing::_, ::testing::_, ::testing::_))
294357 .WillByDefault (
@@ -308,6 +371,8 @@ TEST_F(TransactionRetryTest, CommitNonRetryableErrorStopsImmediately) {
308371 auto result = txn->Commit ();
309372 EXPECT_THAT (result, IsError (ErrorKind::kCommitStateUnknown ));
310373 EXPECT_EQ (update_call_count, 1 ); // Should not retry
374+ EXPECT_FALSE (HasRecord (capturing->records (), LogLevel::kError , " " ))
375+ << " an unknown commit state must not be logged as a confirmed failure" ;
311376}
312377
313378TEST_F (TransactionRetryTest, CreateTransactionDoesNotRetry) {
0 commit comments