Coverage Report

Created: 2026-10-16 09:02

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
be/src/io/fs/s3_file_system.cpp
Line
Count
Source
1
// Licensed to the Apache Software Foundation (ASF) under one
2
// or more contributor license agreements.  See the NOTICE file
3
// distributed with this work for additional information
4
// regarding copyright ownership.  The ASF licenses this file
5
// to you under the Apache License, Version 2.0 (the
6
// "License"); you may not use this file except in compliance
7
// with the License.  You may obtain a copy of the License at
8
//
9
//   http://www.apache.org/licenses/LICENSE-2.0
10
//
11
// Unless required by applicable law or agreed to in writing,
12
// software distributed under the License is distributed on an
13
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
// KIND, either express or implied.  See the License for the
15
// specific language governing permissions and limitations
16
// under the License.
17
18
#include "io/fs/s3_file_system.h"
19
20
#include <fmt/format.h>
21
22
#include <cstddef>
23
24
#include "common/compiler_util.h" // IWYU pragma: keep
25
// IWYU pragma: no_include <bits/chrono.h>
26
#include <aws/core/utils/threading/Executor.h>
27
#include <aws/s3/S3Client.h>
28
29
#include <chrono> // IWYU pragma: keep
30
#include <filesystem>
31
#include <fstream> // IWYU pragma: keep
32
#include <future>
33
#include <memory>
34
35
#include "common/check.h"
36
#include "common/config.h"
37
#include "common/logging.h"
38
#include "common/status.h"
39
#include "cpp/obj-client/obj_storage_client.h"
40
#include "cpp/obj-client/s3_common.h"
41
#include "cpp/sync_point.h"
42
#include "io/fs/err_utils.h"
43
#include "io/fs/file_system.h"
44
#include "io/fs/file_writer.h"
45
#include "io/fs/local_file_system.h"
46
#include "io/fs/remote_file_system.h"
47
#include "io/fs/s3_file_reader.h"
48
#include "io/fs/s3_file_writer.h"
49
#include "io/obj_storage/obj_client_factory.h"
50
#include "io/obj_storage/obj_storage_config_resolver.h"
51
#include "runtime/exec_env.h"
52
#include "runtime/thread_context.h"
53
#include "util/s3_uri.h"
54
#include "util/s3_util.h"
55
56
namespace doris::io {
57
namespace {
58
constexpr std::string_view OSS_PRIVATE_ENDPOINT_SUFFIX = "-internal.aliyuncs.com";
59
constexpr int LEN_OF_OSS_PRIVATE_SUFFIX = 9; // length of "-internal"
60
61
#ifndef CHECK_S3_CLIENT
62
#define CHECK_S3_CLIENT(client)                                 \
63
88.0k
    if (!client) {                                              \
64
0
        return Status::InvalidArgument("init s3 client error"); \
65
0
    }
66
#endif
67
68
1.83M
Result<std::string> get_key(const Path& full_path) {
69
    // FIXME(plat1ko): Check bucket in full path and support relative path
70
1.83M
    S3URI uri(full_path.native());
71
1.83M
    RETURN_IF_ERROR_RESULT(uri.parse());
72
1.83M
    return uri.get_key();
73
1.83M
}
74
75
} // namespace
76
77
ObjClientHolder::ObjClientHolder(S3ClientConf conf)
78
1.60k
        : _state(std::make_shared<ObjClientState>(ObjClientState {
79
1.60k
                  .config = ObjStorageConfigResolver::from_client_conf(conf),
80
1.60k
                  .legacy_conf = std::move(conf),
81
1.60k
                  .client = nullptr,
82
1.60k
                  .generation = 0,
83
1.60k
          })) {}
84
85
ObjClientHolder::ObjClientHolder(ObjClientConfig config)
86
2
        : _state(std::make_shared<ObjClientState>(ObjClientState {
87
2
                  .config = std::move(config),
88
2
                  .legacy_conf = std::nullopt,
89
2
                  .client = nullptr,
90
2
                  .generation = 0,
91
2
          })) {}
92
93
1.59k
ObjClientHolder::~ObjClientHolder() = default;
94
95
1.54k
Status ObjClientHolder::init() {
96
1.54k
    auto current = state();
97
1.54k
    std::shared_ptr<ObjStorageClient> client;
98
1.54k
    if (current->legacy_conf.has_value()) {
99
        // A legacy conf still goes through S3ClientFactory, which applies the legacy
100
        // validation rules (e.g. a non-empty region) and the test client hook.
101
1.54k
        client = DORIS_TRY(S3ClientFactory::instance().create(*current->legacy_conf));
102
1.53k
    } else {
103
18.4E
        client = DORIS_TRY(ObjClientFactory::instance().get_or_create(current->config));
104
18.4E
    }
105
1.53k
    return _publish(current, current->config, current->legacy_conf, std::move(client));
106
1.54k
}
107
108
1
S3ClientConf ObjClientHolder::s3_client_conf() const {
109
1
    auto current = state();
110
1
    DORIS_CHECK(current->legacy_conf.has_value());
111
1
    return *current->legacy_conf;
112
1
}
113
114
536
Status ObjClientHolder::reset(const S3ClientConf& conf) {
115
536
    auto current = state();
116
536
    DORIS_CHECK(current->legacy_conf.has_value());
117
536
    S3ClientConf reset_conf = *current->legacy_conf;
118
536
    reset_conf.ak = conf.ak;
119
536
    reset_conf.sk = conf.sk;
120
536
    reset_conf.token = conf.token;
121
536
    reset_conf.bucket = conf.bucket;
122
536
    reset_conf.connect_timeout_ms = conf.connect_timeout_ms;
123
536
    reset_conf.max_connections = conf.max_connections;
124
536
    reset_conf.request_timeout_ms = conf.request_timeout_ms;
125
536
    reset_conf.use_virtual_addressing = conf.use_virtual_addressing;
126
536
    reset_conf.is_internal_bucket = conf.is_internal_bucket;
127
128
536
    reset_conf.role_arn = conf.role_arn;
129
536
    reset_conf.external_id = conf.external_id;
130
536
    reset_conf.cred_provider_type = conf.cred_provider_type;
131
132
    // Compare full-field equality of the merged conf, not get_hash(): the hash is
133
    // an XOR of crc32s and distinct configurations can collide, which would skip a
134
    // required client rebuild (e.g. a credential update).
135
536
    if (reset_conf == *current->legacy_conf) {
136
532
        return Status::OK(); // Same conf
137
532
    }
138
139
4
    auto client = DORIS_TRY(S3ClientFactory::instance().create(reset_conf));
140
141
4
    LOG(WARNING) << "reset s3 client with new conf: " << conf.to_string();
142
143
4
    auto config = ObjStorageConfigResolver::from_client_conf(reset_conf);
144
4
    return _publish(current, std::move(config), std::move(reset_conf), std::move(client));
145
4
}
146
147
Status ObjClientHolder::_publish(const std::shared_ptr<const ObjClientState>& expected,
148
                                 ObjClientConfig config, std::optional<S3ClientConf> legacy_conf,
149
1.53k
                                 std::shared_ptr<ObjStorageClient> client) {
150
    // Declared before the lock: after the swap below it holds the replaced state, and
151
    // possibly the last reference to its client, which is then released after unlocking.
152
1.53k
    std::shared_ptr<const ObjClientState> next = std::make_shared<ObjClientState>(ObjClientState {
153
1.53k
            .config = std::move(config),
154
1.53k
            .legacy_conf = std::move(legacy_conf),
155
1.53k
            .client = std::move(client),
156
1.53k
            .generation = expected->generation + 1,
157
1.53k
    });
158
1.53k
    {
159
1.53k
        std::lock_guard lock(_mtx);
160
        // Two resets that both start from generation N merge their conf onto N. Publishing
161
        // the second one on top of N + 1 would silently drop the first update, so it is
162
        // rejected and the caller retries from the new state.
163
1.53k
        if (_state->generation != expected->generation) {
164
1
            return Status::Aborted(
165
1
                    "s3 client was updated concurrently, expected generation {}, current {}",
166
1
                    expected->generation, _state->generation);
167
1
        }
168
1.53k
        _state.swap(next);
169
1.53k
    }
170
0
    return Status::OK();
171
1.53k
}
172
173
Result<int64_t> ObjClientHolder::object_file_size(const std::string& bucket,
174
529
                                                  const std::string& key) const {
175
529
    auto client = get();
176
529
    if (!client) {
177
0
        return ResultError(Status::InvalidArgument("init s3 client error"));
178
0
    }
179
180
529
    auto resp = client->head_object({
181
529
            .bucket = bucket,
182
529
            .key = key,
183
529
    });
184
185
529
    if (resp.resp.status.code != ErrorCode::OK) {
186
3
        return ResultError(std::move(Status(resp.resp.status.code, std::move(resp.resp.status.msg))
187
3
                                             .append(fmt::format("failed to head s3 file {}",
188
3
                                                                 full_s3_path(bucket, key)))));
189
3
    }
190
191
526
    return resp.file_size;
192
529
}
193
194
577
std::string ObjClientHolder::full_s3_path(std::string_view bucket, std::string_view key) const {
195
577
    return fmt::format("{}/{}/{}", state()->config.endpoint(), bucket, key);
196
577
}
197
198
574
std::string S3FileSystem::full_s3_path(std::string_view key) const {
199
574
    return _client->full_s3_path(_bucket, key);
200
574
}
201
202
46
Result<std::shared_ptr<S3FileSystem>> S3FileSystem::create(S3Conf s3_conf, std::string id) {
203
46
    std::shared_ptr<S3FileSystem> fs(new S3FileSystem(std::move(s3_conf), std::move(id)));
204
46
    RETURN_IF_ERROR_RESULT(fs->init());
205
37
    return fs;
206
46
}
207
208
S3FileSystem::S3FileSystem(S3Conf s3_conf, std::string id)
209
46
        : RemoteFileSystem(s3_conf.prefix, std::move(id), FileSystemType::S3),
210
46
          _bucket(std::move(s3_conf.bucket)),
211
46
          _prefix(std::move(s3_conf.prefix)),
212
46
          _client(std::make_shared<ObjClientHolder>(std::move(s3_conf.client_conf))) {
213
    // FIXME(plat1ko): Normalize prefix
214
    // remove the first and last '/'
215
46
    if (!_prefix.empty()) {
216
44
        size_t start = _prefix.find_first_not_of('/');
217
44
        if (start == std::string::npos) {
218
0
            _prefix = "";
219
44
        } else {
220
44
            size_t end = _prefix.find_last_not_of('/');
221
44
            if (start > 0 || end < _prefix.size() - 1) {
222
0
                _prefix = _prefix.substr(start, end - start + 1);
223
0
            }
224
44
        }
225
44
    }
226
46
}
227
228
46
Status S3FileSystem::init() {
229
46
    return _client->init();
230
46
}
231
232
32
S3FileSystem::~S3FileSystem() = default;
233
234
Status S3FileSystem::create_file_impl(const Path& file, FileWriterPtr* writer,
235
87.3k
                                      const FileWriterOptions* opts) {
236
87.3k
    auto client = _client->get();
237
87.3k
    CHECK_S3_CLIENT(client);
238
87.3k
    auto key = DORIS_TRY(get_key(file));
239
87.3k
    *writer = std::make_unique<S3FileWriter>(_client, _bucket, std::move(key), opts);
240
87.3k
    return Status::OK();
241
87.3k
}
242
243
Status S3FileSystem::open_file_internal(const Path& file, FileReaderSPtr* reader,
244
1.73M
                                        const FileReaderOptions& opts) {
245
1.73M
    TEST_SYNC_POINT_CALLBACK("S3FileSystem::open_file_internal", &file, &opts);
246
1.73M
    auto key = DORIS_TRY(get_key(file));
247
1.73M
    *reader = DORIS_TRY(S3FileReader::create(_client, _bucket, key, opts.file_size, nullptr));
248
1.73M
    return Status::OK();
249
1.73M
}
250
251
0
Status S3FileSystem::create_directory_impl(const Path& dir, bool failed_if_exists) {
252
0
    return Status::OK();
253
0
}
254
255
0
Status S3FileSystem::delete_file_impl(const Path& file) {
256
0
    auto client = _client->get();
257
0
    CHECK_S3_CLIENT(client);
258
259
0
    auto key = DORIS_TRY(get_key(file));
260
261
0
    auto resp = client->delete_object({.bucket = _bucket, .key = key});
262
263
0
    if (resp.status.code == ErrorCode::OK || resp.status.code == ErrorCode::NOT_FOUND) {
264
0
        return Status::OK();
265
0
    }
266
0
    return std::move(Status(resp.status.code, std::move(resp.status.msg))
267
0
                             .append(fmt::format("failed to delete file {}", full_s3_path(key))));
268
0
}
269
270
0
Status S3FileSystem::delete_directory_impl(const Path& dir) {
271
0
    auto client = _client->get();
272
0
    CHECK_S3_CLIENT(client);
273
274
0
    auto prefix = DORIS_TRY(get_key(dir));
275
0
    if (!prefix.empty() && prefix.back() != '/') {
276
0
        prefix.push_back('/');
277
0
    }
278
279
0
    ObjStoragePath delete_path {
280
0
            .path = full_s3_path(prefix),
281
0
            .bucket = _bucket,
282
0
            .prefix = prefix,
283
0
    };
284
0
    auto resp = delete_objects_recursively(std::move(client), delete_path);
285
0
    return {resp.status.code, std::move(resp.status.msg)};
286
0
}
287
288
0
Status S3FileSystem::batch_delete_impl(const std::vector<Path>& remote_files) {
289
0
    auto client = _client->get();
290
0
    CHECK_S3_CLIENT(client);
291
292
    // `DeleteObjectsRequest` can only contain 1000 keys at most.
293
0
    constexpr size_t max_delete_batch = 1000;
294
0
    auto path_iter = remote_files.begin();
295
296
0
    do {
297
0
        std::vector<std::string> objects;
298
0
        auto path_begin = path_iter;
299
0
        for (; path_iter != remote_files.end() && (path_iter - path_begin < max_delete_batch);
300
0
             ++path_iter) {
301
0
            auto key = DORIS_TRY(get_key(*path_iter));
302
0
            objects.emplace_back(std::move(key));
303
0
        }
304
0
        if (objects.empty()) {
305
0
            return Status::OK();
306
0
        }
307
        // clang-format off
308
0
        if (auto resp = client->delete_objects( {.bucket = _bucket,}, std::move(objects)); resp.status.code != ErrorCode::OK) {
309
0
            return {resp.status.code, std::move(resp.status.msg)};
310
0
        }
311
        // clang-format on
312
0
    } while (path_iter != remote_files.end());
313
314
0
    return Status::OK();
315
0
}
316
317
58
Status S3FileSystem::exists_impl(const Path& path, bool* res) const {
318
58
    auto client = _client->get();
319
58
    CHECK_S3_CLIENT(client);
320
58
    auto key = DORIS_TRY(get_key(path));
321
322
58
    VLOG_DEBUG << "key:" << key << " path:" << path;
323
324
58
    auto resp = client->head_object({.bucket = _bucket, .key = key});
325
326
58
    if (resp.resp.status.code == ErrorCode::OK) {
327
53
        *res = true;
328
53
    } else if (resp.resp.status.code == ErrorCode::NOT_FOUND) {
329
4
        *res = false;
330
4
    } else {
331
1
        return std::move(
332
1
                Status(resp.resp.status.code, std::move(resp.resp.status.msg))
333
1
                        .append(fmt::format(" failed to check exists {}", full_s3_path(key))));
334
1
    }
335
57
    return Status::OK();
336
58
}
337
338
8
Status S3FileSystem::file_size_impl(const Path& file, int64_t* file_size) const {
339
8
    auto key = DORIS_TRY(get_key(file));
340
8
    *file_size = DORIS_TRY(_client->object_file_size(_bucket, key));
341
8
    return Status::OK();
342
8
}
343
344
Status S3FileSystem::list_impl(const Path& dir, bool only_file, std::vector<FileInfo>* files,
345
7
                               bool* exists) {
346
    // For object storage, this path is always not exist.
347
    // So we ignore this property and set exists to true.
348
7
    *exists = true;
349
7
    auto client = _client->get();
350
7
    CHECK_S3_CLIENT(client);
351
7
    auto prefix = DORIS_TRY(get_key(dir));
352
7
    if (!prefix.empty() && prefix.back() != '/') {
353
7
        prefix.push_back('/');
354
7
    }
355
356
7
    std::vector<ObjectMeta> objects;
357
7
    auto resp = client->list_objects({.bucket = _bucket, .prefix = prefix}, &objects);
358
7
    if (!resp.ok()) {
359
0
        files->clear();
360
0
        return {resp.status.code, std::move(resp.status.msg)};
361
0
    }
362
7
    files->reserve(files->size() + objects.size());
363
14
    for (auto& obj : objects) {
364
14
        obj.key.erase(0, prefix.size());
365
14
        bool is_dir = obj.key.empty() || obj.key.back() == '/';
366
14
        files->emplace_back(FileInfo {
367
14
                .file_name = std::move(obj.key), .file_size = obj.size, .is_file = !is_dir});
368
14
    }
369
370
7
    return Status::OK();
371
7
}
372
373
0
Status S3FileSystem::rename_impl(const Path& orig_name, const Path& new_name) {
374
0
    return Status::NotSupported("S3FileSystem::rename_impl");
375
0
}
376
377
574
Status S3FileSystem::upload_impl(const Path& local_file, const Path& remote_file) {
378
574
    auto client = _client->get();
379
574
    CHECK_S3_CLIENT(client);
380
381
574
    auto key = DORIS_TRY(get_key(remote_file));
382
574
    auto start = std::chrono::steady_clock::now();
383
574
    FileWriterPtr obj_writer;
384
574
    RETURN_IF_ERROR(create_file_impl(key, &obj_writer, nullptr));
385
574
    FileReaderSPtr local_reader;
386
574
    RETURN_IF_ERROR(io::global_local_filesystem()->open_file(local_file, &local_reader));
387
574
    size_t local_buffer_size = config::s3_file_system_local_upload_buffer_size;
388
574
    std::unique_ptr<char[]> write_buffer =
389
574
            std::make_unique_for_overwrite<char[]>(local_buffer_size);
390
574
    size_t cur_read = 0;
391
1.14k
    while (cur_read < local_reader->size()) {
392
572
        size_t bytes_read = 0;
393
572
        RETURN_IF_ERROR(local_reader->read_at(
394
572
                cur_read, Slice {write_buffer.get(), local_buffer_size}, &bytes_read));
395
572
        RETURN_IF_ERROR(obj_writer->append({write_buffer.get(), bytes_read}));
396
572
        cur_read += bytes_read;
397
572
    }
398
574
    RETURN_IF_ERROR(obj_writer->close());
399
574
    auto duration = std::chrono::duration<float>(std::chrono::steady_clock::now() - start);
400
401
574
    auto size = local_reader->size();
402
574
    LOG(INFO) << "Upload " << local_file.native() << " to " << full_s3_path(key)
403
574
              << ", duration=" << duration.count() << ", bytes=" << size;
404
405
574
    return Status::OK();
406
574
}
407
408
Status S3FileSystem::batch_upload_impl(const std::vector<Path>& local_files,
409
0
                                       const std::vector<Path>& remote_files) {
410
0
    auto client = _client->get();
411
0
    CHECK_S3_CLIENT(client);
412
413
0
    if (local_files.size() != remote_files.size()) {
414
0
        return Status::InvalidArgument("local_files.size({}) != remote_files.size({})",
415
0
                                       local_files.size(), remote_files.size());
416
0
    }
417
418
0
    std::vector<FileWriterPtr> obj_writers(local_files.size());
419
420
0
    auto upload_task = [&, this](size_t idx) {
421
0
        SCOPED_ATTACH_TASK(ExecEnv::GetInstance()->s3_file_buffer_tracker());
422
0
        const auto& local_file = local_files[idx];
423
0
        const auto& remote_file = remote_files[idx];
424
0
        auto& obj_writer = obj_writers[idx];
425
0
        auto key = DORIS_TRY(get_key(remote_file));
426
0
        LOG(INFO) << "Start to upload " << local_file.native() << " to " << full_s3_path(key);
427
0
        RETURN_IF_ERROR(create_file_impl(key, &obj_writer, nullptr));
428
0
        FileReaderSPtr local_reader;
429
0
        RETURN_IF_ERROR(io::global_local_filesystem()->open_file(local_file, &local_reader));
430
0
        size_t local_buffer_size = config::s3_file_system_local_upload_buffer_size;
431
0
        std::unique_ptr<char[]> write_buffer =
432
0
                std::make_unique_for_overwrite<char[]>(local_buffer_size);
433
0
        size_t cur_read = 0;
434
0
        while (cur_read < local_reader->size()) {
435
0
            size_t bytes_read = 0;
436
0
            RETURN_IF_ERROR(local_reader->read_at(
437
0
                    cur_read, Slice {write_buffer.get(), local_buffer_size}, &bytes_read));
438
0
            RETURN_IF_ERROR((*obj_writer).append({write_buffer.get(), bytes_read}));
439
0
            cur_read += bytes_read;
440
0
        }
441
0
        RETURN_IF_ERROR((*obj_writer).close());
442
0
        return Status::OK();
443
0
    };
444
445
0
    Status s = Status::OK();
446
0
    std::vector<std::future<Status>> futures;
447
0
    for (int i = 0; i < local_files.size(); ++i) {
448
0
        auto task = std::make_shared<std::packaged_task<Status(size_t idx)>>(upload_task);
449
0
        futures.emplace_back(task->get_future());
450
0
        auto st = ExecEnv::GetInstance()->s3_file_system_thread_pool()->submit_func(
451
0
                [t = std::move(task), idx = i]() mutable { (*t)(idx); });
452
        // We shouldn't return immediately since the previous submitted tasks might still be running in the thread pool
453
0
        if (!st.ok()) {
454
0
            s = st;
455
0
            break;
456
0
        }
457
0
    }
458
0
    for (auto&& f : futures) {
459
0
        auto cur_s = f.get();
460
0
        if (!cur_s.ok()) {
461
0
            s = std::move(cur_s);
462
0
        }
463
0
    }
464
0
    return s;
465
0
}
466
467
0
Status S3FileSystem::download_impl(const Path& remote_file, const Path& local_file) {
468
0
    auto client = _client->get();
469
0
    CHECK_S3_CLIENT(client);
470
0
    auto key = DORIS_TRY(get_key(remote_file));
471
0
    int64_t size;
472
0
    RETURN_IF_ERROR(file_size(remote_file, &size));
473
0
    std::unique_ptr<char[]> buf = std::make_unique_for_overwrite<char[]>(size);
474
0
    size_t bytes_read = 0;
475
    // clang-format off
476
0
    auto resp = client->get_object( {.bucket = _bucket, .key = key,},
477
0
            buf.get(), 0, size, &bytes_read);
478
    // clang-format on
479
0
    if (resp.status.code != ErrorCode::OK) {
480
0
        return {resp.status.code, std::move(resp.status.msg)};
481
0
    }
482
0
    Aws::OFStream local_file_s;
483
0
    local_file_s.open(local_file, std::ios::out | std::ios::binary);
484
0
    if (local_file_s.good()) {
485
0
        local_file_s << StringViewStream(buf.get(), size).rdbuf();
486
0
    } else {
487
0
        return localfs_error(errno, fmt::format("failed to write file {}", local_file.native()));
488
0
    }
489
490
0
    return Status::OK();
491
0
}
492
493
// oss has public endpoint and private endpoint, is_public_endpoint determines
494
// whether to return a public endpoint.
495
std::string S3FileSystem::generate_presigned_url(const Path& path, int64_t expiration_secs,
496
574
                                                 bool is_public_endpoint) const {
497
574
    std::string key = fmt::format("{}/{}", _prefix, path.native());
498
574
    std::shared_ptr<ObjStorageClient> client;
499
    // Read the conf and the client from one snapshot so a concurrent reset cannot pair the
500
    // endpoint of one generation with the client of another.
501
574
    auto state = _client->state();
502
574
    if (is_public_endpoint && state->config.endpoint().ends_with(OSS_PRIVATE_ENDPOINT_SUFFIX)) {
503
        // An S3FileSystem is always created from an S3Conf, so its holder keeps the legacy
504
        // conf that the public-endpoint client is derived from.
505
574
        DORIS_CHECK(state->legacy_conf.has_value());
506
574
        auto new_s3_conf = *state->legacy_conf;
507
574
        new_s3_conf.endpoint.erase(new_s3_conf.endpoint.size() - OSS_PRIVATE_ENDPOINT_SUFFIX.size(),
508
574
                                   LEN_OF_OSS_PRIVATE_SUFFIX);
509
574
        auto client_result = S3ClientFactory::instance().create(new_s3_conf);
510
574
        if (!client_result) {
511
0
            LOG(WARNING) << "failed to create S3 client for presigned URL: "
512
0
                         << client_result.error();
513
0
            return {};
514
0
        }
515
574
        client = std::move(client_result).value();
516
574
    } else {
517
0
        client = state->client;
518
0
    }
519
574
    return client->generate_presigned_url({.bucket = _bucket, .key = key, .prefix = ""},
520
574
                                          expiration_secs);
521
574
}
522
523
} // namespace doris::io