feat(services/s3): add conditional delete with if-match (#7607)
* feat(services/s3): add conditional delete with if-match (closes #7090)
* fix: resolve conditional delete CI failures
---------
Co-authored-by: Xuanwo <github@xuanwo.io>
diff --git a/.github/services/s3/0_minio_s3/action.yml b/.github/services/s3/0_minio_s3/action.yml
index f74a077..390e0c9 100644
--- a/.github/services/s3/0_minio_s3/action.yml
+++ b/.github/services/s3/0_minio_s3/action.yml
@@ -43,5 +43,5 @@
OPENDAL_S3_ACCESS_KEY_ID=minioadmin
OPENDAL_S3_SECRET_ACCESS_KEY=minioadmin
OPENDAL_S3_REGION=us-east-1
- OPENDAL_TEST_CAPABILITY_OVERRIDES=stat_with_version=false,read_with_version=false,delete_with_version=false,list_with_versions=false,list_with_deleted=false,copy_with_source_version=false,write_can_append=false,copy_with_if_not_exists=false,copy_with_if_match=false
+ OPENDAL_TEST_CAPABILITY_OVERRIDES=stat_with_version=false,read_with_version=false,delete_with_version=false,list_with_versions=false,list_with_deleted=false,copy_with_source_version=false,write_can_append=false,copy_with_if_not_exists=false,copy_with_if_match=false,delete_with_if_match=false
EOF
diff --git a/.github/services/s3/minio_s3_with_anonymous/action.yml b/.github/services/s3/minio_s3_with_anonymous/action.yml
index 5bd46d4..fc0ee27 100644
--- a/.github/services/s3/minio_s3_with_anonymous/action.yml
+++ b/.github/services/s3/minio_s3_with_anonymous/action.yml
@@ -49,5 +49,5 @@
OPENDAL_S3_REGION=us-east-1
OPENDAL_S3_ALLOW_ANONYMOUS=on
OPENDAL_S3_DISABLE_EC2_METADATA=on
- OPENDAL_TEST_CAPABILITY_OVERRIDES=stat_with_version=false,read_with_version=false,delete_with_version=false,list_with_versions=false,list_with_deleted=false,copy_with_source_version=false,write_can_append=false,copy_with_if_not_exists=false,copy_with_if_match=false
+ OPENDAL_TEST_CAPABILITY_OVERRIDES=stat_with_version=false,read_with_version=false,delete_with_version=false,list_with_versions=false,list_with_deleted=false,copy_with_source_version=false,write_can_append=false,copy_with_if_not_exists=false,copy_with_if_match=false,delete_with_if_match=false
EOF
diff --git a/.github/services/s3/minio_s3_with_list_objects_v1/action.yml b/.github/services/s3/minio_s3_with_list_objects_v1/action.yml
index af0e197..11c6c28 100644
--- a/.github/services/s3/minio_s3_with_list_objects_v1/action.yml
+++ b/.github/services/s3/minio_s3_with_list_objects_v1/action.yml
@@ -44,5 +44,5 @@
OPENDAL_S3_SECRET_ACCESS_KEY=minioadmin
OPENDAL_S3_REGION=us-east-1
OPENDAL_S3_DISABLE_LIST_OBJECTS_V2=true
- OPENDAL_TEST_CAPABILITY_OVERRIDES=stat_with_version=false,read_with_version=false,delete_with_version=false,list_with_versions=false,list_with_deleted=false,copy_with_source_version=false,write_can_append=false,copy_with_if_not_exists=false,copy_with_if_match=false
+ OPENDAL_TEST_CAPABILITY_OVERRIDES=stat_with_version=false,read_with_version=false,delete_with_version=false,list_with_versions=false,list_with_deleted=false,copy_with_source_version=false,write_can_append=false,copy_with_if_not_exists=false,copy_with_if_match=false,delete_with_if_match=false
EOF
diff --git a/.github/services/s3/minio_s3_with_versioning/action.yml b/.github/services/s3/minio_s3_with_versioning/action.yml
index 361bb05..f399771 100644
--- a/.github/services/s3/minio_s3_with_versioning/action.yml
+++ b/.github/services/s3/minio_s3_with_versioning/action.yml
@@ -44,5 +44,5 @@
OPENDAL_S3_ACCESS_KEY_ID=minioadmin
OPENDAL_S3_SECRET_ACCESS_KEY=minioadmin
OPENDAL_S3_REGION=us-east-1
- OPENDAL_TEST_CAPABILITY_OVERRIDES=write_can_append=false,copy_with_if_not_exists=false,copy_with_if_match=false
+ OPENDAL_TEST_CAPABILITY_OVERRIDES=write_can_append=false,copy_with_if_not_exists=false,copy_with_if_match=false,delete_with_if_match=false
EOF
diff --git a/bindings/c/src/operator.rs b/bindings/c/src/operator.rs
index 01b92a7..0a9d77b 100644
--- a/bindings/c/src/operator.rs
+++ b/bindings/c/src/operator.rs
@@ -819,6 +819,7 @@
core::options::DeleteOptions {
version,
recursive: o.recursive,
+ if_match: None,
}
};
match op.deref().delete_options(path, delete_opts) {
diff --git a/bindings/c/src/presign.rs b/bindings/c/src/presign.rs
index 43ce897..4b18cbe 100644
--- a/bindings/c/src/presign.rs
+++ b/bindings/c/src/presign.rs
@@ -241,6 +241,7 @@
core::options::DeleteOptions {
version,
recursive: opts.recursive,
+ if_match: None,
}
};
diff --git a/bindings/cpp/src/lib.rs b/bindings/cpp/src/lib.rs
index e0651b2..0a06516 100644
--- a/bindings/cpp/src/lib.rs
+++ b/bindings/cpp/src/lib.rs
@@ -535,6 +535,7 @@
od::options::DeleteOptions {
version: optional_string(opts.version),
recursive: opts.recursive,
+ if_match: None,
}
}
diff --git a/bindings/dotnet/src/options.rs b/bindings/dotnet/src/options.rs
index 0a84e0e..d0672f4 100644
--- a/bindings/dotnet/src/options.rs
+++ b/bindings/dotnet/src/options.rs
@@ -217,5 +217,6 @@
Ok(opendal::options::DeleteOptions {
version: parse_string(values, "version"),
recursive: parse_bool(values, "recursive")?.unwrap_or(false),
+ if_match: None,
})
}
diff --git a/bindings/nodejs/generated.d.ts b/bindings/nodejs/generated.d.ts
index 8ac5619..cdaeac7 100644
--- a/bindings/nodejs/generated.d.ts
+++ b/bindings/nodejs/generated.d.ts
@@ -1101,6 +1101,11 @@
version?: string
/** Whether to delete recursively. */
recursive?: boolean
+ /** * Sets if-match condition for this operation.
+ * If file exists and its etag does not match, an error of kind
+ * `ConditionNotMatch` will be returned.
+ */
+ ifMatch?: string
}
export declare const enum EntryMode {
diff --git a/bindings/nodejs/src/options.rs b/bindings/nodejs/src/options.rs
index 31a5860..592f170 100644
--- a/bindings/nodejs/src/options.rs
+++ b/bindings/nodejs/src/options.rs
@@ -513,6 +513,12 @@
pub version: Option<String>,
/// Whether to delete recursively.
pub recursive: Option<bool>,
+ /**
+ * Sets if-match condition for this operation.
+ * If file exists and its etag does not match, an error of kind
+ * `ConditionNotMatch` will be returned.
+ */
+ pub if_match: Option<String>,
}
impl From<DeleteOptions> for opendal::options::DeleteOptions {
@@ -520,6 +526,7 @@
Self {
version: value.version,
recursive: value.recursive.unwrap_or_default(),
+ if_match: value.if_match,
}
}
}
diff --git a/bindings/python/python/opendal/operator.pyi b/bindings/python/python/opendal/operator.pyi
index 7062a21..4fddebd 100644
--- a/bindings/python/python/opendal/operator.pyi
+++ b/bindings/python/python/opendal/operator.pyi
@@ -128,6 +128,7 @@
*,
version: str | None = None,
recursive: bool | None = None,
+ if_match: str | None = None,
) -> collections.abc.Awaitable[None]:
"""
Delete a file at the given path.
@@ -150,6 +151,8 @@
recursive : bool, optional
If True, delete the path recursively.
Only supported on backends that support recursive delete.
+ if_match : str, optional
+ If set, only delete when the existing object's ETag matches.
"""
def exists(self, /, path: str | PathLike[str]) -> collections.abc.Awaitable[bool]:
"""
@@ -786,6 +789,7 @@
*,
version: str | None = None,
recursive: bool | None = None,
+ if_match: str | None = None,
) -> None:
"""
Delete a file at the given path.
@@ -803,6 +807,8 @@
recursive : bool, optional
If True, delete the path recursively.
Only supported on backends that support recursive delete.
+ if_match : str, optional
+ If set, only delete when the existing object's ETag matches.
"""
def exists(self, /, path: str | PathLike[str]) -> bool:
"""
diff --git a/bindings/python/python/opendal/options.py b/bindings/python/python/opendal/options.py
index 2011ed2..2862fea 100644
--- a/bindings/python/python/opendal/options.py
+++ b/bindings/python/python/opendal/options.py
@@ -152,6 +152,8 @@
Only supported on backends that support recursive delete.
"""
+ if_match: str
+ """The ETag that the object must match before deletion."""
class OpenKwargs(ReadKwargs, WriteKwargs, total=False):
diff --git a/bindings/python/src/operator.rs b/bindings/python/src/operator.rs
index 3d11d85..47de79e 100644
--- a/bindings/python/src/operator.rs
+++ b/bindings/python/src/operator.rs
@@ -709,18 +709,22 @@
/// recursive : bool, optional
/// If True, delete the path recursively.
/// Only supported on backends that support recursive delete.
- #[pyo3(signature = (path, *, version=None, recursive=None))]
+ /// if_match : str, optional
+ /// If set, only delete when the existing object's ETag matches.
+ #[pyo3(signature = (path, *, version=None, recursive=None, if_match=None))]
pub fn delete(
&self,
path: PathBuf,
version: Option<String>,
recursive: Option<bool>,
+ if_match: Option<String>,
) -> PyResult<()> {
let path = path.to_string_lossy().to_string();
- if version.is_some() || recursive.is_some() {
+ if version.is_some() || recursive.is_some() || if_match.is_some() {
let opts = ocore::options::DeleteOptions {
version,
recursive: recursive.unwrap_or(false),
+ if_match,
};
self.core.delete_options(&path, opts).map_err(format_pyerr)
} else {
@@ -1530,21 +1534,25 @@
/// recursive : bool, optional
/// If True, delete the path recursively.
/// Only supported on backends that support recursive delete.
- #[pyo3(signature = (path, *, version=None, recursive=None) -> "collections.abc.Awaitable[None]")]
+ /// if_match : str, optional
+ /// If set, only delete when the existing object's ETag matches.
+ #[pyo3(signature = (path, *, version=None, recursive=None, if_match=None) -> "collections.abc.Awaitable[None]")]
pub fn delete<'p>(
&'p self,
py: Python<'p>,
path: PathBuf,
version: Option<String>,
recursive: Option<bool>,
+ if_match: Option<String>,
) -> PyResult<Bound<'p, PyAny>> {
let this = self.core.clone();
let path = path.to_string_lossy().to_string();
future_into_py(py, async move {
- if version.is_some() || recursive.is_some() {
+ if version.is_some() || recursive.is_some() || if_match.is_some() {
let opts = ocore::options::DeleteOptions {
version,
recursive: recursive.unwrap_or(false),
+ if_match,
};
this.delete_options(&path, opts).await.map_err(format_pyerr)
} else {
diff --git a/bindings/python/src/options.rs b/bindings/python/src/options.rs
index 1a578d5..0102502 100644
--- a/bindings/python/src/options.rs
+++ b/bindings/python/src/options.rs
@@ -336,6 +336,8 @@
///
/// Only supported on backends that support recursive delete.
pub recursive: Option<bool>,
+ /// The ETag that the object must match before deletion.
+ pub if_match: Option<String>,
}
impl<'a, 'py> FromPyObject<'a, 'py> for DeleteOptions {
@@ -347,6 +349,7 @@
Ok(Self {
version: extract_optional(&dict, "version")?,
recursive: extract_optional(&dict, "recursive")?,
+ if_match: extract_optional(&dict, "if_match")?,
})
}
}
@@ -356,6 +359,7 @@
Self {
version: opts.version,
recursive: opts.recursive.unwrap_or(false),
+ if_match: opts.if_match,
}
}
}
diff --git a/core/core/src/layers/correctness_check.rs b/core/core/src/layers/correctness_check.rs
index f10ef96..603cfd7 100644
--- a/core/core/src/layers/correctness_check.rs
+++ b/core/core/src/layers/correctness_check.rs
@@ -303,6 +303,14 @@
));
}
+ if args.if_match().is_some() && !self.capability.delete_with_if_match {
+ return Err(new_unsupported_error(
+ self.scheme,
+ Operation::Delete,
+ "if_match",
+ ));
+ }
+
Ok(())
}
}
diff --git a/core/core/src/raw/ops.rs b/core/core/src/raw/ops.rs
index 99cdd2d..7f32031 100644
--- a/core/core/src/raw/ops.rs
+++ b/core/core/src/raw/ops.rs
@@ -48,6 +48,9 @@
/// Whether a `delete` is recursive.
recursive: bool,
+
+ /// The ETag that the object must match before deletion.
+ if_match: Option<String>,
}
impl OpDelete {
@@ -70,6 +73,14 @@
self
}
+ /// Set the ETag that the object must match before deletion.
+ ///
+ /// The operation fails when the existing object's ETag does not match.
+ pub fn with_if_match(mut self, if_match: impl Into<String>) -> Self {
+ self.if_match = Some(if_match.into());
+ self
+ }
+
/// Return the version of the object to delete.
pub fn version(&self) -> Option<&str> {
self.version.as_deref()
@@ -79,6 +90,11 @@
pub fn recursive(&self) -> bool {
self.recursive
}
+
+ /// Return the ETag that the object must match before deletion.
+ pub fn if_match(&self) -> Option<&str> {
+ self.if_match.as_deref()
+ }
}
impl From<options::DeleteOptions> for OpDelete {
@@ -86,6 +102,7 @@
Self {
version: value.version,
recursive: value.recursive,
+ if_match: value.if_match,
}
}
}
diff --git a/core/core/src/types/capability.rs b/core/core/src/types/capability.rs
index 9994f6e..cca00a6 100644
--- a/core/core/src/types/capability.rs
+++ b/core/core/src/types/capability.rs
@@ -144,6 +144,8 @@
pub delete_with_version: bool,
/// Indicates if recursive delete operations are supported.
pub delete_with_recursive: bool,
+ /// Indicates if conditional delete operations using If-Match are supported.
+ pub delete_with_if_match: bool,
/// Maximum size supported for single delete operations.
pub delete_max_size: Option<usize>,
diff --git a/core/core/src/types/operator/operator_futures.rs b/core/core/src/types/operator/operator_futures.rs
index d5353dd..b8b6c42 100644
--- a/core/core/src/types/operator/operator_futures.rs
+++ b/core/core/src/types/operator/operator_futures.rs
@@ -1284,6 +1284,12 @@
self.args.recursive = recursive;
self
}
+
+ /// Set `if_match` for this delete operation.
+ pub fn if_match(mut self, etag: &str) -> Self {
+ self.args.if_match = Some(etag.to_string());
+ self
+ }
}
/// This future creates a [`Deleter`].
diff --git a/core/core/src/types/options.rs b/core/core/src/types/options.rs
index 5972183..15c8aa3 100644
--- a/core/core/src/types/options.rs
+++ b/core/core/src/types/options.rs
@@ -32,6 +32,18 @@
/// - If `true`, all entries under the path (or sharing the prefix for file-like paths)
/// will be removed.
pub recursive: bool,
+ /// Sets the condition that delete will succeed only if the existing
+ /// object has the given ETag.
+ ///
+ /// ### Capability
+ ///
+ /// Check [`crate::Capability::delete_with_if_match`] before using this feature.
+ ///
+ /// ### Behavior
+ ///
+ /// - If supported, the delete will only succeed when the existing object's
+ /// ETag matches the given value.
+ pub if_match: Option<String>,
}
/// Options for list operations.
diff --git a/core/services/s3/src/backend.rs b/core/services/s3/src/backend.rs
index 160e737..33081cc 100644
--- a/core/services/s3/src/backend.rs
+++ b/core/services/s3/src/backend.rs
@@ -996,6 +996,7 @@
delete: true,
delete_max_size: Some(DEFAULT_BATCH_MAX_OPERATIONS),
delete_with_version: true,
+ delete_with_if_match: true,
copy: true,
copy_can_multi: true,
diff --git a/core/services/s3/src/core.rs b/core/services/s3/src/core.rs
index a2278a1..0aee67f 100644
--- a/core/services/s3/src/core.rs
+++ b/core/services/s3/src/core.rs
@@ -667,6 +667,11 @@
let mut req = Request::delete(&url);
+ // Set conditional delete header.
+ if let Some(if_match) = args.if_match() {
+ req = req.header(IF_MATCH, if_match);
+ }
+
// Set request payer header if enabled.
req = self.insert_request_payer_header(req);
@@ -1284,6 +1289,7 @@
.map(|(path, op)| DeleteObjectsRequestObject {
key: build_abs_path(&self.root, path),
version_id: op.version().map(|v| v.to_owned()),
+ etag: op.if_match().map(|v| v.to_owned()),
})
.collect(),
})
@@ -1459,6 +1465,8 @@
pub key: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version_id: Option<String>,
+ #[serde(rename = "ETag", skip_serializing_if = "Option::is_none")]
+ pub etag: Option<String>,
}
/// Result of DeleteObjects.
@@ -1721,10 +1729,17 @@
DeleteObjectsRequestObject {
key: "sample1.txt".to_string(),
version_id: None,
+ etag: None,
},
DeleteObjectsRequestObject {
key: "sample2.txt".to_string(),
version_id: Some("11111".to_owned()),
+ etag: None,
+ },
+ DeleteObjectsRequestObject {
+ key: "sample3.txt".to_string(),
+ version_id: None,
+ etag: Some("\"d41d8cd98f00b204e9800998ecf8427e\"".to_owned()),
},
],
};
@@ -1742,6 +1757,10 @@
<Key>sample2.txt</Key>
<VersionId>11111</VersionId>
</Object>
+ <Object>
+ <Key>sample3.txt</Key>
+ <ETag>"d41d8cd98f00b204e9800998ecf8427e"</ETag>
+ </Object>
</Delete>"#
// Cleanup space and new line
.replace([' ', '\n'], "")
diff --git a/core/tests/behavior/async_delete.rs b/core/tests/behavior/async_delete.rs
index ae441cc..9effe5d 100644
--- a/core/tests/behavior/async_delete.rs
+++ b/core/tests/behavior/async_delete.rs
@@ -48,6 +48,13 @@
tests.extend(async_trials!(op, test_remove_all_with_prefix_exists));
}
}
+ if cap.delete_with_if_match {
+ tests.extend(async_trials!(
+ op,
+ test_delete_with_if_match_match,
+ test_delete_with_if_match_mismatch
+ ));
+ }
}
}
@@ -414,3 +421,44 @@
Ok(())
}
+
+/// Delete with a matching `If-Match` ETag should succeed and remove the object.
+pub async fn test_delete_with_if_match_match(op: Operator) -> Result<()> {
+ if !op.info().capability().delete_with_if_match {
+ return Ok(());
+ }
+
+ let (path, content, _) = TEST_FIXTURE.new_file(op.clone());
+ op.write(&path, content).await.expect("write must succeed");
+
+ let meta = op.stat(&path).await.expect("stat must succeed");
+ let etag = meta.etag().expect("etag must be present");
+
+ op.delete_with(&path).if_match(etag).await?;
+
+ assert!(!op.exists(&path).await?);
+
+ Ok(())
+}
+
+/// Delete with a non-matching `If-Match` ETag should fail with
+/// [`ErrorKind::ConditionNotMatch`] and leave the object intact.
+pub async fn test_delete_with_if_match_mismatch(op: Operator) -> Result<()> {
+ if !op.info().capability().delete_with_if_match {
+ return Ok(());
+ }
+
+ let (path, content, _) = TEST_FIXTURE.new_file(op.clone());
+ op.write(&path, content).await.expect("write must succeed");
+
+ let err = op
+ .delete_with(&path)
+ .if_match("\"this-etag-does-not-match\"")
+ .await
+ .expect_err("delete must fail when etag mismatches");
+ assert_eq!(err.kind(), ErrorKind::ConditionNotMatch);
+
+ assert!(op.exists(&path).await?);
+
+ Ok(())
+}
diff --git a/dev/src/generate/options.rs b/dev/src/generate/options.rs
index 16c7031..e513560 100644
--- a/dev/src/generate/options.rs
+++ b/dev/src/generate/options.rs
@@ -220,10 +220,16 @@
);
let delete = &options[4];
- assert_eq!(delete.fields.len(), 2);
+ assert_eq!(delete.fields.len(), 3);
assert_eq!(
delete.fields[1].doc,
"If True, delete the path recursively.\n\nOnly supported on backends that support recursive delete."
);
+ assert_eq!(delete.fields[2].name, "if_match");
+ assert_eq!(delete.fields[2].ty, OptionType::Str);
+ assert_eq!(
+ delete.fields[2].doc,
+ "The ETag that the object must match before deletion."
+ );
}
}