Skip to content

Commit 7a329ad

Browse files
committed
refactor: harden FileCleanupStrategy with retry and parallel deletes
Rebases onto apache#687's executor pool support per @wgtmac's review: * FileCleanupStrategy now takes an OptionalExecutor in its constructor. When the custom DeleteWith() callback is configured, per-path deletes fan out through a TaskGroup that uses the executor (or runs serially when none is set, preserving prior behavior). * The FileIO bulk delete path wraps file_io_->DeleteFiles in a tight RetryRunner<retry::StopRetryOn<kNotFound>> so transient FileIO errors no longer give up after the first attempt. Mirrors Java's Tasks.foreach(...).stopRetryOn(NotFoundException.class).retry(N). * Adds ExpireSnapshots::Executor(OptionalExecutor) so callers can opt in to parallel deletion, and threads it down through both IncrementalFileCleanup and ReachableFileCleanup. * 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 apache#687. Adds ExecutorDispatchesDeletesConcurrently test that wires a test::ThreadExecutor through the new API and asserts the executor received one submission per file.
1 parent ae29c3d commit 7a329ad

3 files changed

Lines changed: 118 additions & 15 deletions

File tree

src/iceberg/test/expire_snapshots_test.cc

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

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

22+
#include <functional>
23+
#include <mutex>
2224
#include <optional>
2325
#include <string>
2426
#include <vector>
@@ -33,6 +35,7 @@
3335
#include "iceberg/snapshot.h"
3436
#include "iceberg/statistics_file.h"
3537
#include "iceberg/table_metadata.h"
38+
#include "iceberg/test/executor.h"
3639
#include "iceberg/test/matchers.h"
3740
#include "iceberg/test/update_test_base.h"
3841

@@ -444,6 +447,45 @@ TEST_F(ExpireSnapshotsCleanupTest, DeletesExpiredFiles) {
444447
expired_manifest_list_path));
445448
}
446449

450+
TEST_F(ExpireSnapshotsCleanupTest, ExecutorDispatchesDeletesConcurrently) {
451+
const auto expired_data_file_path = table_location_ + "/data/expired-data.parquet";
452+
const auto expired_data_manifest_path = table_location_ + "/metadata/expired-data.avro";
453+
const auto expired_manifest_list_path =
454+
table_location_ + "/metadata/expired-manifest-list.avro";
455+
const auto current_manifest_list_path =
456+
table_location_ + "/metadata/current-manifest-list.avro";
457+
458+
auto expired_data_manifest = WriteDataManifest(
459+
expired_data_manifest_path, kExpiredSnapshotId,
460+
{MakeEntry(ManifestStatus::kAdded, kExpiredSnapshotId, kExpiredSequenceNumber,
461+
MakeDataFile(expired_data_file_path))});
462+
WriteManifestList(expired_manifest_list_path, kExpiredSnapshotId,
463+
/*parent_snapshot_id=*/0, kExpiredSequenceNumber,
464+
{expired_data_manifest});
465+
WriteManifestList(current_manifest_list_path, kCurrentSnapshotId, kExpiredSnapshotId,
466+
kCurrentSequenceNumber, {});
467+
RewriteTableWithManifestLists(expired_manifest_list_path, current_manifest_list_path);
468+
469+
test::ThreadExecutor executor;
470+
std::mutex deleted_files_mu;
471+
std::vector<std::string> deleted_files;
472+
473+
ICEBERG_UNWRAP_OR_FAIL(auto update, table_->NewExpireSnapshots());
474+
update->ExpireSnapshotId(kExpiredSnapshotId);
475+
update->Executor(std::ref(executor));
476+
update->DeleteWith([&deleted_files, &deleted_files_mu](const std::string& path) {
477+
std::lock_guard<std::mutex> lock(deleted_files_mu);
478+
deleted_files.push_back(path);
479+
});
480+
481+
EXPECT_THAT(update->Commit(), IsOk());
482+
EXPECT_THAT(deleted_files, testing::UnorderedElementsAre(expired_data_file_path,
483+
expired_data_manifest_path,
484+
expired_manifest_list_path));
485+
// One submission per file: the executor saw real work.
486+
EXPECT_EQ(executor.submit_count(), 3);
487+
}
488+
447489
TEST_F(ExpireSnapshotsCleanupTest, MetadataOnlySkipsDataDeletion) {
448490
const auto expired_data_file_path = table_location_ + "/data/expired-data.parquet";
449491
const auto expired_delete_manifest_path =

src/iceberg/update/expire_snapshots.cc

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,12 @@
4040
#include "iceberg/table_metadata.h"
4141
#include "iceberg/transaction.h"
4242
#include "iceberg/util/error_collector.h"
43+
#include "iceberg/util/executor.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

@@ -65,8 +68,11 @@ Result<std::unique_ptr<ManifestReader>> MakeManifestReader(
6568
class FileCleanupStrategy {
6669
public:
6770
FileCleanupStrategy(std::shared_ptr<FileIO> file_io,
68-
std::function<void(const std::string&)> delete_func)
69-
: file_io_(std::move(file_io)), delete_func_(std::move(delete_func)) {}
71+
std::function<void(const std::string&)> delete_func,
72+
OptionalExecutor executor)
73+
: file_io_(std::move(file_io)),
74+
delete_func_(std::move(delete_func)),
75+
executor_(std::move(executor)) {}
7076

7177
virtual ~FileCleanupStrategy() = default;
7278

@@ -99,19 +105,40 @@ class FileCleanupStrategy {
99105
}
100106

101107
/// \brief Delete files at the given locations.
108+
///
109+
/// Best-effort: errors are suppressed to mirror Java's suppressFailureWhenFinished.
110+
/// When a custom delete function was provided, deletes are invoked one path at a time,
111+
/// optionally parallelized via the strategy's executor. Otherwise the FileIO bulk
112+
/// `DeleteFiles` API is invoked once with a bounded retry that stops on kNotFound.
102113
void DeleteFiles(const std::unordered_set<std::string>& paths) {
103-
try {
104-
if (delete_func_) {
105-
for (const auto& path : paths) {
114+
if (paths.empty()) return;
115+
std::vector<std::string> path_list(paths.begin(), paths.end());
116+
117+
if (!delete_func_) {
118+
// Bulk path: rely on FileIO::DeleteFiles. A bounded retry rides out transient
119+
// FileIO errors; kNotFound is treated as success (the file was already gone).
120+
RetryRunner<retry::StopRetryOn<ErrorKind::kNotFound>> runner(kDeleteRetryConfig);
121+
std::ignore = runner.Run([&]() { return file_io_->DeleteFiles(path_list); });
122+
return;
123+
}
124+
125+
// Custom callback path: invoke one path at a time, optionally on a worker thread
126+
// pulled from the configured executor. Without an executor TaskGroup runs the
127+
// callbacks synchronously on the calling thread.
128+
TaskGroup<> group;
129+
group.SetExecutor(executor_);
130+
for (auto& path : path_list) {
131+
group.Submit([this, path = std::move(path)]() -> Status {
132+
try {
106133
delete_func_(path);
134+
} catch (...) {
135+
// Suppress all exceptions during file cleanup to match Java's
136+
// suppressFailureWhenFinished behavior.
107137
}
108-
} else {
109-
std::vector<std::string> path_list(paths.begin(), paths.end());
110-
std::ignore = file_io_->DeleteFiles(path_list);
111-
}
112-
} catch (...) {
113-
// TODO(shangxinli): add retry
138+
return {};
139+
});
114140
}
141+
std::ignore = std::move(group).Run();
115142
}
116143

117144
bool HasAnyStatisticsFiles(const TableMetadata& metadata) const {
@@ -153,6 +180,18 @@ class FileCleanupStrategy {
153180

154181
std::shared_ptr<FileIO> file_io_;
155182
std::function<void(const std::string&)> delete_func_;
183+
OptionalExecutor executor_;
184+
185+
private:
186+
/// Retry budget for the FileIO bulk `DeleteFiles` path. Tight on purpose: file
187+
/// cleanup is best-effort and runs after a successful commit, so we'd rather give
188+
/// up than block the caller for minutes on a flaky storage layer.
189+
static constexpr RetryConfig kDeleteRetryConfig{
190+
.num_retries = 2,
191+
.min_wait_ms = 100,
192+
.max_wait_ms = 1000,
193+
.total_timeout_ms = 5000,
194+
};
156195
};
157196

158197
/// \brief File cleanup strategy that determines safe deletions via full reachability.
@@ -161,7 +200,7 @@ class FileCleanupStrategy {
161200
/// still referenced by retained snapshots, then deletes orphaned manifests, data
162201
/// files, and manifest lists.
163202
///
164-
/// TODO(shangxinli): Add multi-threaded manifest reading and file deletion support.
203+
/// TODO(shangxinli): Add multi-threaded manifest reading support.
165204
class ReachableFileCleanup : public FileCleanupStrategy {
166205
public:
167206
using FileCleanupStrategy::FileCleanupStrategy;
@@ -366,7 +405,7 @@ class ReachableFileCleanup : public FileCleanupStrategy {
366405
/// logically introduced by a snapshot whose changes are still present in the
367406
/// current state under a different id.
368407
///
369-
/// TODO(shangxinli): Add multi-threaded manifest reading and file deletion support.
408+
/// TODO(shangxinli): Add multi-threaded manifest reading support.
370409
class IncrementalFileCleanup : public FileCleanupStrategy {
371410
public:
372411
using FileCleanupStrategy::FileCleanupStrategy;
@@ -703,6 +742,11 @@ ExpireSnapshots& ExpireSnapshots::CleanExpiredMetadata(bool clean) {
703742
return *this;
704743
}
705744

745+
ExpireSnapshots& ExpireSnapshots::Executor(OptionalExecutor executor) {
746+
executor_ = std::move(executor);
747+
return *this;
748+
}
749+
706750
Result<std::unordered_set<int64_t>> ExpireSnapshots::ComputeBranchSnapshotsToRetain(
707751
int64_t snapshot_id, TimePointMs expire_snapshot_older_than,
708752
int32_t min_snapshots_to_keep) const {
@@ -934,11 +978,11 @@ Status ExpireSnapshots::Finalize(Result<const TableMetadata*> commit_result) {
934978
!HasNonMainSnapshots(metadata_after_expiration);
935979

936980
if (can_use_incremental) {
937-
return IncrementalFileCleanup(ctx_->table->io(), delete_func_)
981+
return IncrementalFileCleanup(ctx_->table->io(), delete_func_, executor_)
938982
.CleanFiles(metadata_before_expiration, metadata_after_expiration,
939983
cleanup_level_);
940984
}
941-
return ReachableFileCleanup(ctx_->table->io(), delete_func_)
985+
return ReachableFileCleanup(ctx_->table->io(), delete_func_, executor_)
942986
.CleanFiles(metadata_before_expiration, metadata_after_expiration, cleanup_level_);
943987
}
944988

src/iceberg/update/expire_snapshots.h

Lines changed: 17 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,9 @@ 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 When an executor is configured via Executor(), this callback may be invoked
120+
/// concurrently from worker threads; implementations must be thread-safe.
121+
///
118122
/// \param delete_func A function that will be called to delete manifests and data files
119123
/// \return Reference to this for method chaining.
120124
ExpireSnapshots& DeleteWith(std::function<void(const std::string&)> delete_func);
@@ -140,6 +144,18 @@ class ICEBERG_EXPORT ExpireSnapshots : public PendingUpdate {
140144
/// \return Reference to this for method chaining.
141145
ExpireSnapshots& CleanExpiredMetadata(bool clean);
142146

147+
/// \brief Configure an executor used to parallelize best-effort file deletion.
148+
///
149+
/// Only meaningful in combination with DeleteWith(): when both are set the custom
150+
/// delete callback is invoked concurrently for each path through the supplied
151+
/// executor. Without DeleteWith(), file deletion uses FileIO's bulk DeleteFiles
152+
/// API and the executor is unused. The caller retains ownership and must keep the
153+
/// executor alive until Finalize() returns.
154+
///
155+
/// \param executor An executor reference, or std::nullopt for serial deletion.
156+
/// \return Reference to this for method chaining.
157+
ExpireSnapshots& Executor(OptionalExecutor executor);
158+
143159
Kind kind() const final { return Kind::kExpireSnapshots; }
144160
bool IsRetryable() const override { return true; }
145161

@@ -184,6 +200,7 @@ class ICEBERG_EXPORT ExpireSnapshots : public PendingUpdate {
184200
enum CleanupLevel cleanup_level_ { CleanupLevel::kAll };
185201
bool clean_expired_metadata_{false};
186202
bool specified_snapshot_id_{false};
203+
OptionalExecutor executor_;
187204

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

0 commit comments

Comments
 (0)