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)
+    }
 }

Reply via email to