diff --git a/src/v/cloud_io/remote.cc b/src/v/cloud_io/remote.cc
index 79c3772ee0603..b7aa3b63163f7 100644
--- a/src/v/cloud_io/remote.cc
+++ b/src/v/cloud_io/remote.cc
@@ -165,11 +165,11 @@ int remote::delete_objects_max_keys() const {
case model::cloud_storage_backend::minio:
// https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html
return 1000;
- case model::cloud_storage_backend::google_s3_compat:
- [[fallthrough]];
case model::cloud_storage_backend::azure:
// Will be supported once azurite supports batch blob delete
- [[fallthrough]];
+ return 256;
+ case model::cloud_storage_backend::google_s3_compat:
+ return 100;
case model::cloud_storage_backend::unknown:
return 1;
}
diff --git a/src/v/cloud_io/tests/s3_imposter.cc b/src/v/cloud_io/tests/s3_imposter.cc
index d3036d08636b6..f0bccfe6223a4 100644
--- a/src/v/cloud_io/tests/s3_imposter.cc
+++ b/src/v/cloud_io/tests/s3_imposter.cc
@@ -369,6 +369,68 @@ struct s3_imposter_fixture::content_handler {
}
return R"xml()xml";
+ } else if (
+ request._method == "POST" && request._url.contains("batch")) {
+ vlog(fixt_log.trace, "Received batch request to {}", request._url);
+ if (
+ expect_iter != expectations.end()
+ && expect_iter->second.body.has_value()) {
+ return expect_iter->second.body.value();
+ }
+ auto keys_to_delete = keys_from_batch_delete_request(ri);
+ vlog(
+ fixt_log.trace,
+ "Parsed batched DELETE request with {} keys",
+ keys_to_delete.size());
+
+ constexpr std::string_view boundary = "response_boundary";
+
+ ss::sstring response;
+ for (size_t i = 0; i < keys_to_delete.size(); ++i) {
+ const auto& [path, key] = keys_to_delete[i];
+ // ss::sstring to_delete = fmt::format("/{}", key().string());
+ auto expect_iter = expectations.find(path);
+ bool obj_present = expect_iter != expectations.end()
+ && expect_iter->second.body.has_value();
+
+ if (!obj_present) {
+ // Missing objects are assumed to be not an error (e.g.
+ // caused by a delete retry).
+ vlog(
+ fixt_log.debug,
+ "Requested DELETE request of {}, not found",
+ path);
+ } else {
+ vlog(fixt_log.trace, "Batched DELETE request of {}", path);
+ expect_iter->second.body = std::nullopt;
+ }
+
+ std::string_view code = [&key, obj_present]() {
+ if (key == "failme") {
+ return "500 Internal Server Error";
+ }
+ return obj_present ? "204 No Content" : "404 Not Found";
+ }();
+
+ response += fmt::format(
+ "--{}\r\n"
+ "Content-Type: application/http\r\n"
+ "Content-ID: response-{}\r\n\r\n"
+ "HTTP/1.1 {}\r\n"
+ "X-GUploader-UploadID: test-upload-id-{}\r\n\r\n",
+ boundary,
+ i,
+ code,
+ i);
+ }
+
+ response += fmt::format("--{}--\r\n", boundary);
+
+ repl.set_status(reply::status_type::ok);
+ repl.set_content_type(
+ fmt::format("multipart/mixed; boundary={}", boundary));
+
+ return response;
} else {
vunreachable("Unhandled request method {}", request._method);
}
@@ -434,18 +496,25 @@ s3_imposter_fixture::get_targets() const {
void s3_imposter_fixture::set_expectations_and_listen(
std::vector expectations,
- std::optional> headers_to_store) {
+ std::optional> headers_to_store,
+ std::set content_type_overrides) {
const ss::sstring url_prefix = "/" + url_base();
for (auto& expectation : expectations) {
expectation.url.insert(
expectation.url.begin(), url_prefix.begin(), url_prefix.end());
}
_server
- ->set_routes(
- [this, &expectations, headers_to_store = std::move(headers_to_store)](
- ss::httpd::routes& r) mutable {
- set_routes(r, expectations, std::move(headers_to_store));
- })
+ ->set_routes([this,
+ &expectations,
+ headers_to_store = std::move(headers_to_store),
+ ct_overrides = std::move(content_type_overrides)](
+ ss::httpd::routes& r) mutable {
+ set_routes(
+ r,
+ expectations,
+ std::move(headers_to_store),
+ std::move(ct_overrides));
+ })
.get();
_server->listen(_server_addr).get();
}
@@ -491,7 +560,8 @@ ss::sstring s3_imposter_fixture::url_base() const {
void s3_imposter_fixture::set_routes(
ss::httpd::routes& r,
const std::vector& expectations,
- std::optional> headers_to_store) {
+ std::optional> headers_to_store,
+ std::set content_type_overrides) {
using namespace ss::httpd;
using reply = ss::http::reply;
_content_handler = ss::make_shared(
@@ -500,7 +570,8 @@ void s3_imposter_fixture::set_routes(
[this](const_req req, reply& repl, [[maybe_unused]] ss::sstring& type) {
return _content_handler->handle(req, repl);
},
- "xml");
+ "xml",
+ std::move(content_type_overrides));
r.add_default_handler(_handler.get());
}
@@ -569,3 +640,42 @@ keys_from_delete_objects_request(const http_test_utils::request_info& req) {
return keys;
}
+
+std::vector>
+keys_from_batch_delete_request(const http_test_utils::request_info& req) {
+ std::vector> keys;
+ auto buffer_stream = std::istringstream{std::string{req.content}};
+
+ // crudely iterate over request lines, stripping out object keys
+ constexpr std::string_view method{"DELETE "};
+
+ std::string line;
+ while (std::getline(buffer_stream, line)) {
+ auto pos = line.find(method);
+ if (pos != 0) {
+ continue;
+ }
+
+ pos += method.size();
+
+ auto ver_pos = line.find(" HTTP/");
+ if (ver_pos == line.npos) {
+ continue;
+ }
+
+ auto path = std::string_view{line}.substr(pos, ver_pos - pos);
+ auto last_slash_pos = path.find_last_of('/');
+ if (last_slash_pos == path.npos) {
+ continue;
+ }
+
+ auto key_pos = last_slash_pos + 1;
+ if (key_pos >= path.size()) {
+ continue;
+ }
+
+ auto key = path.substr(key_pos);
+ keys.emplace_back(path, cloud_storage_clients::object_key{key});
+ }
+ return keys;
+}
diff --git a/src/v/cloud_io/tests/s3_imposter.h b/src/v/cloud_io/tests/s3_imposter.h
index 4875810cb9911..e17fb881f2d86 100644
--- a/src/v/cloud_io/tests/s3_imposter.h
+++ b/src/v/cloud_io/tests/s3_imposter.h
@@ -77,7 +77,8 @@ class s3_imposter_fixture {
void set_expectations_and_listen(
std::vector expectations,
std::optional> headers_to_store
- = std::nullopt);
+ = std::nullopt,
+ std::set content_type_overrides = {});
/// Update expectations for the REST API.
void add_expectations(std::vector expectations);
@@ -122,7 +123,8 @@ class s3_imposter_fixture {
ss::httpd::routes& r,
const std::vector& expectations,
std::optional> headers_to_store
- = std::nullopt);
+ = std::nullopt,
+ std::set content_type_overrides = {});
ss::socket_address _server_addr;
ss::shared_ptr _server;
@@ -157,3 +159,6 @@ cloud_storage_clients::http_byte_range parse_byte_header(std::string_view s);
std::vector
keys_from_delete_objects_request(const http_test_utils::request_info&);
+
+std::vector>
+keys_from_batch_delete_request(const http_test_utils::request_info&);
diff --git a/src/v/cloud_storage/tests/remote_test.cc b/src/v/cloud_storage/tests/remote_test.cc
index 653b794ec8142..ed461ee44512d 100644
--- a/src/v/cloud_storage/tests/remote_test.cc
+++ b/src/v/cloud_storage/tests/remote_test.cc
@@ -917,7 +917,12 @@ TEST_P(all_types_remote_fixture, test_delete_objects_failure_handling) {
}
TEST_P(all_types_gcs_remote_fixture, test_delete_objects_on_unknown_backend) {
- set_expectations_and_listen({});
+ set_expectations_and_listen(
+ {} /* expectations */,
+ std::nullopt /* headers_to_store */,
+ {"multipart/mixed; boundary=response_boundary"}
+ /* content_type_overrides */
+ );
retry_chain_node fib(never_abort, 60s, 20ms);
@@ -945,24 +950,21 @@ TEST_P(all_types_gcs_remote_fixture, test_delete_objects_on_unknown_backend) {
= remote.local().delete_objects(bucket_name, to_delete, fib).get();
ASSERT_EQ(cloud_storage::upload_result::success, result);
- ASSERT_EQ(get_requests().size(), 4);
- auto first_delete = get_requests()[2];
+ ASSERT_EQ(get_requests().size(), 3);
+ auto batch_delete = get_requests()[2];
- std::unordered_set expected_urls{
+ std::vector expected_urls{
"/" + url_base() + "p", "/" + url_base() + "q"};
- ASSERT_EQ(first_delete.method, "DELETE");
- ASSERT_TRUE(expected_urls.contains(first_delete.url));
-
- expected_urls.erase(first_delete.url);
- auto second_delete = get_requests()[3];
- ASSERT_EQ(second_delete.method, "DELETE");
- ASSERT_TRUE(expected_urls.contains(second_delete.url));
+ ASSERT_EQ(batch_delete.method, "POST");
+ ASSERT_TRUE(batch_delete.content.contains(expected_urls[0]));
+ ASSERT_TRUE(batch_delete.content.contains(expected_urls[1]));
}
TEST_P(
all_types_gcs_remote_fixture,
test_delete_objects_on_unknown_backend_result_reduction) {
- set_expectations_and_listen({});
+ set_expectations_and_listen(
+ {}, std::nullopt, {"multipart/mixed; boundary=response_boundary"});
retry_chain_node fib(never_abort, 5s, 20ms);
@@ -981,17 +983,17 @@ TEST_P(
// will time out
cloud_storage_clients::object_key{"failme"}};
+ // key 'failme' will produce a 500 error on the corresponding subrequest in
+ // s3_imposter. by design, this should fail the entire 'delete_objects'
+ // operations, though key 'p' may have been deleted in the process
auto result
= remote.local().delete_objects(bucket_name, to_delete, fib).get();
- if (conf.url_style == cloud_storage_clients::s3_url_style::virtual_host) {
- // Due to virtual-host style addressing, this will timeout as DNS tries
- // to resolve the request with the provided bucket name.
- ASSERT_EQ(cloud_storage::upload_result::timedout, result);
- } else {
- // But, if we have path style addressing, the object won't be found, a
- // warning will be issued, and the request will return success instead.
- ASSERT_EQ(cloud_storage::upload_result::success, result);
- }
+ ASSERT_EQ(cloud_storage::upload_result::failed, result);
+
+ // drop the poison object key
+ to_delete.pop_back();
+ result = remote.local().delete_objects(bucket_name, to_delete, fib).get();
+ ASSERT_EQ(cloud_storage::upload_result::success, result);
}
TEST_P(all_types_remote_fixture, test_filter_by_source) { // NOLINT
@@ -1038,8 +1040,8 @@ TEST_P(all_types_remote_fixture, test_filter_by_source) { // NOLINT
ASSERT_TRUE(
subscription.get().type == api_activity_type::manifest_download);
- // Remove the rtc node from the filter and re-subscribe. This time we should
- // receive the notification.
+ // Remove the rtc node from the filter and re-subscribe. This time we
+ // should receive the notification.
flt.remove_source_to_ignore(&root_rtc);
subscription = remote.local().subscribe(flt);
res = remote.local()
@@ -1099,8 +1101,8 @@ TEST_P(all_types_remote_fixture, test_filter_lifetime_1) { // NOLINT
bucket_name, json_manifest_format_path, actual, child_rtc)
.get();
flt.reset();
- // Notification should be received despite the fact that the filter object
- // is destroyed.
+ // Notification should be received despite the fact that the filter
+ // object is destroyed.
ASSERT_TRUE(res == download_result::success);
ASSERT_TRUE(subscription.available());
ASSERT_TRUE(
diff --git a/src/v/cloud_storage_clients/BUILD b/src/v/cloud_storage_clients/BUILD
index 0141910bbe29d..5dc1d2a60da81 100644
--- a/src/v/cloud_storage_clients/BUILD
+++ b/src/v/cloud_storage_clients/BUILD
@@ -41,6 +41,7 @@ redpanda_cc_library(
"//src/v/cloud_roles:auth_refresh_bg_op",
"//src/v/cloud_roles:types",
"//src/v/config",
+ "//src/v/container:chunked_hash_map",
"//src/v/container:chunked_vector",
"//src/v/container:intrusive",
"//src/v/crash_tracker",
@@ -64,9 +65,11 @@ redpanda_cc_library(
"//src/v/utils:named_type",
"//src/v/utils:retry_chain_node",
"//src/v/utils:stop_signal",
+ "//src/v/utils:uuid",
"@boost//:beast",
"@boost//:lexical_cast",
"@boost//:property_tree",
+ "@rapidjson",
"@seastar",
],
)
diff --git a/src/v/cloud_storage_clients/abs_client.cc b/src/v/cloud_storage_clients/abs_client.cc
index ba9c8ad32946b..7339cb1d5d6bc 100644
--- a/src/v/cloud_storage_clients/abs_client.cc
+++ b/src/v/cloud_storage_clients/abs_client.cc
@@ -11,6 +11,7 @@
#include "cloud_storage_clients/abs_client.h"
#include "base/vlog.h"
+#include "bytes/iostream.h"
#include "bytes/streambuf.h"
#include "cloud_storage_clients/abs_error.h"
#include "cloud_storage_clients/client_pool.h"
@@ -20,10 +21,15 @@
#include "cloud_storage_clients/util.h"
#include "cloud_storage_clients/xml_sax_parser.h"
#include "config/configuration.h"
+#include "container/chunked_hash_map.h"
+#include "http/iobuf_body.h"
#include "http/utils.h"
#include "json/document.h"
#include "json/istreamwrapper.h"
+#include "utils/uuid.h"
+#include
+#include
#include
namespace {
@@ -56,6 +62,9 @@ constexpr boost::beast::string_view expiry_option_name = "x-ms-expiry-option";
constexpr boost::beast::string_view expiry_option_value = "RelativeToNow";
constexpr boost::beast::string_view expiry_time_name = "x-ms-expiry-time";
+constexpr boost::beast::string_view content_type_multipart_val
+ = "multipart/mixed";
+
constexpr boost::beast::string_view
hierarchical_namespace_not_enabled_error_code
= "HierarchicalNamespaceNotEnabled";
@@ -185,6 +194,72 @@ parse_header_error_response(const http::http_response::header_type& hdr) {
return {code, message, hdr.result()};
}
+/// Parse multipart/mixed batch delete response
+/// The response format is:
+/// --boundary
+/// Content-Type: application/http
+/// Content-ID: 0
+///
+/// HTTP/1.1 202 Accepted
+/// x-ms-request-id: ...
+/// ...
+///
+/// --boundary
+/// ... (more responses)
+/// --boundary--
+static cloud_storage_clients::client::delete_objects_result
+parse_batch_delete_response(
+ iobuf buf,
+ const ss::sstring& boundary,
+ const chunked_vector& keys) {
+ cloud_storage_clients::client::delete_objects_result result;
+
+ // Simple multipart parser - split by boundary
+ auto boundary_delim = ssx::sformat("--{}", boundary);
+ util::multipart_response_parser parts{std::move(buf), boundary_delim};
+
+ constexpr auto convert_content_id =
+ [](std::string_view raw) -> std::optional {
+ size_t v{};
+ auto res = std::from_chars(raw.data(), raw.data() + raw.size(), v);
+ return res.ec == std::errc{} ? std::make_optional(v) : std::nullopt;
+ };
+
+ chunked_hash_set content_ids_seen;
+ std::optional part;
+ while ((part = parts.get_part()).has_value()) {
+ iobuf_parser part_parser{std::move(part).value()};
+ auto mime = util::mime_header::from(part_parser);
+ auto maybe_content_id = mime.content_id(convert_content_id);
+ if (!maybe_content_id.has_value()) {
+ throw std::runtime_error(
+ "ABS batch delete response part missing Content-ID");
+ }
+ content_ids_seen.insert(maybe_content_id.value());
+ // having stripped off the MIME headers, now we should have a complete
+ // header in header_buf
+ auto subrequest = util::multipart_subresponse::from(part_parser);
+ if (auto maybe_error = subrequest.error(error_code_name);
+ maybe_error.has_value()) {
+ result.undeleted_keys.push_back({
+ .key = keys[maybe_content_id.value()],
+ .reason = std::move(maybe_error).value(),
+ });
+ }
+ }
+
+ for (auto id : std::views::iota(0ul, keys.size())) {
+ if (!content_ids_seen.contains(id)) {
+ result.undeleted_keys.push_back({
+ .key = keys[id],
+ .reason = "Object missing from batch response",
+ });
+ }
+ }
+
+ return result;
+}
+
abs_request_creator::abs_request_creator(
const abs_configuration& conf,
ss::lw_shared_ptr apply_credentials)
@@ -304,6 +379,137 @@ abs_request_creator::make_delete_blob_request(
return header;
}
+result>>
+abs_request_creator::make_batch_delete_request(
+ const bucket_name& name, const chunked_vector& keys) {
+ // Azure Blob Storage Batch API
+ // https://learn.microsoft.com/en-us/rest/api/storageservices/blob-batch
+ //
+ // POST /?comp=batch HTTP/1.1
+ // Host: {storage-account-id}.blob.core.windows.net
+ // Content-Type: multipart/mixed; boundary=batch_
+ // Content-Length: <...>
+ // x-ms-date: {req-datetime in RFC9110} # added by 'add_auth'
+ // x-ms-version: 2023-01-23 # added by 'add_auth'
+ // Authorization:{signature} # added by 'add_auth'
+ //
+ // Body structure:
+ // --batch_
+ // Content-ID: 0
+ //
+ // DELETE /{container-id}/{blob-id} HTTP/1.1
+ // Content-Type: application/http
+ // Content-Transfer-Encoding: binary
+ // x-ms-delete-snapshots: include
+ // x-ms-date: {req-datetime in RFC9110}
+ // x-ms-version: 2023-01-23 # added by 'add_auth'
+ // Authorization:{signature}
+ //
+ // --batch_
+ // ... (repeat for each blob)
+ // --batch_--
+
+ // Generate unique boundary using counter
+ auto boundary = fmt::format("batch_{}", uuid_t::create());
+
+ // Build the multipart body using iobuf and stream
+ iobuf body;
+ iobuf_ostreambuf obuf(body);
+ std::ostream out(&obuf);
+
+ for (size_t i = 0; i < keys.size(); ++i) {
+ const auto& key = keys[i];
+
+ // Boundary line
+ fmt::print(out, "--{}\r\n", boundary);
+
+ http::client::request_header part_header{};
+ part_header.insert(
+ boost::beast::http::field::content_type, "application/http");
+ part_header.insert(
+ boost::beast::http::field::content_transfer_encoding, "binary");
+ part_header.insert(
+ boost::beast::http::field::content_id, fmt::to_string(i));
+
+ // Create individual delete request for this blob
+
+ // Create a temporary header to get auth headers for this subrequest
+ // NOTE: Per
+ // https://learn.microsoft.com/en-us/rest/api/storageservices/blob-batch?tabs=microsoft-entra-id#request-body
+ // - The subrequests should not have the x-ms-version header.
+ // - The subrequest URL should only have the path of the URL (without
+ // the host).
+ // - Each subrequest is authorized separately, with the provided
+ // information in the subrequest.
+ http::client::request_header subrequest_header{};
+ subrequest_header.method(boost::beast::http::verb::delete_);
+ subrequest_header.target(fmt::format("/{}/{}", name(), key().string()));
+ subrequest_header.insert(delete_snapshot_name, delete_snapshot_value);
+ auto error_code = _apply_credentials->add_auth(subrequest_header);
+ if (error_code) {
+ return error_code;
+ }
+
+ // Content-Length for DELETE is 0
+ subrequest_header.insert(
+ boost::beast::http::field::content_length, fmt::to_string(0));
+
+ for (const auto& f : part_header) {
+ fmt::print(out, "{}: {}\r\n", f.name_string(), f.value());
+ }
+ fmt::print(out, "\r\n");
+
+ fmt::print(
+ out,
+ "{} {} HTTP/1.1\r\n",
+ subrequest_header.method_string(),
+ subrequest_header.target());
+
+ // Add auth headers to body (excluding Host, as per Azure Batch API)
+ for (const auto& field : subrequest_header) {
+ // Skip method, target, and content ID as they're already included
+ fmt::print(out, "{}: {}\r\n", field.name_string(), field.value());
+ }
+
+ fmt::print(out, "\r\n");
+ }
+
+ // Final boundary
+ fmt::print(out, "--{}--\r\n", boundary);
+
+ if (!out.good()) {
+ throw std::runtime_error(
+ fmt::format(
+ "failed to create batch delete request, state: {}", out.rdstate()));
+ }
+
+ // Create the main request header
+ http::client::request_header header{};
+ header.method(boost::beast::http::verb::post);
+ header.target("/?comp=batch");
+ header.insert(
+ boost::beast::http::field::host,
+ boost::beast::string_view{_ap().data(), _ap().length()});
+ header.insert(
+ boost::beast::http::field::content_type,
+ fmt::format("{}; boundary={}", content_type_multipart_val, boundary));
+ header.insert(
+ boost::beast::http::field::content_length,
+ std::to_string(body.size_bytes()));
+
+ auto error_code = _apply_credentials->add_auth(header);
+ if (error_code) {
+ return error_code;
+ }
+
+ util::url_encode_target(header);
+
+ // Convert iobuf to input_stream
+ auto stream = make_iobuf_input_stream(std::move(body));
+
+ return std::make_tuple(std::move(header), std::move(stream));
+}
+
result
abs_request_creator::make_list_blobs_request(
const bucket_name& name,
@@ -857,24 +1063,68 @@ ss::future<> abs_client::do_delete_object(
}
}
-ss::future>
-abs_client::delete_objects(
+ss::future
+abs_client::do_batch_delete_objects(
const bucket_name& bucket,
const chunked_vector& keys,
ss::lowres_clock::duration timeout) {
- abs_client::delete_objects_result delete_objects_result;
- for (const auto& key : keys) {
- try {
- auto res = co_await delete_object(bucket, key, timeout);
- if (res.has_error()) {
- delete_objects_result.undeleted_keys.push_back(
- {key, fmt::format("{}", res.error())});
+ auto request = _requestor.make_batch_delete_request(bucket, keys);
+ if (!request) {
+ co_return ss::coroutine::exception(
+ std::make_exception_ptr(std::system_error(request.error())));
+ }
+ auto& [header, body] = request.value();
+ vlog(abs_log.trace, "send batch delete request:\n{}", header);
+
+ auto response_stream = co_await _client.request(
+ std::move(header), body, timeout);
+
+ co_await response_stream->prefetch_headers();
+ vassert(response_stream->is_header_done(), "Header is not received");
+
+ const auto status = response_stream->get_headers().result();
+ if (status != boost::beast::http::status::accepted) {
+ const auto content_type = util::get_response_content_type(
+ response_stream->get_headers());
+ auto buf = co_await http::drain(std::move(response_stream));
+ throw parse_rest_error_response(content_type, status, std::move(buf));
+ }
+
+ // Extract boundary from Content-Type header
+ const auto& headers = response_stream->get_headers();
+ auto content_type_it = headers.find(
+ boost::beast::http::field::content_type);
+ ss::sstring boundary;
+ if (content_type_it != headers.end()) {
+ ss::sstring content_type{
+ content_type_it->value().data(), content_type_it->value().size()};
+ auto boundary_pos = content_type.find("boundary=");
+ if (boundary_pos != ss::sstring::npos) {
+ boundary = content_type.substr(boundary_pos + 9);
+ // Remove quotes if present
+ if (!boundary.empty() && boundary.front() == '"') {
+ boundary = boundary.substr(1);
+ }
+ if (!boundary.empty() && boundary.back() == '"') {
+ boundary = boundary.substr(0, boundary.size() - 1);
}
- } catch (const std::exception& ex) {
- delete_objects_result.undeleted_keys.push_back({key, ex.what()});
}
}
- co_return delete_objects_result;
+
+ auto response_buf = co_await http::drain(std::move(response_stream));
+ co_return parse_batch_delete_response(
+ std::move(response_buf), boundary, keys);
+}
+
+ss::future>
+abs_client::delete_objects(
+ const bucket_name& bucket,
+ const chunked_vector& keys,
+ ss::lowres_clock::duration timeout) {
+ // Use batch API for efficiency
+ const object_key dummy{""};
+ co_return co_await send_request(
+ do_batch_delete_objects(bucket, keys, timeout), dummy);
}
ss::future>
diff --git a/src/v/cloud_storage_clients/abs_client.h b/src/v/cloud_storage_clients/abs_client.h
index c2e798a313735..e6e00c8b851f9 100644
--- a/src/v/cloud_storage_clients/abs_client.h
+++ b/src/v/cloud_storage_clients/abs_client.h
@@ -65,6 +65,19 @@ class abs_request_creator {
result
make_delete_blob_request(const bucket_name& name, const object_key& key);
+ /// \brief Create a 'Batch Delete' request header and body
+ ///
+ /// Uses the Azure Blob Storage Batch API to delete multiple blobs
+ /// in a single request. The request uses multipart/mixed encoding.
+ ///
+ /// \param name is a container
+ /// \param keys is a vector of blob names to delete
+ /// \return initialized and signed http header and body as input_stream or
+ /// error
+ result>>
+ make_batch_delete_request(
+ const bucket_name& name, const chunked_vector& keys);
+
// clang-format off
/// \brief Initialize http header for 'List Blobs' request
///
@@ -282,6 +295,11 @@ class abs_client : public client {
const object_key& key,
ss::lowres_clock::duration timeout);
+ ss::future do_batch_delete_objects(
+ const bucket_name& bucket,
+ const chunked_vector& keys,
+ ss::lowres_clock::duration timeout);
+
ss::future do_list_objects(
const bucket_name& name,
std::optional prefix,
diff --git a/src/v/cloud_storage_clients/s3_client.cc b/src/v/cloud_storage_clients/s3_client.cc
index 452b9037e536d..af51cb12ef9e2 100644
--- a/src/v/cloud_storage_clients/s3_client.cc
+++ b/src/v/cloud_storage_clients/s3_client.cc
@@ -25,9 +25,12 @@
#include "config/configuration.h"
#include "config/node_config.h"
#include "config/types.h"
+#include "container/chunked_hash_map.h"
#include "hashing/secure.h"
#include "http/client.h"
#include "http/utils.h"
+#include "json/istreamwrapper.h"
+#include "json/reader.h"
#include "utils/base64.h"
#include
@@ -47,6 +50,7 @@
#include
#include
+#include
#include
#include
#include
@@ -382,6 +386,128 @@ request_creator::make_delete_objects_request(
return {std::move(header), make_iobuf_input_stream(std::move(body))};
}
+result>>
+request_creator::make_gcs_batch_delete_request(
+ const bucket_name& name, const chunked_vector& keys) {
+ // Google Cloud Storage Batch API
+ // https://cloud.google.com/storage/docs/batch
+ //
+ // POST /batch/storage/v1 HTTP/1.1
+ // Host: storage.googleapis.com
+ // Content-Type: multipart/mixed; boundary=
+ // Authorization: Bearer # added by 'add_auth'
+ // Content-Length: <...>
+ //
+ // Body structure:
+ // --
+ // Content-Type: application/http
+ // Content-ID:
+ //
+ // DELETE /storage/v1/b//o/