Skip to content

Commit 0eb0820

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

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>
@@ -179,7 +182,7 @@ class ArrowS3FileIO final : public FileIO, public SupportsStorageCredentials {
179182
public:
180183
ArrowS3FileIO(std::shared_ptr<::arrow::fs::FileSystem> arrow_fs,
181184
std::unordered_map<std::string, std::string> default_properties)
182-
: default_file_io_(std::move(arrow_fs)),
185+
: default_file_io_(std::make_shared<ArrowFileSystemFileIO>(std::move(arrow_fs))),
183186
default_properties_(std::move(default_properties)) {}
184187

185188
Result<std::unique_ptr<InputFile>> NewInputFile(std::string file_location) override;
@@ -196,27 +199,67 @@ class ArrowS3FileIO final : public FileIO, public SupportsStorageCredentials {
196199
Status SetStorageCredentials(
197200
const std::vector<StorageCredential>& storage_credentials) override;
198201

199-
const std::vector<StorageCredential>& credentials() const override {
202+
std::vector<StorageCredential> credentials() const override {
203+
std::shared_lock lock(mutex_);
200204
return storage_credentials_;
201205
}
202206

203207
SupportsStorageCredentials* AsSupportsStorageCredentials() override { return this; }
204208

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

215246
Status ArrowS3FileIO::SetStorageCredentials(
216247
const std::vector<StorageCredential>& storage_credentials) {
217-
std::vector<std::pair<std::string, std::unique_ptr<ArrowFileSystemFileIO>>>
218-
file_io_by_prefix;
219-
file_io_by_prefix.reserve(storage_credentials.size());
248+
ICEBERG_ASSIGN_OR_RAISE(auto delegates, BuildDelegates(storage_credentials));
249+
auto credentials = storage_credentials;
250+
{
251+
std::unique_lock lock(mutex_);
252+
InstallCredentials(credentials, delegates);
253+
}
254+
// `credentials` and `delegates` now hold the retired generation and destruct
255+
// here, outside the lock.
256+
return {};
257+
}
258+
259+
Result<ArrowS3FileIO::DelegatesByPrefix> ArrowS3FileIO::BuildDelegates(
260+
const std::vector<StorageCredential>& storage_credentials) const {
261+
DelegatesByPrefix delegates;
262+
delegates.reserve(storage_credentials.size());
220263
// TODO(gangwu): Refresh vended credentials via credentials.uri before tokens expire.
221264
for (const auto& credential : storage_credentials) {
222265
ICEBERG_RETURN_UNEXPECTED(credential.Validate());
@@ -231,62 +274,91 @@ Status ArrowS3FileIO::SetStorageCredentials(
231274
properties[key] = value;
232275
}
233276
ICEBERG_ASSIGN_OR_RAISE(auto fs, BuildArrowS3FileSystem(properties));
234-
file_io_by_prefix.emplace_back(
235-
CanonicalizeS3Scheme(credential.prefix),
236-
std::make_unique<ArrowFileSystemFileIO>(std::move(fs)));
277+
delegates.emplace_back(CanonicalizeS3Scheme(credential.prefix),
278+
std::make_shared<ArrowFileSystemFileIO>(std::move(fs)));
237279
}
238-
if (file_io_by_prefix.empty() && !storage_credentials.empty()) {
280+
if (delegates.empty() && !storage_credentials.empty()) {
239281
// Silent skipping of every vended credential is hard to diagnose: S3 access
240282
// would proceed with the default credentials and fail only at IO time.
241283
ICEBERG_LOG_WARN(
242284
"None of the {} vended storage credential(s) has an S3-compatible prefix; "
243285
"S3 access will use the default credentials",
244286
storage_credentials.size());
245287
}
246-
file_io_by_prefix_ = std::move(file_io_by_prefix);
247-
storage_credentials_ = storage_credentials;
248-
return {};
288+
return delegates;
249289
}
250290

251-
ArrowFileSystemFileIO& ArrowS3FileIO::FileIOForPath(std::string_view location) {
252-
if (file_io_by_prefix_.empty()) {
253-
return default_file_io_;
291+
void ArrowS3FileIO::InstallCredentials(
292+
std::vector<StorageCredential>& storage_credentials, DelegatesByPrefix& delegates) {
293+
file_io_by_prefix_.swap(delegates);
294+
storage_credentials_.swap(storage_credentials);
295+
}
296+
297+
std::shared_ptr<ArrowFileSystemFileIO> ArrowS3FileIO::MatchDelegate(
298+
const std::shared_ptr<ArrowFileSystemFileIO>& fallback,
299+
const DelegatesByPrefix& by_prefix, std::string_view location) {
300+
if (by_prefix.empty()) {
301+
return fallback;
254302
}
255303
const std::string canonical = CanonicalizeS3Scheme(location);
256-
ArrowFileSystemFileIO* best = &default_file_io_;
304+
auto best = fallback;
257305
size_t best_len = 0;
258-
for (const auto& [prefix, file_io] : file_io_by_prefix_) {
306+
for (const auto& [prefix, file_io] : by_prefix) {
259307
if (prefix.size() > best_len && canonical.starts_with(prefix)) {
260-
best = file_io.get();
308+
best = file_io;
261309
best_len = prefix.size();
262310
}
263311
}
264-
return *best;
312+
return best;
313+
}
314+
315+
std::shared_ptr<ArrowFileSystemFileIO> ArrowS3FileIO::FileIOForPath(
316+
std::string_view location) {
317+
std::shared_lock lock(mutex_);
318+
return MatchDelegate(default_file_io_, file_io_by_prefix_, location);
265319
}
266320

267321
Result<std::unique_ptr<InputFile>> ArrowS3FileIO::NewInputFile(
268322
std::string file_location) {
269-
return FileIOForPath(file_location).NewInputFile(std::move(file_location));
323+
return FileIOForPath(file_location)->NewInputFile(std::move(file_location));
270324
}
271325

272326
Result<std::unique_ptr<InputFile>> ArrowS3FileIO::NewInputFile(std::string file_location,
273327
size_t length) {
274-
return FileIOForPath(file_location).NewInputFile(std::move(file_location), length);
328+
return FileIOForPath(file_location)->NewInputFile(std::move(file_location), length);
275329
}
276330

277331
Result<std::unique_ptr<OutputFile>> ArrowS3FileIO::NewOutputFile(
278332
std::string file_location) {
279-
return FileIOForPath(file_location).NewOutputFile(std::move(file_location));
333+
return FileIOForPath(file_location)->NewOutputFile(std::move(file_location));
280334
}
281335

282336
Status ArrowS3FileIO::DeleteFile(const std::string& file_location) {
283-
return FileIOForPath(file_location).DeleteFile(file_location);
337+
return FileIOForPath(file_location)->DeleteFile(file_location);
284338
}
285339

286340
Status ArrowS3FileIO::DeleteFiles(const std::vector<std::string>& file_locations) {
287-
std::unordered_map<ArrowFileSystemFileIO*, std::vector<std::string>> locations_by_io;
341+
// One snapshot so the whole batch matches the same delegate generation; only
342+
// ever a handful of delegates, so a linear scan beats hashing.
343+
std::shared_ptr<ArrowFileSystemFileIO> fallback;
344+
DelegatesByPrefix by_prefix;
345+
{
346+
std::shared_lock lock(mutex_);
347+
fallback = default_file_io_;
348+
by_prefix = file_io_by_prefix_;
349+
}
350+
std::vector<std::pair<std::shared_ptr<ArrowFileSystemFileIO>, std::vector<std::string>>>
351+
locations_by_io;
288352
for (const auto& file_location : file_locations) {
289-
locations_by_io[&FileIOForPath(file_location)].push_back(file_location);
353+
auto file_io = MatchDelegate(fallback, by_prefix, file_location);
354+
auto it = std::ranges::find_if(
355+
locations_by_io, [&](const auto& entry) { return entry.first == file_io; });
356+
if (it == locations_by_io.end()) {
357+
locations_by_io.emplace_back(std::move(file_io),
358+
std::vector<std::string>{file_location});
359+
} else {
360+
it->second.push_back(file_location);
361+
}
290362
}
291363
for (auto& [file_io, locations] : locations_by_io) {
292364
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>
@@ -237,6 +239,69 @@ TEST_F(ArrowS3FileIOTest, WarnsWhenNoCredentialApplies) {
237239
EXPECT_TRUE(HasWarning(*logger));
238240
}
239241

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