Skip to content

Commit 8d576ed

Browse files
authored
refactor: harden FileCleanupStrategy with retry and parallel deletes (#649)
### What * `FileCleanupStrategy` now takes an `OptionalExecutor` in its constructor. When `DeleteWith()` is configured, per-path deletes fan out through `TaskGroup<>` -- uses the supplied executor when set, otherwise runs the callbacks serially on the calling thread via `RunTasksSingleThreaded`. Preserves prior single-threaded behavior by default. * The `FileIO::DeleteFiles` bulk path is wrapped in `RetryRunner<retry::StopRetryOn<ErrorKind::kNotFound>>` with a tight budget (2 retries, 100ms-1s backoff, 5s total). Mirrors Java's `Tasks.foreach(...).stopRetryOn(NotFoundException.class).retry(N)`. The retry primarily helps atomic-bulk FileIO impls (e.g. an S3 `DeleteObjects`-backed FileIO) ride out transient throttles -- best-effort, see code comment for the fail-fast-iterative caveat. * New public builder `ExpireSnapshots::ExecuteDeleteWith(OptionalExecutor)` (named after Java's `executeDeleteWith(ExecutorService)`) so callers opt in to parallel deletion; threaded through both `IncrementalFileCleanup` and `ReachableFileCleanup`. * `DeleteWith()` doc note clarifies that the user-supplied callback may be invoked concurrently from worker threads and must be thread-safe. * Drops the `std::async` / `std::thread` / `std::span` machinery and the ad-hoc retry loop -- replaced by `util/task_group.h` and `util/retry_util.h` from #687. ### Test coverage Existing 25 `ExpireSnapshots*` tests continue to pass unchanged (they hit the default no-executor path). Adds `ExpireSnapshotsCleanupTest.ExecutorDispatchesDeletesConcurrently` -- wires a `test::ThreadExecutor` through `ExecuteDeleteWith()`, runs an `ExpireSnapshotId` cleanup with a custom `DeleteWith` callback, and asserts the executor received one submission per file (data + manifest + manifest-list = 3) and that all paths show up in the mutex-guarded sink.
1 parent 7324142 commit 8d576ed

3 files changed

Lines changed: 182 additions & 16 deletions

File tree

src/iceberg/test/expire_snapshots_test.cc

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,12 @@
1919

2020
#include "iceberg/update/expire_snapshots.h"
2121

22+
#include <functional>
23+
#include <mutex>
2224
#include <optional>
25+
#include <stdexcept>
2326
#include <string>
27+
#include <unordered_map>
2428
#include <vector>
2529

2630
#include <gmock/gmock.h>
@@ -33,6 +37,7 @@
3337
#include "iceberg/snapshot.h"
3438
#include "iceberg/statistics_file.h"
3539
#include "iceberg/table_metadata.h"
40+
#include "iceberg/test/executor.h"
3641
#include "iceberg/test/matchers.h"
3742
#include "iceberg/test/update_test_base.h"
3843

@@ -444,6 +449,115 @@ TEST_F(ExpireSnapshotsCleanupTest, DeletesExpiredFiles) {
444449
expired_manifest_list_path));
445450
}
446451

452+
TEST_F(ExpireSnapshotsCleanupTest, ExecutorDispatchesDeletesConcurrently) {
453+
const auto expired_data_file_path = table_location_ + "/data/expired-data.parquet";
454+
const auto expired_data_manifest_path = table_location_ + "/metadata/expired-data.avro";
455+
const auto expired_manifest_list_path =
456+
table_location_ + "/metadata/expired-manifest-list.avro";
457+
const auto current_manifest_list_path =
458+
table_location_ + "/metadata/current-manifest-list.avro";
459+
460+
auto expired_data_manifest = WriteDataManifest(
461+
expired_data_manifest_path, kExpiredSnapshotId,
462+
{MakeEntry(ManifestStatus::kAdded, kExpiredSnapshotId, kExpiredSequenceNumber,
463+
MakeDataFile(expired_data_file_path))});
464+
WriteManifestList(expired_manifest_list_path, kExpiredSnapshotId,
465+
/*parent_snapshot_id=*/0, kExpiredSequenceNumber,
466+
{expired_data_manifest});
467+
WriteManifestList(current_manifest_list_path, kCurrentSnapshotId, kExpiredSnapshotId,
468+
kCurrentSequenceNumber, {});
469+
RewriteTableWithManifestLists(expired_manifest_list_path, current_manifest_list_path);
470+
471+
test::ThreadExecutor executor;
472+
std::mutex deleted_files_mu;
473+
std::vector<std::string> deleted_files;
474+
475+
ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewExpireSnapshots());
476+
update->ExpireSnapshotId(kExpiredSnapshotId);
477+
update->ExecuteDeleteWith(std::ref(executor));
478+
update->DeleteWith([&deleted_files, &deleted_files_mu](const std::string& path) {
479+
std::lock_guard<std::mutex> lock(deleted_files_mu);
480+
deleted_files.push_back(path);
481+
});
482+
483+
EXPECT_THAT(update->Commit(), IsOk());
484+
EXPECT_THAT(deleted_files, testing::UnorderedElementsAre(expired_data_file_path,
485+
expired_data_manifest_path,
486+
expired_manifest_list_path));
487+
EXPECT_EQ(executor.submit_count(), 3);
488+
}
489+
490+
TEST_F(ExpireSnapshotsCleanupTest, ExecuteDeleteWithWithoutDeleteWithDoesNotUseExecutor) {
491+
const auto expired_data_file_path = table_location_ + "/data/expired-data.parquet";
492+
const auto expired_data_manifest_path = table_location_ + "/metadata/expired-data.avro";
493+
const auto expired_manifest_list_path =
494+
table_location_ + "/metadata/expired-manifest-list.avro";
495+
const auto current_manifest_list_path =
496+
table_location_ + "/metadata/current-manifest-list.avro";
497+
498+
auto expired_data_manifest = WriteDataManifest(
499+
expired_data_manifest_path, kExpiredSnapshotId,
500+
{MakeEntry(ManifestStatus::kAdded, kExpiredSnapshotId, kExpiredSequenceNumber,
501+
MakeDataFile(expired_data_file_path))});
502+
WriteManifestList(expired_manifest_list_path, kExpiredSnapshotId,
503+
/*parent_snapshot_id=*/0, kExpiredSequenceNumber,
504+
{expired_data_manifest});
505+
WriteManifestList(current_manifest_list_path, kCurrentSnapshotId, kExpiredSnapshotId,
506+
kCurrentSequenceNumber, {});
507+
RewriteTableWithManifestLists(expired_manifest_list_path, current_manifest_list_path);
508+
509+
test::ThreadExecutor executor(ServiceUnavailable("executor should be unused"));
510+
511+
ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewExpireSnapshots());
512+
update->ExpireSnapshotId(kExpiredSnapshotId);
513+
update->ExecuteDeleteWith(std::ref(executor));
514+
515+
EXPECT_THAT(update->Commit(), IsOk());
516+
EXPECT_EQ(executor.submit_count(), 0);
517+
}
518+
519+
TEST_F(ExpireSnapshotsCleanupTest, DeleteWithRetriesTransientFailures) {
520+
const auto expired_data_file_path = table_location_ + "/data/expired-data.parquet";
521+
const auto expired_data_manifest_path = table_location_ + "/metadata/expired-data.avro";
522+
const auto expired_manifest_list_path =
523+
table_location_ + "/metadata/expired-manifest-list.avro";
524+
const auto current_manifest_list_path =
525+
table_location_ + "/metadata/current-manifest-list.avro";
526+
527+
auto expired_data_manifest = WriteDataManifest(
528+
expired_data_manifest_path, kExpiredSnapshotId,
529+
{MakeEntry(ManifestStatus::kAdded, kExpiredSnapshotId, kExpiredSequenceNumber,
530+
MakeDataFile(expired_data_file_path))});
531+
WriteManifestList(expired_manifest_list_path, kExpiredSnapshotId,
532+
/*parent_snapshot_id=*/0, kExpiredSequenceNumber,
533+
{expired_data_manifest});
534+
WriteManifestList(current_manifest_list_path, kCurrentSnapshotId, kExpiredSnapshotId,
535+
kCurrentSequenceNumber, {});
536+
RewriteTableWithManifestLists(expired_manifest_list_path, current_manifest_list_path);
537+
538+
std::unordered_map<std::string, int> attempts_by_path;
539+
std::vector<std::string> deleted_files;
540+
541+
ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewExpireSnapshots());
542+
update->ExpireSnapshotId(kExpiredSnapshotId);
543+
update->DeleteWith([&attempts_by_path, &deleted_files](const std::string& path) {
544+
auto& attempts = attempts_by_path[path];
545+
++attempts;
546+
if (attempts == 1) {
547+
throw std::runtime_error("transient delete failure");
548+
}
549+
deleted_files.push_back(path);
550+
});
551+
552+
EXPECT_THAT(update->Commit(), IsOk());
553+
EXPECT_THAT(deleted_files, testing::UnorderedElementsAre(expired_data_file_path,
554+
expired_data_manifest_path,
555+
expired_manifest_list_path));
556+
EXPECT_EQ(attempts_by_path[expired_data_file_path], 2);
557+
EXPECT_EQ(attempts_by_path[expired_data_manifest_path], 2);
558+
EXPECT_EQ(attempts_by_path[expired_manifest_list_path], 2);
559+
}
560+
447561
TEST_F(ExpireSnapshotsCleanupTest, MetadataOnlySkipsDataDeletion) {
448562
const auto expired_data_file_path = table_location_ + "/data/expired-data.parquet";
449563
const auto expired_delete_manifest_path =

src/iceberg/update/expire_snapshots.cc

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
#include <algorithm>
2323
#include <cstdint>
24+
#include <exception>
2425
#include <iterator>
2526
#include <memory>
2627
#include <string>
@@ -41,8 +42,10 @@
4142
#include "iceberg/transaction.h"
4243
#include "iceberg/util/error_collector.h"
4344
#include "iceberg/util/macros.h"
45+
#include "iceberg/util/retry_util.h"
4446
#include "iceberg/util/snapshot_util_internal.h"
4547
#include "iceberg/util/string_util.h"
48+
#include "iceberg/util/task_group.h"
4649

4750
namespace iceberg {
4851

@@ -61,8 +64,11 @@ Result<std::unique_ptr<ManifestReader>> MakeManifestReader(
6164
class FileCleanupStrategy {
6265
public:
6366
FileCleanupStrategy(std::shared_ptr<FileIO> file_io,
64-
std::function<void(const std::string&)> delete_func)
65-
: file_io_(std::move(file_io)), delete_func_(std::move(delete_func)) {}
67+
std::function<void(const std::string&)> delete_func,
68+
OptionalExecutor executor)
69+
: file_io_(std::move(file_io)),
70+
delete_func_(std::move(delete_func)),
71+
executor_(std::move(executor)) {}
6672

6773
virtual ~FileCleanupStrategy() = default;
6874

@@ -94,20 +100,36 @@ class FileCleanupStrategy {
94100
return expired;
95101
}
96102

97-
/// \brief Delete files at the given locations.
103+
/// \brief Best-effort delete with bounded retry.
98104
void DeleteFiles(const std::unordered_set<std::string>& paths) {
99-
try {
100-
if (delete_func_) {
101-
for (const auto& path : paths) {
105+
if (paths.empty()) return;
106+
107+
if (!delete_func_) {
108+
std::vector<std::string> path_list(paths.begin(), paths.end());
109+
110+
TaskGroup<retry::StopRetryOn<ErrorKind::kNotFound>> group(kDeleteRetryConfig);
111+
group.Submit([this, paths = std::move(path_list)]() -> Status {
112+
return file_io_->DeleteFiles(paths);
113+
});
114+
std::ignore = std::move(group).Run();
115+
return;
116+
}
117+
118+
TaskGroup<retry::StopRetryOn<ErrorKind::kNotFound>> group(kDeleteRetryConfig);
119+
group.SetExecutor(executor_);
120+
for (const auto& path : paths) {
121+
group.Submit([this, path]() -> Status {
122+
try {
102123
delete_func_(path);
124+
return {};
125+
} catch (const std::exception& e) {
126+
return IOError("Delete callback failed for {}: {}", path, e.what());
127+
} catch (...) {
128+
return IOError("Delete callback failed for {}", path);
103129
}
104-
} else {
105-
std::vector<std::string> path_list(paths.begin(), paths.end());
106-
std::ignore = file_io_->DeleteFiles(path_list);
107-
}
108-
} catch (...) {
109-
// TODO(shangxinli): add retry
130+
});
110131
}
132+
std::ignore = std::move(group).Run();
111133
}
112134

113135
bool HasAnyStatisticsFiles(const TableMetadata& metadata) const {
@@ -149,6 +171,18 @@ class FileCleanupStrategy {
149171

150172
std::shared_ptr<FileIO> file_io_;
151173
std::function<void(const std::string&)> delete_func_;
174+
OptionalExecutor executor_;
175+
176+
private:
177+
/// Retry budget for the FileIO bulk `DeleteFiles` path. Tight on purpose: file
178+
/// cleanup is best-effort and runs after a successful commit, so we'd rather give
179+
/// up than block the caller for minutes on a flaky storage layer.
180+
static constexpr RetryConfig kDeleteRetryConfig{
181+
.num_retries = 2,
182+
.min_wait_ms = 100,
183+
.max_wait_ms = 1000,
184+
.total_timeout_ms = 5000,
185+
};
152186
};
153187

154188
/// \brief File cleanup strategy that determines safe deletions via full reachability.
@@ -157,7 +191,7 @@ class FileCleanupStrategy {
157191
/// still referenced by retained snapshots, then deletes orphaned manifests, data
158192
/// files, and manifest lists.
159193
///
160-
/// TODO(shangxinli): Add multi-threaded manifest reading and file deletion support.
194+
/// TODO(shangxinli): Add multi-threaded manifest reading support.
161195
class ReachableFileCleanup : public FileCleanupStrategy {
162196
public:
163197
using FileCleanupStrategy::FileCleanupStrategy;
@@ -362,7 +396,7 @@ class ReachableFileCleanup : public FileCleanupStrategy {
362396
/// logically introduced by a snapshot whose changes are still present in the
363397
/// current state under a different id.
364398
///
365-
/// TODO(shangxinli): Add multi-threaded manifest reading and file deletion support.
399+
/// TODO(shangxinli): Add multi-threaded manifest reading support.
366400
class IncrementalFileCleanup : public FileCleanupStrategy {
367401
public:
368402
using FileCleanupStrategy::FileCleanupStrategy;
@@ -699,6 +733,11 @@ ExpireSnapshots& ExpireSnapshots::CleanExpiredMetadata(bool clean) {
699733
return *this;
700734
}
701735

736+
ExpireSnapshots& ExpireSnapshots::ExecuteDeleteWith(OptionalExecutor executor) {
737+
executor_ = std::move(executor);
738+
return *this;
739+
}
740+
702741
Result<std::unordered_set<int64_t>> ExpireSnapshots::ComputeBranchSnapshotsToRetain(
703742
int64_t snapshot_id, TimePointMs expire_snapshot_older_than,
704743
int32_t min_snapshots_to_keep) const {
@@ -930,11 +969,11 @@ Status ExpireSnapshots::Finalize(Result<const TableMetadata*> commit_result) {
930969
!HasNonMainSnapshots(metadata_after_expiration);
931970

932971
if (can_use_incremental) {
933-
return IncrementalFileCleanup(ctx_->table->io(), delete_func_)
972+
return IncrementalFileCleanup(ctx_->table->io(), delete_func_, executor_)
934973
.CleanFiles(metadata_before_expiration, metadata_after_expiration,
935974
cleanup_level_);
936975
}
937-
return ReachableFileCleanup(ctx_->table->io(), delete_func_)
976+
return ReachableFileCleanup(ctx_->table->io(), delete_func_, executor_)
938977
.CleanFiles(metadata_before_expiration, metadata_after_expiration, cleanup_level_);
939978
}
940979

src/iceberg/update/expire_snapshots.h

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
#include "iceberg/result.h"
3333
#include "iceberg/type_fwd.h"
3434
#include "iceberg/update/pending_update.h"
35+
#include "iceberg/util/executor.h"
3536
#include "iceberg/util/timepoint.h"
3637

3738
/// \file iceberg/update/expire_snapshots.h
@@ -115,6 +116,8 @@ class ICEBERG_EXPORT ExpireSnapshots : public PendingUpdate {
115116
/// If this method is not called, unnecessary manifests and data files will still be
116117
/// deleted.
117118
///
119+
/// \note With ExecuteDeleteWith(), callbacks may run concurrently.
120+
///
118121
/// \param delete_func A function that will be called to delete manifests and data files
119122
/// \return Reference to this for method chaining.
120123
ExpireSnapshots& DeleteWith(std::function<void(const std::string&)> delete_func);
@@ -140,6 +143,15 @@ class ICEBERG_EXPORT ExpireSnapshots : public PendingUpdate {
140143
/// \return Reference to this for method chaining.
141144
ExpireSnapshots& CleanExpiredMetadata(bool clean);
142145

146+
/// \brief Configure an executor for DeleteWith() callbacks.
147+
///
148+
/// Only used with DeleteWith(). The caller must keep the executor alive until
149+
/// Finalize() returns.
150+
///
151+
/// \param executor An executor reference, or std::nullopt for serial deletion.
152+
/// \return Reference to this for method chaining.
153+
ExpireSnapshots& ExecuteDeleteWith(OptionalExecutor executor);
154+
143155
Kind kind() const final { return Kind::kExpireSnapshots; }
144156
bool IsRetryable() const override { return true; }
145157

@@ -184,6 +196,7 @@ class ICEBERG_EXPORT ExpireSnapshots : public PendingUpdate {
184196
enum CleanupLevel cleanup_level_ { CleanupLevel::kAll };
185197
bool clean_expired_metadata_{false};
186198
bool specified_snapshot_id_{false};
199+
OptionalExecutor executor_;
187200

188201
/// Cached result from Apply(), consumed by Finalize() and cleared after use.
189202
std::optional<ApplyResult> apply_result_;

0 commit comments

Comments
 (0)