This is an automated email from the ASF dual-hosted git repository.
wgtmac pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git
The following commit(s) were added to refs/heads/main by this push:
new 3c5715c6 refactor(io): hand out S3 delegates by shared_ptr and build
them off the lock (#898)
3c5715c6 is described below
commit 3c5715c66fb4d4306c54eec4aac327e2897f800b
Author: Jiajia Li <[email protected]>
AuthorDate: Tue Sep 29 11:19:32 2026 +0800
refactor(io): hand out S3 delegates by shared_ptr and build them off the
lock (#898)
ResolvingFileIO::FileIOForPath held its mutex across FileIORegistry::Load
and
the delegate's SetStorageCredentials, so building the first S3 client (which
can look up a bucket region) stalled every other operation on the resolver.
It now loads without the lock and caches the result only if no credential
install happened meanwhile; otherwise it loads again with the new
credentials. Delegates retired by an install are torn down outside the lock.
Tests now exercise real IO where the old ones stopped short: the
multi-prefix
DeleteFiles test failed URI parsing before reaching any delegate, and the
concurrency test only built input-file wrappers. The replacements write,
delete and read through the test object store, and the stress test waits
for every worker to run before installing.
---
src/iceberg/arrow/s3/arrow_s3_file_io.cc | 132 ++++++++++++++++++++-------
src/iceberg/file_io.h | 7 +-
src/iceberg/resolving_file_io.cc | 68 +++++++++-----
src/iceberg/resolving_file_io.h | 13 ++-
src/iceberg/test/arrow_s3_file_io_test.cc | 138 +++++++++++++++++++++++++++++
src/iceberg/test/resolving_file_io_test.cc | 80 +++++++++++++++--
src/iceberg/test/rest_file_io_test.cc | 2 +-
7 files changed, 373 insertions(+), 67 deletions(-)
diff --git a/src/iceberg/arrow/s3/arrow_s3_file_io.cc
b/src/iceberg/arrow/s3/arrow_s3_file_io.cc
index c981759c..4fbf3309 100644
--- a/src/iceberg/arrow/s3/arrow_s3_file_io.cc
+++ b/src/iceberg/arrow/s3/arrow_s3_file_io.cc
@@ -17,9 +17,12 @@
* under the License.
*/
+#include <algorithm>
#include <cstdlib>
#include <memory>
+#include <mutex>
#include <optional>
+#include <shared_mutex>
#include <string>
#include <string_view>
#include <unordered_map>
@@ -179,7 +182,7 @@ class ArrowS3FileIO final : public FileIO, public
SupportsStorageCredentials {
public:
ArrowS3FileIO(std::shared_ptr<::arrow::fs::FileSystem> arrow_fs,
std::unordered_map<std::string, std::string>
default_properties)
- : default_file_io_(std::move(arrow_fs)),
+ :
default_file_io_(std::make_shared<ArrowFileSystemFileIO>(std::move(arrow_fs))),
default_properties_(std::move(default_properties)) {}
Result<std::unique_ptr<InputFile>> NewInputFile(std::string file_location)
override;
@@ -196,27 +199,67 @@ class ArrowS3FileIO final : public FileIO, public
SupportsStorageCredentials {
Status SetStorageCredentials(
const std::vector<StorageCredential>& storage_credentials) override;
- const std::vector<StorageCredential>& credentials() const override {
+ std::vector<StorageCredential> credentials() const override {
+ std::shared_lock lock(mutex_);
return storage_credentials_;
}
SupportsStorageCredentials* AsSupportsStorageCredentials() override { return
this; }
private:
- ArrowFileSystemFileIO& FileIOForPath(std::string_view location);
-
- ArrowFileSystemFileIO default_file_io_;
+ /// \brief Delegate serving `location`, pinned by the caller against a
+ /// concurrent credential install.
+ std::shared_ptr<ArrowFileSystemFileIO> FileIOForPath(std::string_view
location);
+
+ using DelegatesByPrefix =
+ std::vector<std::pair<std::string,
std::shared_ptr<ArrowFileSystemFileIO>>>;
+
+ /// \brief Longest-prefix match against one consistent view of the delegates.
+ static std::shared_ptr<ArrowFileSystemFileIO> MatchDelegate(
+ const std::shared_ptr<ArrowFileSystemFileIO>& fallback,
+ const DelegatesByPrefix& by_prefix, std::string_view location);
+
+ /// \brief Build a delegate for each credential this FileIO can serve.
+ ///
+ /// Runs without holding `mutex_`: building an S3 client can block (without
+ /// static keys the AWS SDK may wait on the EC2 metadata service), which
would
+ /// stall every concurrent operation. Reads no mutable member state.
+ Result<DelegatesByPrefix> BuildDelegates(
+ const std::vector<StorageCredential>& storage_credentials) const;
+
+ /// \brief Swap in credentials and delegates, handing back the retired ones.
+ ///
+ /// Callers must hold `mutex_` exclusively and let the returned generation
+ /// destruct only after releasing it: tearing down an S3 client can block on
+ /// in-flight requests, which would stall every operation.
+ void InstallCredentials(std::vector<StorageCredential>& storage_credentials,
+ DelegatesByPrefix& delegates);
+
+ std::shared_ptr<ArrowFileSystemFileIO> default_file_io_;
std::unordered_map<std::string, std::string> default_properties_;
+ // Guards everything below; shared because reads happen per file operation.
+ mutable std::shared_mutex mutex_;
std::vector<StorageCredential> storage_credentials_;
- std::vector<std::pair<std::string, std::unique_ptr<ArrowFileSystemFileIO>>>
- file_io_by_prefix_;
+ DelegatesByPrefix file_io_by_prefix_;
};
Status ArrowS3FileIO::SetStorageCredentials(
const std::vector<StorageCredential>& storage_credentials) {
- std::vector<std::pair<std::string, std::unique_ptr<ArrowFileSystemFileIO>>>
- file_io_by_prefix;
- file_io_by_prefix.reserve(storage_credentials.size());
+ ICEBERG_ASSIGN_OR_RAISE(auto delegates, BuildDelegates(storage_credentials));
+ auto credentials = storage_credentials;
+ {
+ std::unique_lock lock(mutex_);
+ InstallCredentials(credentials, delegates);
+ }
+ // `credentials` and `delegates` now hold the retired generation and destruct
+ // here, outside the lock.
+ return {};
+}
+
+Result<ArrowS3FileIO::DelegatesByPrefix> ArrowS3FileIO::BuildDelegates(
+ const std::vector<StorageCredential>& storage_credentials) const {
+ DelegatesByPrefix delegates;
+ delegates.reserve(storage_credentials.size());
// TODO(gangwu): Refresh vended credentials via credentials.uri before
tokens expire.
for (const auto& credential : storage_credentials) {
ICEBERG_RETURN_UNEXPECTED(credential.Validate());
@@ -231,11 +274,10 @@ Status ArrowS3FileIO::SetStorageCredentials(
properties[key] = value;
}
ICEBERG_ASSIGN_OR_RAISE(auto fs, BuildArrowS3FileSystem(properties));
- file_io_by_prefix.emplace_back(
- CanonicalizeS3Scheme(credential.prefix),
- std::make_unique<ArrowFileSystemFileIO>(std::move(fs)));
+ delegates.emplace_back(CanonicalizeS3Scheme(credential.prefix),
+
std::make_shared<ArrowFileSystemFileIO>(std::move(fs)));
}
- if (file_io_by_prefix.empty() && !storage_credentials.empty()) {
+ if (delegates.empty() && !storage_credentials.empty()) {
// Silent skipping of every vended credential is hard to diagnose: S3
access
// would proceed with the default credentials and fail only at IO time.
ICEBERG_LOG_WARN(
@@ -243,50 +285,80 @@ Status ArrowS3FileIO::SetStorageCredentials(
"S3 access will use the default credentials",
storage_credentials.size());
}
- file_io_by_prefix_ = std::move(file_io_by_prefix);
- storage_credentials_ = storage_credentials;
- return {};
+ return delegates;
}
-ArrowFileSystemFileIO& ArrowS3FileIO::FileIOForPath(std::string_view location)
{
- if (file_io_by_prefix_.empty()) {
- return default_file_io_;
+void ArrowS3FileIO::InstallCredentials(
+ std::vector<StorageCredential>& storage_credentials, DelegatesByPrefix&
delegates) {
+ file_io_by_prefix_.swap(delegates);
+ storage_credentials_.swap(storage_credentials);
+}
+
+std::shared_ptr<ArrowFileSystemFileIO> ArrowS3FileIO::MatchDelegate(
+ const std::shared_ptr<ArrowFileSystemFileIO>& fallback,
+ const DelegatesByPrefix& by_prefix, std::string_view location) {
+ if (by_prefix.empty()) {
+ return fallback;
}
const std::string canonical = CanonicalizeS3Scheme(location);
- ArrowFileSystemFileIO* best = &default_file_io_;
+ auto best = fallback;
size_t best_len = 0;
- for (const auto& [prefix, file_io] : file_io_by_prefix_) {
+ for (const auto& [prefix, file_io] : by_prefix) {
if (prefix.size() > best_len && canonical.starts_with(prefix)) {
- best = file_io.get();
+ best = file_io;
best_len = prefix.size();
}
}
- return *best;
+ return best;
+}
+
+std::shared_ptr<ArrowFileSystemFileIO> ArrowS3FileIO::FileIOForPath(
+ std::string_view location) {
+ std::shared_lock lock(mutex_);
+ return MatchDelegate(default_file_io_, file_io_by_prefix_, location);
}
Result<std::unique_ptr<InputFile>> ArrowS3FileIO::NewInputFile(
std::string file_location) {
- return FileIOForPath(file_location).NewInputFile(std::move(file_location));
+ return FileIOForPath(file_location)->NewInputFile(std::move(file_location));
}
Result<std::unique_ptr<InputFile>> ArrowS3FileIO::NewInputFile(std::string
file_location,
size_t length) {
- return FileIOForPath(file_location).NewInputFile(std::move(file_location),
length);
+ return FileIOForPath(file_location)->NewInputFile(std::move(file_location),
length);
}
Result<std::unique_ptr<OutputFile>> ArrowS3FileIO::NewOutputFile(
std::string file_location) {
- return FileIOForPath(file_location).NewOutputFile(std::move(file_location));
+ return FileIOForPath(file_location)->NewOutputFile(std::move(file_location));
}
Status ArrowS3FileIO::DeleteFile(const std::string& file_location) {
- return FileIOForPath(file_location).DeleteFile(file_location);
+ return FileIOForPath(file_location)->DeleteFile(file_location);
}
Status ArrowS3FileIO::DeleteFiles(const std::vector<std::string>&
file_locations) {
- std::unordered_map<ArrowFileSystemFileIO*, std::vector<std::string>>
locations_by_io;
+ // One snapshot so the whole batch matches the same delegate generation; only
+ // ever a handful of delegates, so a linear scan beats hashing.
+ std::shared_ptr<ArrowFileSystemFileIO> fallback;
+ DelegatesByPrefix by_prefix;
+ {
+ std::shared_lock lock(mutex_);
+ fallback = default_file_io_;
+ by_prefix = file_io_by_prefix_;
+ }
+ std::vector<std::pair<std::shared_ptr<ArrowFileSystemFileIO>,
std::vector<std::string>>>
+ locations_by_io;
for (const auto& file_location : file_locations) {
- locations_by_io[&FileIOForPath(file_location)].push_back(file_location);
+ auto file_io = MatchDelegate(fallback, by_prefix, file_location);
+ auto it = std::ranges::find_if(
+ locations_by_io, [&](const auto& entry) { return entry.first ==
file_io; });
+ if (it == locations_by_io.end()) {
+ locations_by_io.emplace_back(std::move(file_io),
+ std::vector<std::string>{file_location});
+ } else {
+ it->second.push_back(file_location);
+ }
}
for (auto& [file_io, locations] : locations_by_io) {
ICEBERG_RETURN_UNEXPECTED(file_io->DeleteFiles(locations));
diff --git a/src/iceberg/file_io.h b/src/iceberg/file_io.h
index 3ea4afa4..e22e5cf2 100644
--- a/src/iceberg/file_io.h
+++ b/src/iceberg/file_io.h
@@ -193,8 +193,11 @@ class ICEBERG_EXPORT SupportsStorageCredentials {
virtual Status SetStorageCredentials(
const std::vector<StorageCredential>& storage_credentials) = 0;
- /// \brief Return currently installed storage credentials.
- virtual const std::vector<StorageCredential>& credentials() const = 0;
+ /// \brief Return the storage credentials this FileIO holds.
+ ///
+ /// By value because a concurrent install may replace them. An implementation
+ /// that delegates may report what was installed on it.
+ virtual std::vector<StorageCredential> credentials() const = 0;
};
} // namespace iceberg
diff --git a/src/iceberg/resolving_file_io.cc b/src/iceberg/resolving_file_io.cc
index 8a4e8113..91be67ea 100644
--- a/src/iceberg/resolving_file_io.cc
+++ b/src/iceberg/resolving_file_io.cc
@@ -40,28 +40,46 @@ Result<std::shared_ptr<FileIO>>
ResolvingFileIO::FileIOForPath(
const auto scheme =
StringUtils::ToLower(LocationUtil::ParseScheme(location));
ICEBERG_ASSIGN_OR_RAISE(const auto name, FileIORegistry::Resolve(scheme));
- {
- std::shared_lock lock(mutex_);
- if (const auto cached = io_by_name_.find(name); cached !=
io_by_name_.end()) {
- return cached->second;
- }
- }
-
- std::unique_lock lock(mutex_);
- auto it = io_by_name_.find(name);
- if (it == io_by_name_.end()) {
- ICEBERG_ASSIGN_OR_RAISE(auto io, FileIORegistry::Load(name, properties_));
- // Forward all credentials; each implementation applies the prefixes it
- // understands.
- if (!storage_credentials_.empty()) {
+ // Loads without holding `mutex_`: building a client can block (an S3 client
+ // without static keys may wait on the EC2 metadata service), which would
+ // stall every other operation. Forwards all credentials; each implementation
+ // applies the prefixes it understands.
+ auto load = [&](const std::vector<StorageCredential>& credentials)
+ -> Result<std::shared_ptr<FileIO>> {
+ ICEBERG_ASSIGN_OR_RAISE(std::shared_ptr<FileIO> io,
+ FileIORegistry::Load(name, properties_));
+ if (!credentials.empty()) {
if (auto* credentialed = io->AsSupportsStorageCredentials()) {
- ICEBERG_RETURN_UNEXPECTED(
- credentialed->SetStorageCredentials(storage_credentials_));
+
ICEBERG_RETURN_UNEXPECTED(credentialed->SetStorageCredentials(credentials));
+ }
+ }
+ return io;
+ };
+
+ while (true) {
+ uint64_t generation = 0;
+ std::vector<StorageCredential> credentials;
+ {
+ std::shared_lock lock(mutex_);
+ if (const auto cached = io_by_name_.find(name); cached !=
io_by_name_.end()) {
+ return cached->second;
}
+ generation = credential_generation_;
+ credentials = storage_credentials_;
+ }
+ // Declared before the lock, so a delegate that is not cached is torn down
+ // only after the lock is released.
+ auto loaded = load(credentials);
+ std::unique_lock lock(mutex_);
+ if (generation != credential_generation_) {
+ continue; // Credentials were replaced mid-load; load again with them.
}
- it = io_by_name_.emplace(std::string(name), std::move(io)).first;
+ if (const auto cached = io_by_name_.find(name); cached !=
io_by_name_.end()) {
+ return cached->second;
+ }
+ ICEBERG_RETURN_UNEXPECTED(loaded);
+ return io_by_name_.try_emplace(name, *loaded).first->second;
}
- return it->second;
}
Result<std::unique_ptr<InputFile>> ResolvingFileIO::NewInputFile(
@@ -103,13 +121,19 @@ Status ResolvingFileIO::SetStorageCredentials(
const std::vector<StorageCredential>& storage_credentials) {
// Rebuild delegates lazily with the new credentials. Updating live delegates
// instead would leave the resolver inconsistent if one of them rejected
them.
- std::unique_lock lock(mutex_);
- storage_credentials_ = storage_credentials;
- io_by_name_.clear();
+ // Retired outside the lock: tearing down a delegate can block.
+ decltype(io_by_name_) retired;
+ {
+ std::unique_lock lock(mutex_);
+ storage_credentials_ = storage_credentials;
+ ++credential_generation_;
+ retired.swap(io_by_name_);
+ }
return {};
}
-const std::vector<StorageCredential>& ResolvingFileIO::credentials() const {
+std::vector<StorageCredential> ResolvingFileIO::credentials() const {
+ std::shared_lock lock(mutex_);
return storage_credentials_;
}
diff --git a/src/iceberg/resolving_file_io.h b/src/iceberg/resolving_file_io.h
index 837afd60..9a5cf047 100644
--- a/src/iceberg/resolving_file_io.h
+++ b/src/iceberg/resolving_file_io.h
@@ -22,6 +22,7 @@
/// \file iceberg/resolving_file_io.h
/// \brief FileIO that resolves the concrete implementation per file-path
scheme.
+#include <cstdint>
#include <memory>
#include <shared_mutex>
#include <string>
@@ -38,6 +39,9 @@
namespace iceberg {
/// \brief FileIO that resolves and caches implementations by registry name.
+///
+/// Vended credentials are forwarded to every resolved implementation that
+/// supports them; each applies what it understands.
class ICEBERG_EXPORT ResolvingFileIO final : public FileIO,
public SupportsStorageCredentials
{
public:
@@ -58,7 +62,7 @@ class ICEBERG_EXPORT ResolvingFileIO final : public FileIO,
Status SetStorageCredentials(
const std::vector<StorageCredential>& storage_credentials) override;
- const std::vector<StorageCredential>& credentials() const override;
+ std::vector<StorageCredential> credentials() const override;
SupportsStorageCredentials* AsSupportsStorageCredentials() override { return
this; }
@@ -67,9 +71,12 @@ class ICEBERG_EXPORT ResolvingFileIO final : public FileIO,
Result<std::shared_ptr<FileIO>> FileIOForPath(std::string_view location);
std::unordered_map<std::string, std::string> properties_;
- // Guards lazy resolution and credential refresh.
- std::shared_mutex mutex_;
+ // Guards lazy resolution and credential state.
+ mutable std::shared_mutex mutex_;
std::vector<StorageCredential> storage_credentials_;
+ // Bumped by every credential install, so a delegate loaded from an older set
+ // never reaches the cache.
+ uint64_t credential_generation_ = 0;
std::unordered_map<std::string, std::shared_ptr<FileIO>, StringHash,
StringEqual>
io_by_name_;
};
diff --git a/src/iceberg/test/arrow_s3_file_io_test.cc
b/src/iceberg/test/arrow_s3_file_io_test.cc
index 7235ea36..24628949 100644
--- a/src/iceberg/test/arrow_s3_file_io_test.cc
+++ b/src/iceberg/test/arrow_s3_file_io_test.cc
@@ -18,12 +18,15 @@
*/
#include <algorithm>
+#include <atomic>
#include <cstdlib>
#include <iostream>
#include <memory>
#include <optional>
+#include <span>
#include <string>
#include <string_view>
+#include <thread>
#include <unordered_map>
#include <utility>
#include <vector>
@@ -237,6 +240,57 @@ TEST_F(ArrowS3FileIOTest, WarnsWhenNoCredentialApplies) {
EXPECT_TRUE(HasWarning(*logger));
}
+TEST_F(ArrowS3FileIOTest, OperationsSurviveConcurrentCredentialInstalls) {
+ auto result = MakeS3FileIO({});
+ ASSERT_THAT(result, IsOk());
+ auto* credentialed = result.value()->AsSupportsStorageCredentials();
+ ASSERT_NE(credentialed, nullptr);
+
+ auto credential = [](std::string_view access_key) {
+ return StorageCredential{
+ .prefix = "s3://bucket",
+ .config = {{std::string(S3Properties::kAccessKeyId),
std::string(access_key)},
+ {std::string(S3Properties::kSecretAccessKey), "secret"}}};
+ };
+ ASSERT_THAT(credentialed->SetStorageCredentials({credential("first")}),
IsOk());
+
+ std::atomic<bool> stop = false;
+ std::atomic<int> started = 0;
+ std::atomic<int> failures = 0;
+ std::vector<std::thread> operations;
+ operations.reserve(4);
+ for (int i = 0; i < 4; ++i) {
+ operations.emplace_back([&] {
+ auto open = [&] {
+ if (!result.value()->NewInputFile("s3://bucket/key").has_value()) {
+ ++failures;
+ }
+ };
+ open();
+ ++started;
+ while (!stop.load()) {
+ open();
+ }
+ });
+ }
+ // Every worker has run and is still looping before the first install.
+ while (started.load() < 4) {
+ std::this_thread::yield();
+ }
+ // No assertions until the threads are joined: a fatal assertion here would
+ // destroy joinable threads and terminate the binary, masking the failure.
+ Status install_status = {};
+ for (int round = 0; round < 3 && install_status.has_value(); ++round) {
+ install_status =
credentialed->SetStorageCredentials({credential("replacement")});
+ }
+ stop = true;
+ for (auto& operation : operations) {
+ operation.join();
+ }
+ ASSERT_THAT(install_status, IsOk());
+ EXPECT_EQ(failures, 0);
+}
+
TEST_F(ArrowS3FileIOTest, RejectsIncompleteStaticCredentials) {
auto result =
MakeS3FileIO({{std::string(S3Properties::kAccessKeyId),
"access-key-only"}});
@@ -341,6 +395,90 @@ TEST_F(ArrowS3FileIOTest,
AppliesOssCredentialInRealRoundTrip) {
EXPECT_THAT(CheckReadWrite(*io, s3_uri, "hello oss with vended
credentials"), IsOk());
}
+TEST_F(ArrowS3FileIOTest, DeleteFilesReachesEveryCredentialPrefix) {
+ if (!HasIntegrationEnv()) {
+ GTEST_SKIP() << "Set ICEBERG_TEST_S3_URI to enable S3 IO test";
+ }
+
+ auto properties = PropertiesFromEnv();
+ if (!properties.contains(std::string(S3Properties::kAccessKeyId)) ||
+ !properties.contains(std::string(S3Properties::kSecretAccessKey))) {
+ GTEST_SKIP() << "Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to enable
"
+ "credential routing test";
+ }
+
+ // Only the prefix delegates can authenticate, so a location that falls to
+ // the default one fails the batch instead of passing unnoticed.
+ auto bad_defaults = properties;
+ for (const auto& [key, value] : BadS3Credentials()) {
+ bad_defaults.insert_or_assign(key, value);
+ }
+ ICEBERG_UNWRAP_OR_FAIL(auto io, MakeS3FileIO(std::move(bad_defaults)));
+ auto* credentialed = io->AsSupportsStorageCredentials();
+ ASSERT_NE(credentialed, nullptr);
+
+ const auto a = ObjectUri("delete_files_a/");
+ const auto b = ObjectUri("delete_files_b/");
+ ASSERT_THAT(credentialed->SetStorageCredentials({{.prefix = a, .config =
properties},
+ {.prefix = b, .config =
properties}}),
+ IsOk());
+
+ const std::vector<std::string> paths = {a + "first", b + "only", a +
"second"};
+ for (const auto& path : paths) {
+ ASSERT_THAT(io->WriteFile(path, "payload"), IsOk());
+ }
+ ASSERT_THAT(io->DeleteFiles(paths), IsOk());
+ // The writes above authenticated on these paths, so a failed read means the
+ // object is gone.
+ for (const auto& path : paths) {
+ EXPECT_FALSE(io->ReadFile(path, std::nullopt).has_value()) << path;
+ }
+}
+
+TEST_F(ArrowS3FileIOTest, InputFileOutlivesCredentialInstall) {
+ if (!HasIntegrationEnv()) {
+ GTEST_SKIP() << "Set ICEBERG_TEST_S3_URI to enable S3 IO test";
+ }
+
+ auto properties = PropertiesFromEnv();
+ if (!properties.contains(std::string(S3Properties::kAccessKeyId)) ||
+ !properties.contains(std::string(S3Properties::kSecretAccessKey))) {
+ GTEST_SKIP() << "Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to enable
"
+ "credential routing test";
+ }
+
+ auto bad_defaults = properties;
+ for (const auto& [key, value] : BadS3Credentials()) {
+ bad_defaults.insert_or_assign(key, value);
+ }
+ ICEBERG_UNWRAP_OR_FAIL(auto io, MakeS3FileIO(std::move(bad_defaults)));
+ auto* credentialed = io->AsSupportsStorageCredentials();
+ ASSERT_NE(credentialed, nullptr);
+
+ const auto prefix = ObjectUri("retained_input/");
+ const std::vector<StorageCredential> credentials = {
+ {.prefix = prefix, .config = properties}};
+ ASSERT_THAT(credentialed->SetStorageCredentials(credentials), IsOk());
+
+ const auto uri = prefix + "object";
+ constexpr std::string_view kContent = "written before the credential
install";
+ ASSERT_THAT(io->WriteFile(uri, kContent), IsOk());
+ ICEBERG_UNWRAP_OR_FAIL(auto file, io->NewInputFile(uri));
+ ICEBERG_UNWRAP_OR_FAIL(auto opened_before, file->Open());
+
+ // Retires the delegate `file` came from; both handles must still read.
+ ASSERT_THAT(credentialed->SetStorageCredentials(credentials), IsOk());
+
+ ICEBERG_UNWRAP_OR_FAIL(auto opened_after, file->Open());
+ for (auto* stream : {opened_before.get(), opened_after.get()}) {
+ std::string read(kContent.size(), '\0');
+ EXPECT_THAT(stream->ReadFully(0, std::as_writable_bytes(std::span(read))),
IsOk());
+ EXPECT_EQ(read, kContent);
+ EXPECT_THAT(stream->Close(), IsOk());
+ }
+ EXPECT_THAT(io->DeleteFile(uri), IsOk());
+}
+
#if ICEBERG_S3_ENABLED
TEST_F(ArrowS3FileIOTest, ClientRegion) {
auto result =
diff --git a/src/iceberg/test/resolving_file_io_test.cc
b/src/iceberg/test/resolving_file_io_test.cc
index 788b59be..2189b64a 100644
--- a/src/iceberg/test/resolving_file_io_test.cc
+++ b/src/iceberg/test/resolving_file_io_test.cc
@@ -19,8 +19,13 @@
#include "iceberg/resolving_file_io.h"
+#include <chrono>
+#include <condition_variable>
+#include <future>
#include <memory>
+#include <mutex>
#include <string>
+#include <thread>
#include <unordered_map>
#include <vector>
@@ -60,9 +65,7 @@ class RecordingCredentialedFileIO : public RecordingFileIO,
return {};
}
- const std::vector<StorageCredential>& credentials() const override {
- return credentials_;
- }
+ std::vector<StorageCredential> credentials() const override { return
credentials_; }
SupportsStorageCredentials* AsSupportsStorageCredentials() override { return
this; }
@@ -153,9 +156,9 @@ TEST(ResolvingFileIOTest,
RoutesPathsAndCachesResolvedImplementations) {
ResolvingFileIO io({{"k", "v"}});
// Errors come from the recording mock; routing is what is under test.
- (void)io.NewInputFile("s3a://bucket/db/table/data/file.parquet");
- (void)io.NewInputFile("s3://bucket/db/table/data/file.parquet");
- (void)io.NewInputFile("/tmp/local/file.parquet");
+ std::ignore = io.NewInputFile("s3a://bucket/db/table/data/file.parquet");
+ std::ignore = io.NewInputFile("s3://bucket/db/table/data/file.parquet");
+ std::ignore = io.NewInputFile("/tmp/local/file.parquet");
ASSERT_NE(last_s3_io, nullptr);
ASSERT_NE(last_local_io, nullptr);
@@ -238,7 +241,7 @@ TEST(ResolvingFileIOTest,
ForwardsAllCredentialsToResolvedImplementations) {
EXPECT_THAT(io.SetStorageCredentials(credentials), IsOk());
EXPECT_EQ(io.credentials(), credentials);
- (void)io.NewInputFile("s3://bucket/db/table/data/file.parquet");
+ std::ignore = io.NewInputFile("s3://bucket/db/table/data/file.parquet");
ASSERT_NE(last_s3_io, nullptr);
EXPECT_EQ(last_s3_io->credentials(), credentials);
EXPECT_EQ(s3_factory_calls, 1);
@@ -246,14 +249,73 @@ TEST(ResolvingFileIOTest,
ForwardsAllCredentialsToResolvedImplementations) {
// Delegates are rebuilt with the new credentials, not mutated in place.
std::vector<StorageCredential> refreshed = {{.prefix = "s3", .config =
{{"k3", "v3"}}}};
EXPECT_THAT(io.SetStorageCredentials(refreshed), IsOk());
- (void)io.NewInputFile("s3://bucket/db/table/data/other.parquet");
+ std::ignore = io.NewInputFile("s3://bucket/db/table/data/other.parquet");
ASSERT_NE(last_s3_io, nullptr);
EXPECT_EQ(last_s3_io->credentials(), refreshed);
EXPECT_EQ(s3_factory_calls, 2);
// The local FileIO does not support credentials; resolving it still works.
- (void)io.NewInputFile("/tmp/local/file.parquet");
+ std::ignore = io.NewInputFile("/tmp/local/file.parquet");
ASSERT_NE(last_local_io, nullptr);
}
+TEST(ResolvingFileIOTest, LoadsWithoutTheLockAndDropsStaleDelegates) {
+ // The first load parks until new credentials are installed. Loading outside
+ // the lock lets that install proceed; what the parked load built then
carries
+ // stale credentials and must be redone rather than cached.
+ // Static because registry factories are process-global; reset for reruns.
+ static std::mutex gate;
+ static std::condition_variable cv;
+ static bool parked;
+ static bool resume;
+ static int calls;
+ static RecordingCredentialedFileIO* last;
+ parked = resume = false;
+ calls = 0;
+ last = nullptr;
+ FileIORegistry::Register(
+ "test.file-io.slow",
+ {.create =
+ [](const FileIORegistry::Properties&) ->
Result<std::unique_ptr<FileIO>> {
+ if (++calls == 1) {
+ std::unique_lock lock(gate);
+ parked = true;
+ cv.notify_all();
+ cv.wait(lock, [] { return resume; });
+ }
+ auto io = std::make_unique<RecordingCredentialedFileIO>();
+ last = io.get();
+ return io;
+ },
+ .accepts = [](std::string_view scheme) { return scheme == "slow"; }});
+
+ ResolvingFileIO io({});
+ const std::vector<StorageCredential> stale = {
+ {.prefix = "slow", .config = {{"k", "1"}}}};
+ const std::vector<StorageCredential> fresh = {
+ {.prefix = "slow", .config = {{"k", "2"}}}};
+ ASSERT_THAT(io.SetStorageCredentials(stale), IsOk());
+
+ std::thread reader([&] { std::ignore =
io.NewInputFile("slow://bucket/file"); });
+ {
+ std::unique_lock lock(gate);
+ cv.wait(lock, [] { return parked; });
+ }
+ auto install =
+ std::async(std::launch::async, [&] { return
io.SetStorageCredentials(fresh); });
+ const bool install_waited =
+ install.wait_for(std::chrono::seconds(10)) != std::future_status::ready;
+ {
+ std::lock_guard lock(gate);
+ resume = true;
+ }
+ cv.notify_all();
+ reader.join();
+
+ EXPECT_FALSE(install_waited) << "a credential install waited on an in-flight
load";
+ EXPECT_THAT(install.get(), IsOk());
+ ASSERT_EQ(calls, 2);
+ EXPECT_EQ(last->credentials(), fresh);
+}
+
} // namespace iceberg
diff --git a/src/iceberg/test/rest_file_io_test.cc
b/src/iceberg/test/rest_file_io_test.cc
index dde3238a..2fce9dac 100644
--- a/src/iceberg/test/rest_file_io_test.cc
+++ b/src/iceberg/test/rest_file_io_test.cc
@@ -61,7 +61,7 @@ class MockCredentialedFileIO : public MockFileIO, public
SupportsStorageCredenti
return {};
}
- const std::vector<StorageCredential>& credentials() const override {
+ std::vector<StorageCredential> credentials() const override {
return captured_storage_credentials;
}