Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/services/s3/0_minio_s3/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,5 @@ runs:
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,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,write_can_append=false,copy_with_if_not_exists=false,copy_with_if_match=false,delete_with_if_match=false
EOF
2 changes: 1 addition & 1 deletion .github/services/s3/minio_s3_with_anonymous/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,5 @@ runs:
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,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,write_can_append=false,copy_with_if_not_exists=false,copy_with_if_match=false,delete_with_if_match=false
EOF
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,5 @@ runs:
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,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,write_can_append=false,copy_with_if_not_exists=false,copy_with_if_match=false,delete_with_if_match=false
EOF
2 changes: 1 addition & 1 deletion .github/services/s3/minio_s3_with_versioning/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,5 @@ runs:
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
5 changes: 5 additions & 0 deletions bindings/nodejs/generated.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,11 @@ export interface DeleteOptions {
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 {
Expand Down
7 changes: 7 additions & 0 deletions bindings/nodejs/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,13 +512,20 @@ pub struct DeleteOptions {
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 {
fn from(value: DeleteOptions) -> Self {
Self {
version: value.version,
recursive: value.recursive.unwrap_or_default(),
if_match: value.if_match,
}
}
}
2 changes: 2 additions & 0 deletions bindings/python/python/opendal/operator.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,7 @@ class AsyncOperator:
*,
version: builtins.str | None = None,
recursive: builtins.bool | None = None,
if_match: builtins.str | None = None,
) -> collections.abc.Awaitable[None]:
r"""
Delete a file at the given path.
Expand Down Expand Up @@ -3069,6 +3070,7 @@ class Operator:
*,
version: builtins.str | None = None,
recursive: builtins.bool | None = None,
if_match: builtins.str | None = None,
) -> None:
r"""
Delete a file at the given path.
Expand Down
16 changes: 12 additions & 4 deletions bindings/python/src/operator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -527,18 +527,22 @@ impl Operator {
/// The version of the file to delete. Only supported on version-aware backends.
/// 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, the delete will only succeed 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 {
Expand Down Expand Up @@ -1324,21 +1328,25 @@ impl AsyncOperator {
/// The version of the file to delete. Only supported on version-aware backends.
/// 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, the delete will only succeed when the existing object's ETag matches.
#[pyo3(signature = (path, *, version=None, recursive=None, if_match=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 {
Expand Down
3 changes: 3 additions & 0 deletions bindings/python/src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ impl From<StatOptions> for ocore::options::StatOptions {
pub struct DeleteOptions {
pub version: Option<String>,
pub recursive: Option<bool>,
pub if_match: Option<String>,
}

impl<'a, 'py> FromPyObject<'a, 'py> for DeleteOptions {
Expand All @@ -300,6 +301,7 @@ impl<'a, 'py> FromPyObject<'a, 'py> for DeleteOptions {
Ok(Self {
version: extract_optional(&dict, "version")?,
recursive: extract_optional(&dict, "recursive")?,
if_match: extract_optional(&dict, "if_match")?,
})
}
}
Expand All @@ -309,6 +311,7 @@ impl From<DeleteOptions> for ocore::options::DeleteOptions {
Self {
version: opts.version,
recursive: opts.recursive.unwrap_or(false),
if_match: opts.if_match,
}
}
}
8 changes: 8 additions & 0 deletions core/core/src/layers/correctness_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,14 @@ impl<T> CheckWrapper<T> {
));
}

if args.if_match().is_some() && !self.info.full_capability().delete_with_if_match {
return Err(new_unsupported_error(
&self.info,
Operation::Delete,
"if_match",
));
}

Ok(())
}
}
Expand Down
16 changes: 16 additions & 0 deletions core/core/src/raw/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ impl OpCreateDir {
pub struct OpDelete {
version: Option<String>,
recursive: bool,
if_match: Option<String>,
}

impl OpDelete {
Expand All @@ -66,6 +67,15 @@ impl OpDelete {
self
}

/// Set the if_match condition for this delete operation.
///
/// When set, the delete will only proceed if the existing object's ETag
/// matches the given value.
pub fn with_if_match(mut self, if_match: impl Into<String>) -> Self {
self.if_match = Some(if_match.into());
self
}

/// Get the version of this delete operation.
pub fn version(&self) -> Option<&str> {
self.version.as_deref()
Expand All @@ -75,13 +85,19 @@ impl OpDelete {
pub fn recursive(&self) -> bool {
self.recursive
}

/// Get the if_match condition.
pub fn if_match(&self) -> Option<&str> {
self.if_match.as_deref()
}
}

impl From<options::DeleteOptions> for OpDelete {
fn from(value: options::DeleteOptions) -> Self {
Self {
version: value.version,
recursive: value.recursive,
if_match: value.if_match,
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions core/core/src/types/capability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ pub struct Capability {
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>,

Expand Down
6 changes: 6 additions & 0 deletions core/core/src/types/operator/operator_futures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,12 @@ impl<F: Future<Output = Result<()>>> FutureDelete<F> {
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
}
}

/// Future that generated by [`Operator::deleter_with`].
Expand Down
12 changes: 12 additions & 0 deletions core/core/src/types/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ pub struct DeleteOptions {
/// - 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 [`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.
Expand Down
1 change: 1 addition & 0 deletions core/services/s3/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,7 @@ impl Builder for S3Builder {
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,
Expand Down
19 changes: 19 additions & 0 deletions core/services/s3/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,11 @@ impl S3Core {

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);

Expand Down Expand Up @@ -1206,6 +1211,7 @@ impl S3Core {
.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(),
})
Expand Down Expand Up @@ -1380,6 +1386,8 @@ pub struct DeleteObjectsRequestObject {
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.
Expand Down Expand Up @@ -1642,10 +1650,17 @@ mod tests {
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()),
},
],
};
Expand All @@ -1663,6 +1678,10 @@ mod tests {
<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'], "")
Expand Down
48 changes: 48 additions & 0 deletions core/tests/behavior/async_delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ pub fn tests(op: &Operator, tests: &mut Vec<Trial>) {
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
));
}
}
}

Expand Down Expand Up @@ -414,3 +421,44 @@ pub async fn test_batch_delete_with_version(op: Operator) -> Result<()> {

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().full_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().full_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(())
}
Loading