You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/08/07 14:11:41 UTC

(opendal) branch main updated: feat(services/azblob): support if_match writes and honor preconditions on block commit (#8028)

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 73cd73fea5 feat(services/azblob): support if_match writes and honor preconditions on block commit (#8028)
73cd73fea5 is described below

commit 73cd73fea5bd99bfa3531fbe95681fa0bced4ada
Author: Tenzin <14...@users.noreply.github.com>
AuthorDate: Sat Aug 8 00:11:34 2026 +1000

    feat(services/azblob): support if_match writes and honor preconditions on block commit (#8028)
    
    * feat(services/azblob): support if_match writes and honor preconditions on block commit
    
    Azure Blob's Put Blob API supports If-Match with an arbitrary ETag, so wire
    it in alongside the existing If-None-Match support and declare
    write_with_if_match.
    
    Conditional args were only applied to the single-shot Put Blob request.
    Writing more than once before close() commits through Put Block + Put Block
    List instead, and that commit request carried no conditional headers, so
    if_match, if_none_match and if_not_exists were silently dropped and the
    write degraded to an unconditional overwrite. Put Block does not evaluate
    preconditions, so they are applied to Put Block List, which does.
    
    Add behavior tests that force the block path by writing twice before
    close(). They fail against Azurite on the previous code for if_none_match
    and if_not_exists, and pass with this change.
    
    * chore: remove multiwrite helper
    
    ---------
    
    Co-authored-by: Shani Elharrar <sh...@eon.io>
---
 core/services/azblob/src/backend.rs |   1 +
 core/services/azblob/src/core.rs    |  18 ++++++
 core/tests/behavior/async_write.rs  | 113 +++++++++++++++++++++++++++++++++++-
 3 files changed, 131 insertions(+), 1 deletion(-)

diff --git a/core/services/azblob/src/backend.rs b/core/services/azblob/src/backend.rs
index 9e6e946226..7f269ee382 100644
--- a/core/services/azblob/src/backend.rs
+++ b/core/services/azblob/src/backend.rs
@@ -407,6 +407,7 @@ impl Builder for AzblobBuilder {
             write_can_multi: true,
             write_with_cache_control: true,
             write_with_content_type: true,
+            write_with_if_match: true,
             write_with_if_not_exists: true,
             write_with_if_none_match: true,
             write_with_user_metadata: true,
diff --git a/core/services/azblob/src/core.rs b/core/services/azblob/src/core.rs
index 8f9ddc2b58..1bb10e08dc 100644
--- a/core/services/azblob/src/core.rs
+++ b/core/services/azblob/src/core.rs
@@ -306,6 +306,10 @@ impl AzblobCore {
             req = req.header(IF_NONE_MATCH, v);
         }
 
+        if let Some(v) = args.if_match() {
+            req = req.header(IF_MATCH, v);
+        }
+
         if let Some(cache_control) = args.cache_control() {
             req = req.header(constants::X_MS_BLOB_CACHE_CONTROL, cache_control);
         }
@@ -586,6 +590,20 @@ impl AzblobCore {
             req = req.header(constants::X_MS_BLOB_CACHE_CONTROL, cache_control);
         }
 
+        // Put Block List is the request that actually commits a blocked write, so the
+        // write's preconditions have to be evaluated here rather than on Put Block.
+        if args.if_not_exists() {
+            req = req.header(IF_NONE_MATCH, "*");
+        }
+
+        if let Some(v) = args.if_none_match() {
+            req = req.header(IF_NONE_MATCH, v);
+        }
+
+        if let Some(v) = args.if_match() {
+            req = req.header(IF_MATCH, v);
+        }
+
         let content = quick_xml::se::to_string(&PutBlockListRequest {
             latest: block_ids
                 .into_iter()
diff --git a/core/tests/behavior/async_write.rs b/core/tests/behavior/async_write.rs
index d7a88cbada..267bcd4c31 100644
--- a/core/tests/behavior/async_write.rs
+++ b/core/tests/behavior/async_write.rs
@@ -57,7 +57,10 @@ pub fn tests(op: &Operator, tests: &mut Vec<Trial>) {
             test_writer_futures_copy,
             test_writer_futures_copy_with_concurrent,
             test_writer_return_metadata,
-            test_writer_write_non_contiguous_data
+            test_writer_write_non_contiguous_data,
+            test_writer_write_with_if_not_exists,
+            test_writer_write_with_if_none_match,
+            test_writer_write_with_if_match
         ))
     }
 
@@ -810,6 +813,114 @@ pub async fn test_write_with_if_match(op: Operator) -> Result<()> {
     Ok(())
 }
 
+/// Write an existing file through a chunked writer with if_not_exists should get a
+/// ConditionNotMatch error.
+pub async fn test_writer_write_with_if_not_exists(op: Operator) -> Result<()> {
+    let cap = op.info().capability();
+    if !cap.write_with_if_not_exists || !cap.write_can_multi {
+        return Ok(());
+    }
+
+    let path = TEST_FIXTURE.new_file_path();
+    let content = gen_fixed_bytes(cap.write_multi_min_size.unwrap_or(1));
+
+    op.write(&path, content.clone())
+        .await
+        .expect("write must succeed");
+
+    // Some services reject the precondition when the writer is created or on an early
+    // write rather than at commit time
+    let res: opendal::Result<()> = async {
+        let mut w = op.writer_with(&path).if_not_exists(true).await?;
+        w.write(content.clone()).await?;
+        w.write(content.clone()).await?;
+        w.close().await?;
+        Ok(())
+    }
+    .await;
+    assert_eq!(res.unwrap_err().kind(), ErrorKind::ConditionNotMatch);
+
+    Ok(())
+}
+
+/// Write an existing file through a chunked writer with its own etag as if_none_match
+/// should get a ConditionNotMatch error.
+pub async fn test_writer_write_with_if_none_match(op: Operator) -> Result<()> {
+    let cap = op.info().capability();
+    if !cap.write_with_if_none_match || !cap.write_can_multi {
+        return Ok(());
+    }
+
+    let path = TEST_FIXTURE.new_file_path();
+    let content = gen_fixed_bytes(cap.write_multi_min_size.unwrap_or(1));
+
+    op.write(&path, content.clone())
+        .await
+        .expect("write must succeed");
+
+    let meta = op.stat(&path).await?;
+    let etag = meta.etag().expect("etag must exist");
+
+    let res: opendal::Result<()> = async {
+        let mut w = op.writer_with(&path).if_none_match(etag).await?;
+        w.write(content.clone()).await?;
+        w.write(content.clone()).await?;
+        w.close().await?;
+        Ok(())
+    }
+    .await;
+    assert_eq!(res.unwrap_err().kind(), ErrorKind::ConditionNotMatch);
+
+    Ok(())
+}
+
+/// Write a file through a chunked writer with if_match should succeed with the file's own
+/// etag and get a ConditionNotMatch error with a stale one.
+pub async fn test_writer_write_with_if_match(op: Operator) -> Result<()> {
+    let cap = op.info().capability();
+    if !cap.write_with_if_match || !cap.write_can_multi {
+        return Ok(());
+    }
+
+    let path_a = TEST_FIXTURE.new_file_path();
+    let content_a = gen_fixed_bytes(cap.write_multi_min_size.unwrap_or(1));
+    let (path_b, content_b, _) = TEST_FIXTURE.new_file(op.clone());
+
+    op.write(&path_a, content_a.clone()).await?;
+    op.write(&path_b, content_b.clone()).await?;
+
+    let etag_a = op
+        .stat(&path_a)
+        .await?
+        .etag()
+        .expect("etag must exist")
+        .to_string();
+    let etag_b = op
+        .stat(&path_b)
+        .await?
+        .etag()
+        .expect("etag must exist")
+        .to_string();
+
+    let mut w = op.writer_with(&path_a).if_match(&etag_a).await?;
+    w.write(content_a.clone()).await?;
+    w.write(content_a.clone()).await?;
+    w.close().await.expect("close with own etag must succeed");
+
+    // Should fail: writing to path_a with path_b's etag.
+    let res: opendal::Result<()> = async {
+        let mut w = op.writer_with(&path_a).if_match(&etag_b).await?;
+        w.write(content_a.clone()).await?;
+        w.write(content_a.clone()).await?;
+        w.close().await?;
+        Ok(())
+    }
+    .await;
+    assert_eq!(res.unwrap_err().kind(), ErrorKind::ConditionNotMatch);
+
+    Ok(())
+}
+
 pub async fn test_writer_write_non_contiguous_data(op: Operator) -> Result<()> {
     let path = TEST_FIXTURE.new_file_path();
     let size = 1024 * 1024; // write file with 1 MiB