You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by su...@apache.org on 2026/09/12 07:23:26 UTC

(opendal) 01/01: feat(services/cos): support object restoration

This is an automated email from the ASF dual-hosted git repository.

suyanhanx pushed a commit to branch feat/cos-restore
in repository https://gitbox.apache.org/repos/asf/opendal.git

commit 792f1eb9e7eb665c91e067c54b44f583b96f9cc1
Author: suyanhanx <su...@gmail.com>
AuthorDate: Sat Sep 12 14:49:28 2026 +0800

    feat(services/cos): support object restoration
---
 core/services/cos/src/backend.rs     | 108 +++++++++++++++++++++++++++++++++++
 core/services/cos/src/core.rs        |   6 +-
 core/services/cos/src/docs.md        |  16 ++++++
 core/tests/behavior/async_restore.rs |  23 +++++++-
 4 files changed, 151 insertions(+), 2 deletions(-)

diff --git a/core/services/cos/src/backend.rs b/core/services/cos/src/backend.rs
index a6145d0e1..f6201a55a 100644
--- a/core/services/cos/src/backend.rs
+++ b/core/services/cos/src/backend.rs
@@ -18,6 +18,7 @@
 use std::fmt::Debug;
 use std::sync::Arc;
 
+use bytes::Buf;
 use http::StatusCode;
 use http::Uri;
 use log::debug;
@@ -249,6 +250,9 @@ impl Builder for CosBuilder {
             write_with_content_disposition: true,
             write_with_if_not_exists: true,
             copy_with_if_not_exists: true,
+            copy_with_source_version: true,
+            restore: true,
+            restore_with_version: true,
             // The min multipart size of COS is 1 MiB.
             //
             // ref: <https://www.tencentcloud.com/document/product/436/14112>
@@ -490,6 +494,110 @@ impl Service for CosBackend {
         ))
     }
 
+    async fn restore(
+        &self,
+        ctx: &OperationContext,
+        path: &str,
+        args: OpRestore,
+    ) -> Result<RpRestore> {
+        if let Some(version) = args.version() {
+            let copy_args = OpCopy::from_options(
+                &self.capability(),
+                options::CopyOptions {
+                    source_version: Some(version.to_owned()),
+                    if_not_exists: args.if_not_exists(),
+                    ..Default::default()
+                },
+            )?;
+            let resp = self
+                .core
+                .cos_copy_object(ctx, path, path, &copy_args)
+                .await?;
+            return match resp.status() {
+                StatusCode::OK => Ok(RpRestore::new()),
+                _ => Err(parse_error(
+                    ErrorContext::new(ServiceOperation("CopyObject")),
+                    resp,
+                )),
+            };
+        }
+
+        if args.if_not_exists() {
+            return Err(Error::new(
+                ErrorKind::ConfigInvalid,
+                "if_not_exists requires a restore version",
+            ));
+        }
+
+        let resp = self
+            .core
+            .cos_list_object_versions(ctx, path, "", Some(1), "", "")
+            .await?;
+        if resp.status() != StatusCode::OK {
+            return Err(parse_error(
+                ErrorContext::new(ServiceOperation("ListObjectVersions")),
+                resp,
+            ));
+        }
+
+        let output: ListObjectVersionsOutput =
+            quick_xml::de::from_reader(resp.into_body().reader())
+                .map_err(new_xml_deserialize_error)
+                .map_err(Error::set_temporary)?;
+        let abs_path = build_abs_path(&self.core.root, path);
+
+        if output
+            .version
+            .iter()
+            .any(|version| version.key == abs_path && version.is_latest)
+        {
+            return Ok(RpRestore::new());
+        }
+
+        let Some(marker) = output
+            .delete_marker
+            .into_iter()
+            .find(|marker| marker.key == abs_path && marker.is_latest)
+        else {
+            return Err(Error::new(
+                ErrorKind::NotFound,
+                "no live object or current delete marker exists",
+            ));
+        };
+
+        let delete_args = OpDelete::from_options(
+            &self.capability(),
+            options::DeleteOptions {
+                version: Some(marker.version_id.clone()),
+                ..Default::default()
+            },
+        )?;
+        let resp = self.core.cos_delete_object(ctx, path, &delete_args).await?;
+        match resp.status() {
+            StatusCode::NO_CONTENT => {
+                // COS can still list a marker after it was deleted. Only a
+                // deletion of an existing marker returns this response header.
+                if resp
+                    .headers()
+                    .get("x-cos-delete-marker")
+                    .is_some_and(|v| v == "true")
+                {
+                    Ok(RpRestore::new())
+                } else {
+                    Err(Error::new(
+                        ErrorKind::Unexpected,
+                        "the listed delete marker no longer exists; retry after the version listing updates",
+                    )
+                    .set_temporary())
+                }
+            }
+            _ => Err(parse_error(
+                ErrorContext::new(ServiceOperation("DeleteObject")),
+                resp,
+            )),
+        }
+    }
+
     async fn presign(
         &self,
         ctx: &OperationContext,
diff --git a/core/services/cos/src/core.rs b/core/services/cos/src/core.rs
index ae25ea245..e427281aa 100644
--- a/core/services/cos/src/core.rs
+++ b/core/services/cos/src/core.rs
@@ -365,7 +365,11 @@ impl CosCore {
         let source = build_abs_path(&self.root, from);
         let target = build_abs_path(&self.root, to);
 
-        let source = format!("/{}/{}", self.bucket, percent_encode_path(&source));
+        let mut source = format!("/{}/{}", self.bucket, percent_encode_path(&source));
+        if let Some(version) = args.source_version() {
+            source.push_str("?versionId=");
+            source.push_str(&percent_encode_path(version));
+        }
         let url = format!("{}/{}", self.endpoint, percent_encode_path(&target));
 
         let mut req = Request::put(&url)
diff --git a/core/services/cos/src/docs.md b/core/services/cos/src/docs.md
index 23c320f29..55f6816dd 100644
--- a/core/services/cos/src/docs.md
+++ b/core/services/cos/src/docs.md
@@ -51,3 +51,19 @@ async fn main() -> Result<()> {
     Ok(())
 }
 ```
+
+## Restore
+
+Restore requires a bucket with versioning enabled and permission to list object
+versions, delete specific versions, and copy objects. `Operator::restore` removes
+the current delete marker, or succeeds without changing an already-live object.
+Each call removes only one current marker; repeated deletions can require repeated
+restore calls. An unknown path returns `NotFound`.
+
+`Operator::restore_with(...).version(...)` copies the selected historical version
+to the same path as a new current version. Conditional restore with
+`if_not_exists` is not supported.
+
+COS version listings can briefly retain a deleted marker. If the listed marker
+no longer exists, restore returns a temporary error rather than reporting that
+another deletion was restored. Use `RetryLayer` to retry after the listing updates.
diff --git a/core/tests/behavior/async_restore.rs b/core/tests/behavior/async_restore.rs
index a8b4f88f5..6d4d269ab 100644
--- a/core/tests/behavior/async_restore.rs
+++ b/core/tests/behavior/async_restore.rs
@@ -28,7 +28,8 @@ pub fn tests(op: &Operator, tests: &mut Vec<Trial>) {
             test_restore_deleted_file,
             test_restore_repeated_delete,
             test_restore_live_file,
-            test_restore_not_found
+            test_restore_not_found,
+            test_restore_not_found_with_matching_prefix
         ));
     }
 
@@ -71,6 +72,7 @@ pub async fn test_restore_repeated_delete(op: Operator) -> Result<()> {
     op.delete(&path).await?;
 
     op.restore(&path).await?;
+    assert!(!op.exists(&path).await?);
     op.restore(&path).await?;
     assert_eq!(op.read(&path).await?.to_bytes(), content);
     Ok(())
@@ -160,3 +162,22 @@ pub async fn test_restore_with_if_not_exists_conflict(op: Operator) -> Result<()
     assert_eq!(op.read(&path).await?.to_bytes(), new_content);
     Ok(())
 }
+
+/// A prefix match must not restore or otherwise change a different object.
+pub async fn test_restore_not_found_with_matching_prefix(op: Operator) -> Result<()> {
+    let (path, content, _) = TEST_FIXTURE.new_file(op.clone());
+    op.write(&path, content.clone()).await?;
+    op.delete(&path).await?;
+
+    let prefix = &path[..path.len() - 1];
+    let err = op
+        .restore(prefix)
+        .await
+        .expect_err("a matching prefix is not a recoverable object");
+    assert_eq!(err.kind(), ErrorKind::NotFound);
+    assert!(!op.exists(&path).await?);
+
+    op.restore(&path).await?;
+    assert_eq!(op.read(&path).await?.to_bytes(), content);
+    Ok(())
+}