Skip to content

Commit 01d4bfa

Browse files
committed
fix: unify pending update lifecycle
1 parent 72df100 commit 01d4bfa

7 files changed

Lines changed: 171 additions & 44 deletions

File tree

src/iceberg/test/fast_append_test.cc

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,26 @@ TEST_F(FastAppendTest, FinalizeIgnoresCleanupDeleteFailure) {
307307
IsOk());
308308
}
309309

310+
TEST_F(FastAppendTest, TransactionApplyFailureCleansUpStagedFiles) {
311+
ICEBERG_UNWRAP_OR_FAIL(auto txn, table_->NewTransaction());
312+
ICEBERG_UNWRAP_OR_FAIL(auto fast_append, txn->NewFastAppend());
313+
std::vector<std::string> deleted_paths;
314+
fast_append->DeleteWith([&](const std::string& path) {
315+
deleted_paths.push_back(path);
316+
return file_io_->DeleteFile(path);
317+
});
318+
fast_append->AppendFile(file_a_);
319+
320+
EXPECT_THAT(static_cast<SnapshotUpdate&>(*fast_append).Apply(), IsOk());
321+
fast_append->AppendFile(nullptr);
322+
323+
EXPECT_THAT(fast_append->Commit(), IsError(ErrorKind::kValidationFailed));
324+
EXPECT_THAT(deleted_paths, ::testing::SizeIs(2U));
325+
EXPECT_THAT(txn->Commit(),
326+
::testing::AllOf(IsError(ErrorKind::kValidationFailed),
327+
HasErrorMessage("Transaction already finalized")));
328+
}
329+
310330
TEST_F(FastAppendTest, RetryCopiesAppendManifestAgain) {
311331
table_->metadata()->format_version = 1;
312332
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
@@ -110,11 +110,11 @@ class ScopedPuffinDVIORegistry {
110110
/// \brief Concrete subclass of MergingSnapshotUpdate for testing.
111111
class TestMergeAppend : public MergingSnapshotUpdate {
112112
public:
113-
static Result<std::unique_ptr<TestMergeAppend>> Make(std::string table_name,
113+
static Result<std::shared_ptr<TestMergeAppend>> Make(std::string table_name,
114114
std::shared_ptr<Table> table) {
115115
ICEBERG_ASSIGN_OR_RAISE(
116116
auto ctx, TransactionContext::Make(std::move(table), TransactionKind::kUpdate));
117-
return std::unique_ptr<TestMergeAppend>(
117+
return std::shared_ptr<TestMergeAppend>(
118118
new TestMergeAppend(std::move(table_name), std::move(ctx)));
119119
}
120120

@@ -256,11 +256,11 @@ class TestMergeAppend : public MergingSnapshotUpdate {
256256

257257
class TestOverwriteUpdate : public MergingSnapshotUpdate {
258258
public:
259-
static Result<std::unique_ptr<TestOverwriteUpdate>> Make(std::string table_name,
259+
static Result<std::shared_ptr<TestOverwriteUpdate>> Make(std::string table_name,
260260
std::shared_ptr<Table> table) {
261261
ICEBERG_ASSIGN_OR_RAISE(
262262
auto ctx, TransactionContext::Make(std::move(table), TransactionKind::kUpdate));
263-
return std::unique_ptr<TestOverwriteUpdate>(
263+
return std::shared_ptr<TestOverwriteUpdate>(
264264
new TestOverwriteUpdate(std::move(table_name), std::move(ctx)));
265265
}
266266

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

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

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

@@ -983,7 +983,7 @@ class MergingSnapshotUpdateV1Test : public UpdateTestBase {
983983
return f;
984984
}
985985

986-
Result<std::unique_ptr<TestMergeAppend>> NewMergeAppend() {
986+
Result<std::shared_ptr<TestMergeAppend>> NewMergeAppend() {
987987
return TestMergeAppend::Make(TableName(), table_);
988988
}
989989

src/iceberg/test/transaction_test.cc

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,22 @@
1919

2020
#include "iceberg/transaction.h"
2121

22+
#include <format>
23+
#include <memory>
24+
#include <string>
25+
#include <vector>
26+
2227
#include "iceberg/expression/expressions.h"
2328
#include "iceberg/expression/term.h"
2429
#include "iceberg/sort_order.h"
30+
#include "iceberg/table_metadata.h"
2531
#include "iceberg/test/matchers.h"
2632
#include "iceberg/test/mock_catalog.h"
2733
#include "iceberg/test/update_test_base.h"
2834
#include "iceberg/transform.h"
2935
#include "iceberg/type.h"
36+
#include "iceberg/update/fast_append.h"
37+
#include "iceberg/update/set_snapshot.h"
3038
#include "iceberg/update/update_properties.h"
3139
#include "iceberg/update/update_schema.h"
3240
#include "iceberg/update/update_sort_order.h"
@@ -46,6 +54,47 @@ TEST_F(TransactionTest, CommitEmptyTransaction) {
4654
EXPECT_THAT(txn->Commit(), IsOk());
4755
}
4856

57+
TEST_F(TransactionTest, TemporaryTransactionDoesNotAttachToContext) {
58+
ICEBERG_UNWRAP_OR_FAIL(auto ctx,
59+
TransactionContext::Make(table_, TransactionKind::kUpdate));
60+
ASSERT_FALSE(ctx->transaction.has_value());
61+
62+
ICEBERG_UNWRAP_OR_FAIL(auto txn, Transaction::Make(ctx));
63+
64+
EXPECT_NE(txn, nullptr);
65+
EXPECT_FALSE(ctx->transaction.has_value());
66+
}
67+
68+
TEST_F(TransactionTest, StandaloneCommitRequiresSharedOwnership) {
69+
ICEBERG_UNWRAP_OR_FAIL(auto ctx,
70+
TransactionContext::Make(table_, TransactionKind::kUpdate));
71+
ICEBERG_UNWRAP_OR_FAIL(auto update, FastAppend::Make(table_->name().name, ctx));
72+
73+
EXPECT_THAT(update->Commit(),
74+
::testing::AllOf(
75+
IsError(ErrorKind::kInvalidArgument),
76+
HasErrorMessage("PendingUpdate must be owned by std::shared_ptr")));
77+
}
78+
79+
TEST_F(TransactionTest, CommitNoOpUpdate) {
80+
ICEBERG_UNWRAP_OR_FAIL(auto txn, table_->NewTransaction());
81+
ICEBERG_UNWRAP_OR_FAIL(auto update, txn->NewSetSnapshot());
82+
83+
EXPECT_THAT(update->Commit(), IsOk());
84+
EXPECT_THAT(txn->Commit(), IsOk());
85+
}
86+
87+
TEST_F(TransactionTest, ApplyFailureFinalizesTransaction) {
88+
ICEBERG_UNWRAP_OR_FAIL(auto txn, table_->NewTransaction());
89+
ICEBERG_UNWRAP_OR_FAIL(auto update, txn->NewFastAppend());
90+
update->AppendFile(nullptr);
91+
92+
EXPECT_THAT(update->Commit(), IsError(ErrorKind::kValidationFailed));
93+
EXPECT_THAT(txn->Commit(),
94+
::testing::AllOf(IsError(ErrorKind::kValidationFailed),
95+
HasErrorMessage("Transaction already finalized")));
96+
}
97+
4998
TEST_F(TransactionTest, CommitTransactionWithPropertyUpdate) {
5099
ICEBERG_UNWRAP_OR_FAIL(auto txn, table_->NewTransaction());
51100
ICEBERG_UNWRAP_OR_FAIL(auto update, txn->NewUpdateProperties());
@@ -151,6 +200,39 @@ TEST_F(TransactionRetryTest, CommitRetrySucceedsAfterConflict) {
151200
EXPECT_EQ(update_call_count, 2);
152201
}
153202

203+
TEST_F(TransactionRetryTest, StandaloneCommitRetryReappliesUpdate) {
204+
std::vector<size_t> update_counts;
205+
ON_CALL(*mock_catalog_, UpdateTable(::testing::_, ::testing::_, ::testing::_))
206+
.WillByDefault(
207+
[this, &update_counts](const TableIdentifier&,
208+
const std::vector<std::unique_ptr<TableRequirement>>&,
209+
const std::vector<std::unique_ptr<TableUpdate>>& updates)
210+
-> Result<std::shared_ptr<Table>> {
211+
update_counts.push_back(updates.size());
212+
if (update_counts.size() == 1) {
213+
return CommitFailed("conflict on first attempt");
214+
}
215+
return Table::Make(mock_table_->name(), mock_table_->metadata(),
216+
std::string(mock_table_->metadata_file_location()),
217+
mock_table_->io(), mock_catalog_);
218+
});
219+
EXPECT_CALL(*mock_catalog_, LoadTable(::testing::_))
220+
.WillOnce([this](const TableIdentifier&) -> Result<std::shared_ptr<Table>> {
221+
auto builder = TableMetadataBuilder::BuildFrom(mock_table_->metadata().get());
222+
ICEBERG_ASSIGN_OR_RAISE(auto metadata, builder->Build());
223+
return Table::Make(
224+
mock_table_->name(), std::shared_ptr<TableMetadata>(std::move(metadata)),
225+
std::format("{}.refreshed", mock_table_->metadata_file_location()),
226+
mock_table_->io(), mock_catalog_);
227+
});
228+
229+
ICEBERG_UNWRAP_OR_FAIL(auto update, mock_table_->NewUpdateProperties());
230+
update->Set("retry.test", "value");
231+
232+
EXPECT_THAT(update->Commit(), IsOk());
233+
EXPECT_THAT(update_counts, ::testing::ElementsAre(1U, 1U));
234+
}
235+
154236
TEST_F(TransactionRetryTest, CommitRetryExhausted) {
155237
int update_call_count = 0;
156238
ON_CALL(*mock_catalog_, UpdateTable(::testing::_, ::testing::_, ::testing::_))

src/iceberg/transaction.cc

Lines changed: 36 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,7 @@ Result<std::shared_ptr<Transaction>> Transaction::Make(std::shared_ptr<Table> ta
123123
Result<std::shared_ptr<Transaction>> Transaction::Make(
124124
std::shared_ptr<TransactionContext> ctx) {
125125
ICEBERG_PRECHECK(ctx != nullptr, "TransactionContext cannot be null");
126-
auto txn = std::shared_ptr<Transaction>(new Transaction(ctx));
127-
ctx->transaction = std::weak_ptr<Transaction>(txn);
128-
return txn;
126+
return std::shared_ptr<Transaction>(new Transaction(std::move(ctx)));
129127
}
130128

131129
const std::shared_ptr<Table>& Transaction::table() const { return ctx_->table; }
@@ -139,6 +137,8 @@ std::string Transaction::MetadataFileLocation(std::string_view filename) const {
139137
}
140138

141139
Status Transaction::AddUpdate(const std::shared_ptr<PendingUpdate>& update) {
140+
ICEBERG_CHECK(!committed_, "Cannot add update to a committed transaction");
141+
ICEBERG_CHECK(!finalized_, "Cannot add update to a finalized transaction");
142142
ICEBERG_CHECK(last_update_committed_,
143143
"Cannot add update when previous update is not committed");
144144

@@ -148,6 +148,9 @@ Status Transaction::AddUpdate(const std::shared_ptr<PendingUpdate>& update) {
148148
}
149149

150150
Status Transaction::Apply(PendingUpdate& update) {
151+
ICEBERG_CHECK(!committed_, "Cannot apply update to a committed transaction");
152+
ICEBERG_CHECK(!finalized_, "Cannot apply update to a finalized transaction");
153+
151154
switch (update.kind()) {
152155
case PendingUpdate::Kind::kExpireSnapshots:
153156
ICEBERG_RETURN_UNEXPECTED(
@@ -359,40 +362,36 @@ Status Transaction::ApplyUpdatePartitionStatistics(UpdatePartitionStatistics& up
359362

360363
Result<std::shared_ptr<Table>> Transaction::Commit() {
361364
ICEBERG_CHECK(!committed_, "Transaction already committed");
365+
ICEBERG_CHECK(!finalized_, "Transaction already finalized");
362366
ICEBERG_CHECK(last_update_committed_,
363367
"Cannot commit transaction when previous update is not committed");
364368

365369
const auto& updates = ctx_->metadata_builder->changes();
366-
if (updates.empty()) {
367-
committed_ = true;
368-
return ctx_->table;
370+
Result<std::shared_ptr<Table>> commit_result = ctx_->table;
371+
if (!updates.empty()) {
372+
const auto& props = ctx_->table->properties();
373+
int32_t num_retries =
374+
CanRetry() ? static_cast<int32_t>(props.Get(TableProperties::kCommitNumRetries))
375+
: 0;
376+
int32_t min_wait_ms = props.Get(TableProperties::kCommitMinRetryWaitMs);
377+
int32_t max_wait_ms = props.Get(TableProperties::kCommitMaxRetryWaitMs);
378+
int32_t total_timeout_ms = props.Get(TableProperties::kCommitTotalRetryTimeMs);
379+
380+
bool is_first_attempt = true;
381+
commit_result =
382+
MakeCommitRetryRunner(num_retries, min_wait_ms, max_wait_ms, total_timeout_ms)
383+
.Run([this, &is_first_attempt]() -> Result<std::shared_ptr<Table>> {
384+
auto result = CommitOnce(is_first_attempt);
385+
is_first_attempt = false;
386+
return result;
387+
});
369388
}
370389

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

397396
ICEBERG_RETURN_UNEXPECTED(commit_result);
398397

@@ -403,6 +402,16 @@ Result<std::shared_ptr<Table>> Transaction::Commit() {
403402
return ctx_->table;
404403
}
405404

405+
void Transaction::FinalizeUpdates(const Result<const TableMetadata*>& commit_result) {
406+
if (finalized_) {
407+
return;
408+
}
409+
finalized_ = true;
410+
for (const auto& update : pending_updates_) {
411+
std::ignore = update->Finalize(commit_result);
412+
}
413+
}
414+
406415
Result<std::shared_ptr<Table>> Transaction::CommitOnce(bool is_first_attempt) {
407416
std::vector<std::unique_ptr<TableRequirement>> requirements;
408417

src/iceberg/transaction.h

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ class ICEBERG_EXPORT Transaction : public std::enable_shared_from_this<Transacti
4848
static Result<std::shared_ptr<Transaction>> Make(std::shared_ptr<Table> table,
4949
TransactionKind kind);
5050

51-
/// \brief Create a transaction from an existing context (used by PendingUpdate::Commit)
51+
/// \brief Create a detached transaction from an existing context.
52+
///
53+
/// This overload is used by PendingUpdate::Commit for standalone updates and does not
54+
/// attach the temporary transaction to the context.
5255
static Result<std::shared_ptr<Transaction>> Make(
5356
std::shared_ptr<TransactionContext> ctx);
5457

@@ -163,6 +166,9 @@ class ICEBERG_EXPORT Transaction : public std::enable_shared_from_this<Transacti
163166
/// \brief Whether this transaction can retry after a commit conflict.
164167
bool CanRetry() const;
165168

169+
/// \brief Finalize all registered updates exactly once.
170+
void FinalizeUpdates(const Result<const TableMetadata*>& commit_result);
171+
166172
private:
167173
friend class PendingUpdate;
168174

@@ -174,6 +180,8 @@ class ICEBERG_EXPORT Transaction : public std::enable_shared_from_this<Transacti
174180
bool last_update_committed_ = true;
175181
// Tracks if transaction has been committed to prevent double-commit
176182
bool committed_ = false;
183+
// Tracks whether registered updates have reached a terminal state.
184+
bool finalized_ = false;
177185
};
178186

179187
/// \brief Shared context between Transaction and PendingUpdate instances.

src/iceberg/update/pending_update.cc

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,27 +34,33 @@ PendingUpdate::~PendingUpdate() = default;
3434
Status PendingUpdate::Commit() {
3535
if (!ctx_->transaction) {
3636
// Table-created path: no transaction exists yet, create a temporary one.
37+
auto update = weak_from_this().lock();
38+
ICEBERG_PRECHECK(update != nullptr, "PendingUpdate must be owned by std::shared_ptr");
39+
3740
ICEBERG_ASSIGN_OR_RAISE(auto txn, Transaction::Make(ctx_));
41+
ICEBERG_RETURN_UNEXPECTED(txn->AddUpdate(update));
42+
3843
auto apply_status = txn->Apply(*this);
3944
if (!apply_status.has_value()) {
40-
std::ignore = Finalize(std::unexpected(apply_status.error()));
45+
txn->FinalizeUpdates(std::unexpected(apply_status.error()));
4146
return apply_status;
4247
}
4348

4449
auto commit_result = txn->Commit();
45-
if (!commit_result.has_value()) {
46-
std::ignore = Finalize(std::unexpected(commit_result.error()));
47-
return std::unexpected(commit_result.error());
48-
}
49-
50-
std::ignore = Finalize(commit_result.value()->metadata().get());
50+
ICEBERG_RETURN_UNEXPECTED(commit_result);
5151
return {};
5252
}
53+
5354
auto txn = ctx_->transaction->lock();
5455
if (!txn) {
5556
return CommitFailed("Transaction has been destroyed");
5657
}
57-
return txn->Apply(*this);
58+
59+
auto apply_status = txn->Apply(*this);
60+
if (!apply_status.has_value()) {
61+
txn->FinalizeUpdates(std::unexpected(apply_status.error()));
62+
}
63+
return apply_status;
5864
}
5965

6066
Status PendingUpdate::Finalize(

src/iceberg/update/pending_update.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ namespace iceberg {
3838
///
3939
/// \note Implementations are expected to use builder pattern and errors
4040
/// should be handled by the ErrorCollector base class.
41-
class ICEBERG_EXPORT PendingUpdate : public ErrorCollector {
41+
class ICEBERG_EXPORT PendingUpdate : public ErrorCollector,
42+
public std::enable_shared_from_this<PendingUpdate> {
4243
public:
4344
enum class Kind : uint8_t {
4445
kExpireSnapshots,
@@ -66,6 +67,7 @@ class ICEBERG_EXPORT PendingUpdate : public ErrorCollector {
6667
/// - ValidationFailed: if it cannot be applied to the current table metadata.
6768
/// - CommitFailed: if it cannot be committed due to conflicts.
6869
/// - CommitStateUnknown: unknown status, no cleanup should be done.
70+
/// \note The update must be owned by a `std::shared_ptr` before calling Commit().
6971
virtual Status Commit();
7072

7173
/// \brief Finalize the pending update.

0 commit comments

Comments
 (0)