1919
2020#include " iceberg/transaction.h"
2121
22+ #include < format>
23+ #include < memory>
24+ #include < stdexcept>
25+ #include < string>
26+ #include < vector>
27+
2228#include " iceberg/expression/expressions.h"
2329#include " iceberg/expression/term.h"
2430#include " iceberg/sort_order.h"
31+ #include " iceberg/table_metadata.h"
2532#include " iceberg/test/matchers.h"
2633#include " iceberg/test/mock_catalog.h"
2734#include " iceberg/test/update_test_base.h"
2835#include " iceberg/transform.h"
2936#include " iceberg/type.h"
37+ #include " iceberg/update/fast_append.h"
38+ #include " iceberg/update/set_snapshot.h"
3039#include " iceberg/update/update_properties.h"
3140#include " iceberg/update/update_schema.h"
3241#include " iceberg/update/update_sort_order.h"
3342
3443namespace iceberg {
3544
45+ class TestPendingUpdate final : public PendingUpdate {
46+ public:
47+ explicit TestPendingUpdate (std::shared_ptr<TransactionContext> ctx)
48+ : PendingUpdate(std::move(ctx)) {}
49+
50+ Kind kind () const override { return Kind::kUpdateProperties ; }
51+ bool IsRetryable () const override { return true ; }
52+ };
53+
3654class TransactionTest : public UpdateTestBase {};
3755
3856TEST_F (TransactionTest, CreateTransaction) {
@@ -46,6 +64,58 @@ TEST_F(TransactionTest, CommitEmptyTransaction) {
4664 EXPECT_THAT (txn->Commit (), IsOk ());
4765}
4866
67+ TEST_F (TransactionTest, TemporaryTransactionDoesNotAttachToContext) {
68+ ICEBERG_UNWRAP_OR_FAIL (auto ctx,
69+ TransactionContext::Make (table_, TransactionKind::kUpdate ));
70+ ASSERT_FALSE (ctx->transaction .has_value ());
71+
72+ ICEBERG_UNWRAP_OR_FAIL (auto txn, Transaction::Make (ctx));
73+
74+ EXPECT_NE (txn, nullptr );
75+ EXPECT_FALSE (ctx->transaction .has_value ());
76+ }
77+
78+ TEST_F (TransactionTest, StandaloneCommitRequiresSharedOwnership) {
79+ ICEBERG_UNWRAP_OR_FAIL (auto ctx,
80+ TransactionContext::Make (table_, TransactionKind::kUpdate ));
81+ auto update = std::make_unique<TestPendingUpdate>(ctx);
82+
83+ EXPECT_THAT (update->Commit (),
84+ ::testing::AllOf (
85+ IsError (ErrorKind::kInvalidArgument ),
86+ HasErrorMessage(" PendingUpdate must be owned by std::shared_ptr" )));
87+ EXPECT_FALSE (ctx->transaction.has_value());
88+ }
89+
90+ TEST_F (TransactionTest, StandaloneCommitClearsTemporaryTransactionBinding) {
91+ ICEBERG_UNWRAP_OR_FAIL (auto ctx,
92+ TransactionContext::Make (table_, TransactionKind::kUpdate ));
93+ ICEBERG_UNWRAP_OR_FAIL (auto update, UpdateProperties::Make (ctx));
94+ update->Set (" standalone.property" , " standalone.value" );
95+
96+ EXPECT_THAT (update->Commit (), IsOk ());
97+ EXPECT_FALSE (ctx->transaction .has_value ());
98+ }
99+
100+ TEST_F (TransactionTest, CommitNoOpUpdate) {
101+ ICEBERG_UNWRAP_OR_FAIL (auto txn, table_->NewTransaction ());
102+ ICEBERG_UNWRAP_OR_FAIL (auto update, txn->NewSetSnapshot ());
103+
104+ EXPECT_THAT (update->Commit (), IsOk ());
105+ EXPECT_THAT (txn->Commit (), IsOk ());
106+ }
107+
108+ TEST_F (TransactionTest, ApplyFailureFinalizesTransaction) {
109+ ICEBERG_UNWRAP_OR_FAIL (auto txn, table_->NewTransaction ());
110+ ICEBERG_UNWRAP_OR_FAIL (auto update, txn->NewFastAppend ());
111+ update->AppendFile (nullptr );
112+
113+ EXPECT_THAT (update->Commit (), IsError (ErrorKind::kValidationFailed ));
114+ EXPECT_THAT (txn->Commit (),
115+ ::testing::AllOf (IsError(ErrorKind::kValidationFailed ),
116+ HasErrorMessage(" Transaction already finalized" )));
117+ }
118+
49119TEST_F (TransactionTest, CommitTransactionWithPropertyUpdate) {
50120 ICEBERG_UNWRAP_OR_FAIL (auto txn, table_->NewTransaction ());
51121 ICEBERG_UNWRAP_OR_FAIL (auto update, txn->NewUpdateProperties ());
@@ -151,6 +221,39 @@ TEST_F(TransactionRetryTest, CommitRetrySucceedsAfterConflict) {
151221 EXPECT_EQ (update_call_count, 2 );
152222}
153223
224+ TEST_F (TransactionRetryTest, StandaloneCommitRetryReappliesUpdate) {
225+ std::vector<size_t > update_counts;
226+ ON_CALL (*mock_catalog_, UpdateTable (::testing::_, ::testing::_, ::testing::_))
227+ .WillByDefault (
228+ [this , &update_counts](const TableIdentifier&,
229+ const std::vector<std::unique_ptr<TableRequirement>>&,
230+ const std::vector<std::unique_ptr<TableUpdate>>& updates)
231+ -> Result<std::shared_ptr<Table>> {
232+ update_counts.push_back (updates.size ());
233+ if (update_counts.size () == 1 ) {
234+ return CommitFailed (" conflict on first attempt" );
235+ }
236+ return Table::Make (mock_table_->name (), mock_table_->metadata (),
237+ std::string (mock_table_->metadata_file_location ()),
238+ mock_table_->io (), mock_catalog_);
239+ });
240+ EXPECT_CALL (*mock_catalog_, LoadTable (::testing::_))
241+ .WillOnce ([this ](const TableIdentifier&) -> Result<std::shared_ptr<Table>> {
242+ auto builder = TableMetadataBuilder::BuildFrom (mock_table_->metadata ().get ());
243+ ICEBERG_ASSIGN_OR_RAISE (auto metadata, builder->Build ());
244+ return Table::Make (
245+ mock_table_->name (), std::shared_ptr<TableMetadata>(std::move (metadata)),
246+ std::format (" {}.refreshed" , mock_table_->metadata_file_location ()),
247+ mock_table_->io (), mock_catalog_);
248+ });
249+
250+ ICEBERG_UNWRAP_OR_FAIL (auto update, mock_table_->NewUpdateProperties ());
251+ update->Set (" retry.test" , " value" );
252+
253+ EXPECT_THAT (update->Commit (), IsOk ());
254+ EXPECT_THAT (update_counts, ::testing::ElementsAre (1U , 1U ));
255+ }
256+
154257TEST_F (TransactionRetryTest, CommitRetryExhausted) {
155258 int update_call_count = 0 ;
156259 ON_CALL (*mock_catalog_, UpdateTable (::testing::_, ::testing::_, ::testing::_))
@@ -195,6 +298,34 @@ TEST_F(TransactionRetryTest, CommitNonRetryableErrorStopsImmediately) {
195298 EXPECT_EQ (update_call_count, 1 ); // Should not retry
196299}
197300
301+ TEST_F (TransactionRetryTest, CommitExceptionRestoresLifecycleState) {
302+ int update_call_count = 0 ;
303+ ON_CALL (*mock_catalog_, UpdateTable (::testing::_, ::testing::_, ::testing::_))
304+ .WillByDefault (
305+ [&update_call_count](const TableIdentifier&,
306+ const std::vector<std::unique_ptr<TableRequirement>>&,
307+ const std::vector<std::unique_ptr<TableUpdate>>&)
308+ -> Result<std::shared_ptr<Table>> {
309+ ++update_call_count;
310+ throw std::runtime_error (" injected catalog failure" );
311+ });
312+
313+ ICEBERG_UNWRAP_OR_FAIL (auto txn, mock_table_->NewTransaction ());
314+ ICEBERG_UNWRAP_OR_FAIL (auto properties, txn->NewUpdateProperties ());
315+ properties->Set (" exception.test" , " value" );
316+ EXPECT_THAT (properties->Commit (), IsOk ());
317+
318+ EXPECT_THROW (std::ignore = txn->Commit (), std::runtime_error);
319+
320+ ICEBERG_UNWRAP_OR_FAIL (auto append, txn->NewFastAppend ());
321+ append->AppendFile (nullptr );
322+ EXPECT_THAT (append->Commit (), IsError (ErrorKind::kValidationFailed ));
323+ EXPECT_THAT (txn->Commit (),
324+ ::testing::AllOf (IsError(ErrorKind::kValidationFailed ),
325+ HasErrorMessage(" Transaction already finalized" )));
326+ EXPECT_EQ (update_call_count, 1 );
327+ }
328+
198329TEST_F (TransactionRetryTest, CreateTransactionDoesNotRetry) {
199330 int update_call_count = 0 ;
200331 ON_CALL (*mock_catalog_, UpdateTable (::testing::_, ::testing::_, ::testing::_))
0 commit comments