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, ©_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(())
+}