Skip to content

Commit ea3d7cd

Browse files
refactor(io): hand out S3 delegates by shared_ptr and build them off the lock
1 parent 268883e commit ea3d7cd

7 files changed

Lines changed: 182 additions & 40 deletions

File tree

‎src/iceberg/arrow/s3/arrow_s3_file_io.cc‎

Lines changed: 102 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717
* under the License.
1818
*/
1919

20+
#include <algorithm>
2021
#include <cstdlib>
2122
#include <memory>
23+
#include <mutex>
2224
#include <optional>
25+
#include <shared_mutex>
2326
#include <string>
2427
#include <string_view>
2528
#include <unordered_map>
@@ -190,7 +193,7 @@ class ArrowS3FileIO final : public FileIO, public SupportsStorageCredentials {
190193
public:
191194
ArrowS3FileIO(std::shared_ptr<::arrow::fs::FileSystem> arrow_fs,
192195
std::unordered_map<std::string, std::string> default_properties)
193-
: default_file_io_(std::move(arrow_fs)),
196+
: default_file_io_(std::make_shared<ArrowFileSystemFileIO>(std::move(arrow_fs))),
194197
default_properties_(std::move(default_properties)) {}
195198

196199
Result<std::unique_ptr<InputFile>> NewInputFile(std::string file_location) override;
@@ -207,27 +210,67 @@ class ArrowS3FileIO final : public FileIO, public SupportsStorageCredentials {
207210
Status SetStorageCredentials(
208211
const std::vector<StorageCredential>& storage_credentials) override;
209212

210-
const std::vector<StorageCredential>& credentials() const override {
213+
std::vector<StorageCredential> credentials() const override {
214+
std::shared_lock lock(mutex_);
211215
return storage_credentials_;
212216
}
213217

214218
SupportsStorageCredentials* AsSupportsStorageCredentials() override { return this; }
215219

216220
private:
217-
ArrowFileSystemFileIO& FileIOForPath(std::string_view location);
218-
219-
ArrowFileSystemFileIO default_file_io_;
221+
/// \brief Delegate serving `location`, pinned by the caller against a
222+
/// concurrent credential install.
223+
std::shared_ptr<ArrowFileSystemFileIO> FileIOForPath(std::string_view location);
224+
225+
using DelegatesByPrefix =
226+
std::vector<std::pair<std::string, std::shared_ptr<ArrowFileSystemFileIO>>>;
227+
228+
/// \brief Longest-prefix match against one consistent view of the delegates.
229+
static std::shared_ptr<ArrowFileSystemFileIO> MatchDelegate(
230+
const std::shared_ptr<ArrowFileSystemFileIO>& fallback,
231+
const DelegatesByPrefix& by_prefix, std::string_view location);
232+
233+
/// \brief Build a delegate for each credential this FileIO can serve.
234+
///
235+
/// Lock-free on purpose: building an S3 client can reach out to discover a
236+
/// bucket region, which would stall every concurrent operation. Reads no
237+
/// mutable member state.
238+
Result<DelegatesByPrefix> BuildDelegates(
239+
const std::vector<StorageCredential>& storage_credentials) const;
240+
241+
/// \brief Swap in credentials and delegates, handing back the retired ones.
242+
///
243+
/// Callers must hold `mutex_` exclusively and let the returned generation
244+
/// destruct only after releasing it: tearing down an S3 client can block on
245+
/// in-flight requests, which would stall every operation.
246+
void InstallCredentials(std::vector<StorageCredential>& storage_credentials,
247+
DelegatesByPrefix& delegates);
248+
249+
std::shared_ptr<ArrowFileSystemFileIO> default_file_io_;
220250
std::unordered_map<std::string, std::string> default_properties_;
251+
// Guards everything below; shared because reads happen per file operation.
252+
mutable std::shared_mutex mutex_;
221253
std::vector<StorageCredential> storage_credentials_;
222-
std::vector<std::pair<std::string, std::unique_ptr<ArrowFileSystemFileIO>>>
223-
file_io_by_prefix_;
254+
DelegatesByPrefix file_io_by_prefix_;
224255
};
225256

226257
Status ArrowS3FileIO::SetStorageCredentials(
227258
const std::vector<StorageCredential>& storage_credentials) {
228-
std::vector<std::pair<std::string, std::unique_ptr<ArrowFileSystemFileIO>>>
229-
file_io_by_prefix;
230-
file_io_by_prefix.reserve(storage_credentials.size());
259+
ICEBERG_ASSIGN_OR_RAISE(auto delegates, BuildDelegates(storage_credentials));
260+
auto credentials = storage_credentials;
261+
{
262+
std::unique_lock lock(mutex_);
263+
InstallCredentials(credentials, delegates);
264+
}
265+
// `credentials` and `delegates` now hold the retired generation and destruct
266+
// here, outside the lock.
267+
return {};
268+
}
269+
270+
Result<ArrowS3FileIO::DelegatesByPrefix> ArrowS3FileIO::BuildDelegates(
271+
const std::vector<StorageCredential>& storage_credentials) const {
272+
DelegatesByPrefix delegates;
273+
delegates.reserve(storage_credentials.size());
231274
// TODO(gangwu): Refresh vended credentials via credentials.uri before tokens expire.
232275
for (const auto& credential : storage_credentials) {
233276
ICEBERG_RETURN_UNEXPECTED(credential.Validate());
@@ -242,62 +285,91 @@ Status ArrowS3FileIO::SetStorageCredentials(
242285
properties[key] = value;
243286
}
244287
ICEBERG_ASSIGN_OR_RAISE(auto fs, BuildArrowS3FileSystem(properties));
245-
file_io_by_prefix.emplace_back(
246-
CanonicalizeS3Scheme(credential.prefix),
247-
std::make_unique<ArrowFileSystemFileIO>(std::move(fs)));
288+
delegates.emplace_back(CanonicalizeS3Scheme(credential.prefix),
289+
std::make_shared<ArrowFileSystemFileIO>(std::move(fs)));
248290
}
249-
if (file_io_by_prefix.empty() && !storage_credentials.empty()) {
291+
if (delegates.empty() && !storage_credentials.empty()) {
250292
// Silent skipping of every vended credential is hard to diagnose: S3 access
251293
// would proceed with the default credentials and fail only at IO time.
252294
ICEBERG_LOG_WARN(
253295
"None of the {} vended storage credential(s) has an S3-compatible prefix; "
254296
"S3 access will use the default credentials",
255297
storage_credentials.size());
256298
}
257-
file_io_by_prefix_ = std::move(file_io_by_prefix);
258-
storage_credentials_ = storage_credentials;
259-
return {};
299+
return delegates;
260300
}
261301

262-
ArrowFileSystemFileIO& ArrowS3FileIO::FileIOForPath(std::string_view location) {
263-
if (file_io_by_prefix_.empty()) {
264-
return default_file_io_;
302+
void ArrowS3FileIO::InstallCredentials(
303+
std::vector<StorageCredential>& storage_credentials, DelegatesByPrefix& delegates) {
304+
file_io_by_prefix_.swap(delegates);
305+
storage_credentials_.swap(storage_credentials);
306+
}
307+
308+
std::shared_ptr<ArrowFileSystemFileIO> ArrowS3FileIO::MatchDelegate(
309+
const std::shared_ptr<ArrowFileSystemFileIO>& fallback,
310+
const DelegatesByPrefix& by_prefix, std::string_view location) {
311+
if (by_prefix.empty()) {
312+
return fallback;
265313
}
266314
const std::string canonical = CanonicalizeS3Scheme(location);
267-
ArrowFileSystemFileIO* best = &default_file_io_;
315+
auto best = fallback;
268316
size_t best_len = 0;
269-
for (const auto& [prefix, file_io] : file_io_by_prefix_) {
317+
for (const auto& [prefix, file_io] : by_prefix) {
270318
if (prefix.size() > best_len && canonical.starts_with(prefix)) {
271-
best = file_io.get();
319+
best = file_io;
272320
best_len = prefix.size();
273321
}
274322
}
275-
return *best;
323+
return best;
324+
}
325+
326+
std::shared_ptr<ArrowFileSystemFileIO> ArrowS3FileIO::FileIOForPath(
327+
std::string_view location) {
328+
std::shared_lock lock(mutex_);
329+
return MatchDelegate(default_file_io_, file_io_by_prefix_, location);
276330
}
277331

278332
Result<std::unique_ptr<InputFile>> ArrowS3FileIO::NewInputFile(
279333
std::string file_location) {
280-
return FileIOForPath(file_location).NewInputFile(std::move(file_location));
334+
return FileIOForPath(file_location)->NewInputFile(std::move(file_location));
281335
}
282336

283337
Result<std::unique_ptr<InputFile>> ArrowS3FileIO::NewInputFile(std::string file_location,
284338
size_t length) {
285-
return FileIOForPath(file_location).NewInputFile(std::move(file_location), length);
339+
return FileIOForPath(file_location)->NewInputFile(std::move(file_location), length);
286340
}
287341

288342
Result<std::unique_ptr<OutputFile>> ArrowS3FileIO::NewOutputFile(
289343
std::string file_location) {
290-
return FileIOForPath(file_location).NewOutputFile(std::move(file_location));
344+
return FileIOForPath(file_location)->NewOutputFile(std::move(file_location));
291345
}
292346

293347
Status ArrowS3FileIO::DeleteFile(const std::string& file_location) {
294-
return FileIOForPath(file_location).DeleteFile(file_location);
348+
return FileIOForPath(file_location)->DeleteFile(file_location);
295349
}
296350

297351
Status ArrowS3FileIO::DeleteFiles(const std::vector<std::string>& file_locations) {
298-
std::unordered_map<ArrowFileSystemFileIO*, std::vector<std::string>> locations_by_io;
352+
// One snapshot so the whole batch matches the same delegate generation; only
353+
// ever a handful of delegates, so a linear scan beats hashing.
354+
std::shared_ptr<ArrowFileSystemFileIO> fallback;
355+
DelegatesByPrefix by_prefix;
356+
{
357+
std::shared_lock lock(mutex_);
358+
fallback = default_file_io_;
359+
by_prefix = file_io_by_prefix_;
360+
}
361+
std::vector<std::pair<std::shared_ptr<ArrowFileSystemFileIO>, std::vector<std::string>>>
362+
locations_by_io;
299363
for (const auto& file_location : file_locations) {
300-
locations_by_io[&FileIOForPath(file_location)].push_back(file_location);
364+
auto file_io = MatchDelegate(fallback, by_prefix, file_location);
365+
auto it = std::ranges::find_if(
366+
locations_by_io, [&](const auto& entry) { return entry.first == file_io; });
367+
if (it == locations_by_io.end()) {
368+
locations_by_io.emplace_back(std::move(file_io),
369+
std::vector<std::string>{file_location});
370+
} else {
371+
it->second.push_back(file_location);
372+
}
301373
}
302374
for (auto& [file_io, locations] : locations_by_io) {
303375
ICEBERG_RETURN_UNEXPECTED(file_io->DeleteFiles(locations));

‎src/iceberg/file_io.h‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,11 @@ class ICEBERG_EXPORT SupportsStorageCredentials {
193193
virtual Status SetStorageCredentials(
194194
const std::vector<StorageCredential>& storage_credentials) = 0;
195195

196-
/// \brief Return currently installed storage credentials.
197-
virtual const std::vector<StorageCredential>& credentials() const = 0;
196+
/// \brief Return the storage credentials this FileIO holds.
197+
///
198+
/// By value because a concurrent install may replace them. An implementation
199+
/// that delegates may report what was installed on it.
200+
virtual std::vector<StorageCredential> credentials() const = 0;
198201
};
199202

200203
} // namespace iceberg

‎src/iceberg/resolving_file_io.cc‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ Status ResolvingFileIO::SetStorageCredentials(
109109
return {};
110110
}
111111

112-
const std::vector<StorageCredential>& ResolvingFileIO::credentials() const {
112+
std::vector<StorageCredential> ResolvingFileIO::credentials() const {
113+
std::shared_lock lock(mutex_);
113114
return storage_credentials_;
114115
}
115116

‎src/iceberg/resolving_file_io.h‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@
3838
namespace iceberg {
3939

4040
/// \brief FileIO that resolves and caches implementations by registry name.
41+
///
42+
/// Vended credentials are forwarded to every resolved implementation that
43+
/// supports them; each applies what it understands.
4144
class ICEBERG_EXPORT ResolvingFileIO final : public FileIO,
4245
public SupportsStorageCredentials {
4346
public:
@@ -58,7 +61,7 @@ class ICEBERG_EXPORT ResolvingFileIO final : public FileIO,
5861
Status SetStorageCredentials(
5962
const std::vector<StorageCredential>& storage_credentials) override;
6063

61-
const std::vector<StorageCredential>& credentials() const override;
64+
std::vector<StorageCredential> credentials() const override;
6265

6366
SupportsStorageCredentials* AsSupportsStorageCredentials() override { return this; }
6467

@@ -67,8 +70,8 @@ class ICEBERG_EXPORT ResolvingFileIO final : public FileIO,
6770
Result<std::shared_ptr<FileIO>> FileIOForPath(std::string_view location);
6871

6972
std::unordered_map<std::string, std::string> properties_;
70-
// Guards lazy resolution and credential refresh.
71-
std::shared_mutex mutex_;
73+
// Guards lazy resolution and credential state.
74+
mutable std::shared_mutex mutex_;
7275
std::vector<StorageCredential> storage_credentials_;
7376
std::unordered_map<std::string, std::shared_ptr<FileIO>, StringHash, StringEqual>
7477
io_by_name_;

‎src/iceberg/test/arrow_s3_file_io_test.cc‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@
1818
*/
1919

2020
#include <algorithm>
21+
#include <atomic>
2122
#include <cstdlib>
2223
#include <iostream>
2324
#include <memory>
2425
#include <optional>
2526
#include <string>
2627
#include <string_view>
28+
#include <thread>
2729
#include <unordered_map>
2830
#include <utility>
2931
#include <vector>
@@ -226,6 +228,69 @@ TEST_F(ArrowS3FileIOTest, WarnsWhenNoCredentialApplies) {
226228
EXPECT_TRUE(HasWarning(*logger));
227229
}
228230

231+
TEST_F(ArrowS3FileIOTest, DeleteFilesDispatchesAcrossCredentialPrefixes) {
232+
auto result = MakeS3FileIO({});
233+
ASSERT_THAT(result, IsOk());
234+
auto* credentialed = result.value()->AsSupportsStorageCredentials();
235+
ASSERT_NE(credentialed, nullptr);
236+
237+
auto credential = [](std::string_view prefix, std::string_view access_key) {
238+
return StorageCredential{
239+
.prefix = std::string(prefix),
240+
.config = {{std::string(S3Properties::kAccessKeyId), std::string(access_key)},
241+
{std::string(S3Properties::kSecretAccessKey), "secret"}}};
242+
};
243+
ASSERT_THAT(credentialed->SetStorageCredentials({credential("s3://bucket-a", "key-a"),
244+
credential("s3://bucket-b", "key-b")}),
245+
IsOk());
246+
247+
auto status = result.value()->DeleteFiles({"s3://bucket-a/%ZZ.parquet",
248+
"s3://bucket-a/second.parquet",
249+
"s3://bucket-b/other.parquet"});
250+
EXPECT_THAT(status, HasErrorMessage("Cannot parse URI"));
251+
}
252+
253+
TEST_F(ArrowS3FileIOTest, OperationsSurviveConcurrentCredentialInstalls) {
254+
auto result = MakeS3FileIO({});
255+
ASSERT_THAT(result, IsOk());
256+
auto* credentialed = result.value()->AsSupportsStorageCredentials();
257+
ASSERT_NE(credentialed, nullptr);
258+
259+
auto credential = [](std::string_view access_key) {
260+
return StorageCredential{
261+
.prefix = "s3://bucket",
262+
.config = {{std::string(S3Properties::kAccessKeyId), std::string(access_key)},
263+
{std::string(S3Properties::kSecretAccessKey), "secret"}}};
264+
};
265+
ASSERT_THAT(credentialed->SetStorageCredentials({credential("first")}), IsOk());
266+
267+
std::atomic<bool> stop = false;
268+
std::atomic<int> failures = 0;
269+
std::vector<std::thread> operations;
270+
operations.reserve(4);
271+
for (int i = 0; i < 4; ++i) {
272+
operations.emplace_back([&] {
273+
while (!stop.load()) {
274+
if (!result.value()->NewInputFile("s3://bucket/key").has_value()) {
275+
++failures;
276+
}
277+
}
278+
});
279+
}
280+
// No assertions until the threads are joined: a fatal assertion here would
281+
// destroy joinable threads and terminate the binary, masking the failure.
282+
Status install_status = {};
283+
for (int round = 0; round < 3 && install_status.has_value(); ++round) {
284+
install_status = credentialed->SetStorageCredentials({credential("replacement")});
285+
}
286+
stop = true;
287+
for (auto& operation : operations) {
288+
operation.join();
289+
}
290+
ASSERT_THAT(install_status, IsOk());
291+
EXPECT_EQ(failures, 0);
292+
}
293+
229294
TEST_F(ArrowS3FileIOTest, RejectsIncompleteStaticCredentials) {
230295
auto result =
231296
MakeS3FileIO({{std::string(S3Properties::kAccessKeyId), "access-key-only"}});

‎src/iceberg/test/resolving_file_io_test.cc‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,7 @@ class RecordingCredentialedFileIO : public RecordingFileIO,
6060
return {};
6161
}
6262

63-
const std::vector<StorageCredential>& credentials() const override {
64-
return credentials_;
65-
}
63+
std::vector<StorageCredential> credentials() const override { return credentials_; }
6664

6765
SupportsStorageCredentials* AsSupportsStorageCredentials() override { return this; }
6866

‎src/iceberg/test/rest_file_io_test.cc‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ class MockCredentialedFileIO : public MockFileIO, public SupportsStorageCredenti
6161
return {};
6262
}
6363

64-
const std::vector<StorageCredential>& credentials() const override {
64+
std::vector<StorageCredential> credentials() const override {
6565
return captured_storage_credentials;
6666
}
6767

0 commit comments

Comments
 (0)