Skip to content

Commit 9983e1a

Browse files
committed
fix: enforce pending update lifecycle
Pending updates need deterministic ownership and finalization across both standalone commits and explicit transactions. Register updates through shared ownership so transactions can retain and finalize them safely, and scope the temporary transaction binding used by standalone commits so it never escapes through TransactionContext. Finalize no-op commits and failed applies so every update reaches a terminal state and staged files are cleaned even when the caller never commits the transaction. During Transaction::Commit retries, defer eager finalization while updates are being reapplied; otherwise a retryable validation error would finalize the transaction and destroy staged state before RetryRunner can retry. Restore the retry lifecycle marker with RAII when commit exits or throws. Convert snapshot update factories and test helpers to shared_ptr to satisfy the ownership contract. Tests cover detached temporary transactions, cleared standalone bindings, rejection of unshared standalone updates, no-op and apply-failure finalization, staged-file cleanup, standalone retry reapplication, and lifecycle restoration after commit exceptions.
1 parent 7dcc4ce commit 9983e1a

20 files changed

Lines changed: 282 additions & 65 deletions

src/iceberg/test/fast_append_test.cc

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,26 @@ TEST_F(FastAppendTest, FinalizeIgnoresCleanupDeleteFailure) {
315315
IsOk());
316316
}
317317

318+
TEST_F(FastAppendTest, TransactionApplyFailureCleansUpStagedFiles) {
319+
ICEBERG_UNWRAP_OR_FAIL(auto txn, table_->NewTransaction());
320+
ICEBERG_UNWRAP_OR_FAIL(auto fast_append, txn->NewFastAppend());
321+
std::vector<std::string> deleted_paths;
322+
fast_append->DeleteWith([&](const std::string& path) {
323+
deleted_paths.push_back(path);
324+
return file_io_->DeleteFile(path);
325+
});
326+
fast_append->AppendFile(file_a_);
327+
328+
EXPECT_THAT(static_cast<SnapshotUpdate&>(*fast_append).Apply(), IsOk());
329+
fast_append->AppendFile(nullptr);
330+
331+
EXPECT_THAT(fast_append->Commit(), IsError(ErrorKind::kValidationFailed));
332+
EXPECT_THAT(deleted_paths, ::testing::SizeIs(2U));
333+
EXPECT_THAT(txn->Commit(),
334+
::testing::AllOf(IsError(ErrorKind::kValidationFailed),
335+
HasErrorMessage("Transaction already finalized")));
336+
}
337+
318338
TEST_F(FastAppendTest, RetryCopiesAppendManifestAgain) {
319339
table_->metadata()->format_version = 1;
320340
const auto path = table_location_ + "/metadata/input.avro";

src/iceberg/test/merging_snapshot_update_test.cc

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,11 @@ class MergingSnapshotCapturingReporter final : public MetricsReporter {
8181
/// \brief Concrete subclass of MergingSnapshotUpdate for testing.
8282
class TestMergeAppend : public MergingSnapshotUpdate {
8383
public:
84-
static Result<std::unique_ptr<TestMergeAppend>> Make(std::string table_name,
84+
static Result<std::shared_ptr<TestMergeAppend>> Make(std::string table_name,
8585
std::shared_ptr<Table> table) {
8686
ICEBERG_ASSIGN_OR_RAISE(
8787
auto ctx, TransactionContext::Make(std::move(table), TransactionKind::kUpdate));
88-
return std::unique_ptr<TestMergeAppend>(
88+
return std::shared_ptr<TestMergeAppend>(
8989
new TestMergeAppend(std::move(table_name), std::move(ctx)));
9090
}
9191

@@ -231,11 +231,11 @@ class TestMergeAppend : public MergingSnapshotUpdate {
231231

232232
class TestOverwriteUpdate : public MergingSnapshotUpdate {
233233
public:
234-
static Result<std::unique_ptr<TestOverwriteUpdate>> Make(std::string table_name,
234+
static Result<std::shared_ptr<TestOverwriteUpdate>> Make(std::string table_name,
235235
std::shared_ptr<Table> table) {
236236
ICEBERG_ASSIGN_OR_RAISE(
237237
auto ctx, TransactionContext::Make(std::move(table), TransactionKind::kUpdate));
238-
return std::unique_ptr<TestOverwriteUpdate>(
238+
return std::shared_ptr<TestOverwriteUpdate>(
239239
new TestOverwriteUpdate(std::move(table_name), std::move(ctx)));
240240
}
241241

@@ -335,11 +335,11 @@ class MergingSnapshotUpdateTest : public MinimalUpdateTestBase {
335335
return f;
336336
}
337337

338-
Result<std::unique_ptr<TestMergeAppend>> NewMergeAppend() {
338+
Result<std::shared_ptr<TestMergeAppend>> NewMergeAppend() {
339339
return TestMergeAppend::Make(TableName(), table_);
340340
}
341341

342-
Result<std::unique_ptr<TestOverwriteUpdate>> NewOverwriteUpdate() {
342+
Result<std::shared_ptr<TestOverwriteUpdate>> NewOverwriteUpdate() {
343343
return TestOverwriteUpdate::Make(TableName(), table_);
344344
}
345345

@@ -1101,7 +1101,7 @@ class MergingSnapshotUpdateV1Test : public UpdateTestBase {
11011101
return f;
11021102
}
11031103

1104-
Result<std::unique_ptr<TestMergeAppend>> NewMergeAppend() {
1104+
Result<std::shared_ptr<TestMergeAppend>> NewMergeAppend() {
11051105
return TestMergeAppend::Make(TableName(), table_);
11061106
}
11071107

src/iceberg/test/replace_partitions_test.cc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ class ReplacePartitionsTest : public UpdateTestBase {
216216
return paths;
217217
}
218218

219-
Result<std::unique_ptr<ReplacePartitions>> NewReplace() {
219+
Result<std::shared_ptr<ReplacePartitions>> NewReplace() {
220220
ICEBERG_ASSIGN_OR_RAISE(auto ctx,
221221
TransactionContext::Make(table_, TransactionKind::kUpdate));
222222
return ReplacePartitions::Make(TableName(), std::move(ctx));

src/iceberg/test/transaction_test.cc

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,38 @@
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

3443
namespace 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+
3654
class TransactionTest : public UpdateTestBase {};
3755

3856
TEST_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+
49119
TEST_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+
154257
TEST_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+
198329
TEST_F(TransactionRetryTest, CreateTransactionDoesNotRetry) {
199330
int update_call_count = 0;
200331
ON_CALL(*mock_catalog_, UpdateTable(::testing::_, ::testing::_, ::testing::_))

src/iceberg/transaction.cc

Lines changed: 52 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,21 @@
5757
#include "iceberg/util/retry_util.h"
5858

5959
namespace iceberg {
60+
namespace {
61+
62+
class ScopedTrue {
63+
public:
64+
explicit ScopedTrue(bool& value) : value_(value) { value_ = true; }
65+
~ScopedTrue() { value_ = false; }
66+
67+
ScopedTrue(const ScopedTrue&) = delete;
68+
ScopedTrue& operator=(const ScopedTrue&) = delete;
69+
70+
private:
71+
bool& value_;
72+
};
73+
74+
} // namespace
6075

6176
// ---------------------------------------------------------------------------
6277
// TransactionContext
@@ -122,9 +137,7 @@ Result<std::shared_ptr<Transaction>> Transaction::Make(std::shared_ptr<Table> ta
122137
Result<std::shared_ptr<Transaction>> Transaction::Make(
123138
std::shared_ptr<TransactionContext> ctx) {
124139
ICEBERG_PRECHECK(ctx != nullptr, "TransactionContext cannot be null");
125-
auto txn = std::shared_ptr<Transaction>(new Transaction(ctx));
126-
ctx->transaction = std::weak_ptr<Transaction>(txn);
127-
return txn;
140+
return std::shared_ptr<Transaction>(new Transaction(std::move(ctx)));
128141
}
129142

130143
const std::shared_ptr<Table>& Transaction::table() const { return ctx_->table; }
@@ -138,6 +151,8 @@ std::string Transaction::MetadataFileLocation(std::string_view filename) const {
138151
}
139152

140153
Status Transaction::AddUpdate(const std::shared_ptr<PendingUpdate>& update) {
154+
ICEBERG_CHECK(!committed_, "Cannot add update to a committed transaction");
155+
ICEBERG_CHECK(!finalized_, "Cannot add update to a finalized transaction");
141156
ICEBERG_CHECK(last_update_committed_,
142157
"Cannot add update when previous update is not committed");
143158

@@ -147,6 +162,9 @@ Status Transaction::AddUpdate(const std::shared_ptr<PendingUpdate>& update) {
147162
}
148163

149164
Status Transaction::Apply(PendingUpdate& update) {
165+
ICEBERG_CHECK(!committed_, "Cannot apply update to a committed transaction");
166+
ICEBERG_CHECK(!finalized_, "Cannot apply update to a finalized transaction");
167+
150168
switch (update.kind()) {
151169
case PendingUpdate::Kind::kExpireSnapshots:
152170
ICEBERG_RETURN_UNEXPECTED(
@@ -358,40 +376,37 @@ Status Transaction::ApplyUpdatePartitionStatistics(UpdatePartitionStatistics& up
358376

359377
Result<std::shared_ptr<Table>> Transaction::Commit() {
360378
ICEBERG_CHECK(!committed_, "Transaction already committed");
379+
ICEBERG_CHECK(!finalized_, "Transaction already finalized");
361380
ICEBERG_CHECK(last_update_committed_,
362381
"Cannot commit transaction when previous update is not committed");
363382

364383
const auto& updates = ctx_->metadata_builder->changes();
365-
if (updates.empty()) {
366-
committed_ = true;
367-
return ctx_->table;
384+
Result<std::shared_ptr<Table>> commit_result = ctx_->table;
385+
if (!updates.empty()) {
386+
const auto& props = ctx_->table->properties();
387+
int32_t num_retries =
388+
CanRetry() ? static_cast<int32_t>(props.Get(TableProperties::kCommitNumRetries))
389+
: 0;
390+
int32_t min_wait_ms = props.Get(TableProperties::kCommitMinRetryWaitMs);
391+
int32_t max_wait_ms = props.Get(TableProperties::kCommitMaxRetryWaitMs);
392+
int32_t total_timeout_ms = props.Get(TableProperties::kCommitTotalRetryTimeMs);
393+
394+
bool is_first_attempt = true;
395+
ScopedTrue committing(committing_);
396+
commit_result =
397+
MakeCommitRetryRunner(num_retries, min_wait_ms, max_wait_ms, total_timeout_ms)
398+
.Run([this, &is_first_attempt]() -> Result<std::shared_ptr<Table>> {
399+
auto result = CommitOnce(is_first_attempt);
400+
is_first_attempt = false;
401+
return result;
402+
});
368403
}
369404

370-
const auto& props = ctx_->table->properties();
371-
int32_t num_retries =
372-
CanRetry() ? static_cast<int32_t>(props.Get(TableProperties::kCommitNumRetries))
373-
: 0;
374-
int32_t min_wait_ms = props.Get(TableProperties::kCommitMinRetryWaitMs);
375-
int32_t max_wait_ms = props.Get(TableProperties::kCommitMaxRetryWaitMs);
376-
int32_t total_timeout_ms = props.Get(TableProperties::kCommitTotalRetryTimeMs);
377-
378-
bool is_first_attempt = true;
379-
auto commit_result =
380-
MakeCommitRetryRunner(num_retries, min_wait_ms, max_wait_ms, total_timeout_ms)
381-
.Run([this, &is_first_attempt]() -> Result<std::shared_ptr<Table>> {
382-
auto result = CommitOnce(is_first_attempt);
383-
is_first_attempt = false;
384-
return result;
385-
});
386-
387405
Result<const TableMetadata*> finalize_result =
388406
commit_result.has_value()
389407
? Result<const TableMetadata*>(commit_result.value()->metadata().get())
390408
: std::unexpected(commit_result.error());
391-
392-
for (const auto& update : pending_updates_) {
393-
std::ignore = update->Finalize(finalize_result);
394-
}
409+
FinalizeUpdates(finalize_result);
395410

396411
ICEBERG_RETURN_UNEXPECTED(commit_result);
397412

@@ -402,6 +417,16 @@ Result<std::shared_ptr<Table>> Transaction::Commit() {
402417
return ctx_->table;
403418
}
404419

420+
void Transaction::FinalizeUpdates(const Result<const TableMetadata*>& commit_result) {
421+
if (finalized_) {
422+
return;
423+
}
424+
finalized_ = true;
425+
for (const auto& update : pending_updates_) {
426+
std::ignore = update->Finalize(commit_result);
427+
}
428+
}
429+
405430
Result<std::shared_ptr<Table>> Transaction::CommitOnce(bool is_first_attempt) {
406431
std::vector<std::unique_ptr<TableRequirement>> requirements;
407432

0 commit comments

Comments
 (0)