This is an automated email from the ASF dual-hosted git repository.
xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal.git
The following commit(s) were added to refs/heads/main by this push:
new ec8ff5280 feat(services/swift): add bulk delete support (#7210)
ec8ff5280 is described below
commit ec8ff52804be4872b65b33d5ebbd20f0db535e44
Author: Ben Roeder <[email protected]>
AuthorDate: Sun Feb 22 16:52:45 2026 -0800
feat(services/swift): add bulk delete support (#7210)
feat(services/swift): support bulk delete via BatchDelete trait
Implement oio::BatchDelete for SwiftDeleter, using Swift's
POST ?bulk-delete API to delete up to 10,000 objects per request.
- Add swift_bulk_delete() to SwiftCore for the HTTP request
- Add BulkDeleteResponse serde struct for parsing JSON responses
- Change Deleter type from OneShotDeleter to BatchDeleter
- Declare delete_max_size: 10000 in capabilities
- Add unit tests for bulk delete response parsing
---
core/services/swift/src/backend.rs | 8 ++-
core/services/swift/src/core.rs | 109 +++++++++++++++++++++++++++++++++++++
core/services/swift/src/deleter.rs | 49 ++++++++++++++++-
3 files changed, 163 insertions(+), 3 deletions(-)
diff --git a/core/services/swift/src/backend.rs
b/core/services/swift/src/backend.rs
index 3bc7bd00a..5d5169629 100644
--- a/core/services/swift/src/backend.rs
+++ b/core/services/swift/src/backend.rs
@@ -164,6 +164,7 @@ impl Builder for SwiftBuilder {
write_with_user_metadata: true,
delete: true,
+ delete_max_size: Some(10000),
copy: true,
@@ -195,7 +196,7 @@ impl Access for SwiftBackend {
type Reader = HttpBody;
type Writer = oio::OneShotWriter<SwiftWriter>;
type Lister = oio::PageLister<SwiftLister>;
- type Deleter = oio::OneShotDeleter<SwiftDeleter>;
+ type Deleter = oio::BatchDeleter<SwiftDeleter>;
fn info(&self) -> Arc<AccessorInfo> {
self.core.info.clone()
@@ -245,7 +246,10 @@ impl Access for SwiftBackend {
async fn delete(&self) -> Result<(RpDelete, Self::Deleter)> {
Ok((
RpDelete::default(),
- oio::OneShotDeleter::new(SwiftDeleter::new(self.core.clone())),
+ oio::BatchDeleter::new(
+ SwiftDeleter::new(self.core.clone()),
+ self.core.info.full_capability().delete_max_size,
+ ),
))
}
diff --git a/core/services/swift/src/core.rs b/core/services/swift/src/core.rs
index 6301b7974..b5d702ceb 100644
--- a/core/services/swift/src/core.rs
+++ b/core/services/swift/src/core.rs
@@ -73,6 +73,43 @@ impl SwiftCore {
self.info.http_client().send(req).await
}
+ /// Bulk delete multiple objects in a single request.
+ ///
+ /// Reference:
<https://docs.openstack.org/api-ref/object-store/#bulk-delete>
+ pub async fn swift_bulk_delete(
+ &self,
+ paths: &[(String, OpDelete)],
+ ) -> Result<Response<Buffer>> {
+ // The bulk-delete endpoint is on the account URL (the endpoint
itself).
+ let url = format!("{}?bulk-delete", &self.endpoint);
+
+ let mut req = Request::post(&url);
+
+ req = req.header("X-Auth-Token", &self.token);
+ req = req.header(header::CONTENT_TYPE, "text/plain");
+ req = req.header(header::ACCEPT, "application/json");
+
+ // Body is newline-separated list of URL-encoded paths:
+ // /{container}/{object_path}
+ let body_str: String = paths
+ .iter()
+ .map(|(path, _)| {
+ let abs = build_abs_path(&self.root, path);
+ format!("{}/{}", &self.container, percent_encode_path(&abs))
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+
+ req = req.header(header::CONTENT_LENGTH, body_str.len());
+
+ let req = req
+ .extension(Operation::Delete)
+ .body(Buffer::from(bytes::Bytes::from(body_str)))
+ .map_err(new_request_build_error)?;
+
+ self.info.http_client().send(req).await
+ }
+
pub async fn swift_list(
&self,
path: &str,
@@ -293,6 +330,30 @@ pub enum ListOpResponse {
},
}
+/// Response from Swift bulk-delete API.
+///
+/// Reference: <https://docs.openstack.org/api-ref/object-store/#bulk-delete>
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "PascalCase")]
+#[allow(dead_code)]
+pub struct BulkDeleteResponse {
+ /// Number of objects successfully deleted.
+ #[serde(rename = "Number Deleted")]
+ pub number_deleted: i64,
+ /// Number of objects not found (treated as success).
+ #[serde(rename = "Number Not Found")]
+ pub number_not_found: i64,
+ /// Response status string, e.g. "200 OK".
+ #[serde(rename = "Response Status")]
+ pub response_status: String,
+ /// Per-object errors as [path, status_string] pairs.
+ #[serde(rename = "Errors", default)]
+ pub errors: Vec<Vec<String>>,
+ /// Response body (usually empty on success).
+ #[serde(rename = "Response Body")]
+ pub response_body: Option<String>,
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -350,4 +411,52 @@ mod tests {
Ok(())
}
+
+ #[test]
+ fn parse_bulk_delete_response_test() -> Result<()> {
+ let resp = bytes::Bytes::from(
+ r#"{
+ "Number Deleted": 2,
+ "Number Not Found": 1,
+ "Response Status": "200 OK",
+ "Errors": [],
+ "Response Body": ""
+ }"#,
+ );
+
+ let result: BulkDeleteResponse =
+ serde_json::from_slice(&resp).map_err(new_json_deserialize_error)?;
+
+ assert_eq!(result.number_deleted, 2);
+ assert_eq!(result.number_not_found, 1);
+ assert_eq!(result.response_status, "200 OK");
+ assert!(result.errors.is_empty());
+
+ Ok(())
+ }
+
+ #[test]
+ fn parse_bulk_delete_response_with_errors_test() -> Result<()> {
+ let resp = bytes::Bytes::from(
+ r#"{
+ "Number Deleted": 1,
+ "Number Not Found": 0,
+ "Response Status": "400 Bad Request",
+ "Errors": [
+ ["/container/path/to/file", "403 Forbidden"]
+ ],
+ "Response Body": ""
+ }"#,
+ );
+
+ let result: BulkDeleteResponse =
+ serde_json::from_slice(&resp).map_err(new_json_deserialize_error)?;
+
+ assert_eq!(result.number_deleted, 1);
+ assert_eq!(result.errors.len(), 1);
+ assert_eq!(result.errors[0][0], "/container/path/to/file");
+ assert_eq!(result.errors[0][1], "403 Forbidden");
+
+ Ok(())
+ }
}
diff --git a/core/services/swift/src/deleter.rs
b/core/services/swift/src/deleter.rs
index 99a260eb8..d429a0fa3 100644
--- a/core/services/swift/src/deleter.rs
+++ b/core/services/swift/src/deleter.rs
@@ -34,7 +34,7 @@ impl SwiftDeleter {
}
}
-impl oio::OneShotDelete for SwiftDeleter {
+impl oio::BatchDelete for SwiftDeleter {
async fn delete_once(&self, path: String, _: OpDelete) -> Result<()> {
let resp = self.core.swift_delete(&path).await?;
@@ -46,4 +46,51 @@ impl oio::OneShotDelete for SwiftDeleter {
_ => Err(parse_error(resp)),
}
}
+
+ async fn delete_batch(&self, batch: Vec<(String, OpDelete)>) ->
Result<oio::BatchDeleteResult> {
+ let resp = self.core.swift_bulk_delete(&batch).await?;
+
+ let status = resp.status();
+ if status != StatusCode::OK {
+ return Err(parse_error(resp));
+ }
+
+ let bs = resp.into_body().to_bytes();
+ let result: BulkDeleteResponse =
+ serde_json::from_slice(&bs).map_err(new_json_deserialize_error)?;
+
+ let mut batched_result = oio::BatchDeleteResult {
+ succeeded: Vec::with_capacity(batch.len() - result.errors.len()),
+ failed: Vec::with_capacity(result.errors.len()),
+ };
+
+ for (path, op) in batch {
+ // Check if this path appears in the errors list.
+ // The error paths from Swift include the container prefix, so we
need
+ // to reconstruct the full path for comparison.
+ let abs = build_abs_path(&self.core.root, &path);
+ let full_path = format!("{}/{}", &self.core.container, abs);
+
+ if let Some(error_entry) = result.errors.iter().find(|e| {
+ e.first()
+ .map(|p| percent_decode_path(p) == full_path)
+ .unwrap_or(false)
+ }) {
+ let status_str =
error_entry.get(1).cloned().unwrap_or_default();
+ batched_result.failed.push((
+ path,
+ op,
+ Error::new(
+ ErrorKind::Unexpected,
+ format!("bulk delete error: {status_str}"),
+ ),
+ ));
+ } else {
+ // Either deleted successfully or not found (both are success
for us).
+ batched_result.succeeded.push((path, op));
+ }
+ }
+
+ Ok(batched_result)
+ }
}