Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
1 change: 1 addition & 0 deletions sdk/cosmos/azure_data_cosmos/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

### Breaking Changes

- `ContainerClient::patch_item()` and `PatchItemOptions` are now gated behind the new, non-default `preview_patch` feature. PATCH is not exactly-once under transport failures: an interrupted patch may re-apply non-idempotent operations (`increment`, `add` on an array, `move`), so it is no longer part of the supported default surface. Enable `preview_patch` to keep using it, and prefer idempotent operations (`set` on a caller-computed value) until the guarantee is fixed. ([#5133](https://github.com/Azure/azure-sdk-for-rust/pull/5133))
- `CosmosClient::database_client` and `DatabaseClient::container_client` now take `impl Into<ResourceIdentity>` instead of `&str`; call sites passing a deref-able string (for example a `Cow<str>` field) need `&*value` or `.as_ref()`. ([#4687](https://github.com/Azure/azure-sdk-for-rust/pull/4687))
- `DatabaseClient::id()` now returns `&ResourceIdentity` instead of `&str`. ([#4687](https://github.com/Azure/azure-sdk-for-rust/pull/4687))
- Control-plane APIs are now gated behind the new `control_plane` feature, which is **not** enabled by default. Code using database or container management (`CosmosClient::create_database`/`query_databases`, `DatabaseClient::read`/`create_container`/`query_containers`/`delete`, `ContainerClient::replace`/`delete`), throughput management (`read_throughput`/`begin_replace_throughput`, `ThroughputPoller`), or the associated model and options types (`DatabaseProperties`, `ThroughputProperties`, and the container create/replace/delete/query, database, and throughput option types) must now enable the `control_plane` feature. Reading container properties via `ContainerClient::read()` — along with `ContainerProperties`, `IndexingPolicy`, `ResourceResponse`, and `ReadContainerOptions` — remains available without the feature, since it works with Entra ID authentication and mirrors the metadata read the SDK already performs internally. ([#4854](https://github.com/Azure/azure-sdk-for-rust/pull/4854))
Expand Down
11 changes: 11 additions & 0 deletions sdk/cosmos/azure_data_cosmos/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ metrics = [
preview_dtx = [
"azure_data_cosmos_driver/preview_dtx",
] # Enables preview Distributed Transaction APIs. Disabled by default and not production-ready.
preview_patch = [
"azure_data_cosmos_driver/preview_patch",
] # Enables the preview PATCH API. Disabled by default and not production-ready: an interrupted patch may re-apply non-idempotent operations.
Comment thread
FabianMeiswinkel marked this conversation as resolved.
Outdated
__internal_in_memory_emulator = [
"azure_data_cosmos_driver/__internal_in_memory_emulator",
"key_auth",
Expand All @@ -123,6 +126,7 @@ features = [
"key_auth",
"metrics",
"native_tls",
"preview_patch",
"rustls",
]

Expand Down Expand Up @@ -172,3 +176,10 @@ required-features = ["__internal_in_memory_emulator"]
name = "cosmos"
path = "examples/cosmos/main.rs"
required-features = ["control_plane"]

# The PATCH example calls the preview `patch_item` API, which only exists
# when `preview_patch` is enabled.
[[example]]
name = "cosmos_patch"
path = "examples/cosmos_patch.rs"
required-features = ["preview_patch"]
37 changes: 27 additions & 10 deletions sdk/cosmos/azure_data_cosmos/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,6 @@ container metadata read the SDK already performs internally.
```rust
use serde::{Serialize, Deserialize};
use azure_data_cosmos::CosmosClient;
use azure_data_cosmos::models::{PatchInstructions, PatchOperation};

#[derive(Serialize, Deserialize)]
struct Item {
Expand Down Expand Up @@ -137,21 +136,39 @@ async fn example(cosmos_client: CosmosClient) -> Result<(), Box<dyn std::error::
// Replace an item
container.replace_item("partition1", "1", item, None).await?;

let patch = PatchInstructions::from(vec![
PatchOperation::set("/value", serde_json::json!("4")),
]);
let patched: Item = container
.patch_item("partition1", "1", patch, None)
.await?
.into_model()?;
println!("patched value = {}", patched.value);

// Delete an item
container.delete_item("partition1", "1", None).await?;
Ok(())
}
```

### Partial updates with PATCH (preview)

`ContainerClient::patch_item()` applies JSON-Patch-style operations to a single item. It is
gated behind the `preview_patch` feature and is **not production-ready**:

```sh
cargo add azure_data_cosmos --features preview_patch
```

```rust,ignore
use azure_data_cosmos::models::{PatchInstructions, PatchOperation};

let patch = PatchInstructions::from(vec![
PatchOperation::set("/value", serde_json::json!("4")),
]);
let patched: Item = container
.patch_item("partition1", "1", patch, None)
.await?
.into_model()?;
```

PATCH is implemented client-side as a read, a local merge, and an ETag-guarded replace. If the
replace is interrupted after the service commits it, the pipeline may retry it and the
read-modify-write loop may re-apply the patch. Non-idempotent operations
(`increment`, `add` on an array, `move`) can therefore be applied **more than once**. Use
idempotent operations such as `set` with a caller-computed value until this limitation is fixed.

## Next steps

* [Resource Model of Azure Cosmos DB Service](https://learn.microsoft.com/azure/cosmos-db/sql-api-resources)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ pub struct ItemReadOptions {

### 5.2 `ItemWriteOptions`

Options for item write operations (`create_item`, `replace_item`, `upsert_item`, `delete_item`, `patch_item`).
Options for item write operations (`create_item`, `replace_item`, `upsert_item`, `delete_item`).

```rust
#[derive(Clone, Default)]
Expand Down
23 changes: 15 additions & 8 deletions sdk/cosmos/azure_data_cosmos/examples/cosmos_patch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@

//! Partial updates with PATCH.
//!
//! **Preview.** Requires the `preview_patch` feature. PATCH is not
//! production-ready: an interrupted patch may re-apply non-idempotent
//! operations. See `ContainerClient::patch_item` rustdoc.
//!
//! Demonstrates `ContainerClient::patch_item` and the `PatchInstructions`
//! builder, exercising every variant of `PatchOperation` (`set`, `add`,
//! `replace`, `remove`, `increment`, `move_value`).
Expand All @@ -15,8 +19,9 @@
//! 1. The SDK reads the current item (capturing its ETag).
//! 2. The SDK applies the patch operations locally.
//! 3. The SDK issues a conditional Replace gated on the ETag from step 1.
//! 4. On a 412 Precondition Failed (mid-air collision), the SDK retries
//! from step 1 up to `PatchItemOptions::with_max_attempts` times.
//! 4. On a 412 Precondition Failed (mid-air collision), the SDK starts another
//! attempt from step 1 until the total `PatchItemOptions::with_max_attempts`
//! budget, including the initial attempt, is exhausted.
//!
//! See `ContainerClient::patch_item` rustdoc for the idempotency caveats.
//!
Expand All @@ -27,7 +32,7 @@
//! ## Running
//!
//! ```text
//! cargo run --example cosmos_patch -- \
//! cargo run --features preview_patch --example cosmos_patch -- \
//! https://<account>.documents.azure.com:443/ --region "East US" --use-entra
//! ```

Expand Down Expand Up @@ -110,9 +115,10 @@ async fn main() -> Result<(), Box<dyn Error>> {
.with_operation(PatchOperation::move_value("/tags/0", "/headline_tag"));

// ----- Issue the patch. -------------------------------------------------
// `with_max_attempts` bounds the SDK's internal RMW retry loop. The
// default (3) is fine for most workloads; very contended items may
// benefit from a higher cap, but consider re-shaping the workload first.
// `with_max_attempts` bounds the total number of RMW attempts, including
// the initial attempt. The default is 5; this example sets it explicitly.
// Very contended items may benefit from a higher cap, but consider
// re-shaping the workload first.
let response = items
.patch_item(
"contoso",
Expand All @@ -130,8 +136,9 @@ async fn main() -> Result<(), Box<dyn Error>> {
response.headers().request_charge(),
);

// The response is the standard `ItemResponse` shape, so we can read the
// patched item back via `into_model::<T>()` when content-on-write is on.
// The response always contains the post-image: the handler uses the
// service body when present and otherwise synthesizes it from the locally
// merged document, so `into_model::<T>()` does not require content-on-write.
let patched: serde_json::Value = response.into_model()?;
println!("after {patched:#}");

Expand Down
22 changes: 16 additions & 6 deletions sdk/cosmos/azure_data_cosmos/src/clients/container_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ use crate::{
clients::ClientContext,
diagnostics::CosmosOperationContext,
feed::{ChangeFeedPageIterator, FeedRange, FeedScope, QueryItemIterator},
models::{BatchResponse, ChangeFeedItem, ItemResponse, PatchInstructions, TransactionalBatch},
models::{BatchResponse, ChangeFeedItem, ItemResponse, TransactionalBatch},
options::{
BatchOptions, BinaryEncodingOptions, ChangeFeedMode, ChangeFeedOptions,
ChangeFeedStartFrom, ItemReadOptions, ItemWriteOptions, OperationOptions, PatchItemOptions,
Precondition, QueryOptions, ReadContainerOptions, ReadFeedRangesOptions, SessionToken,
ChangeFeedStartFrom, ItemReadOptions, ItemWriteOptions, OperationOptions, Precondition,
QueryOptions, ReadContainerOptions, ReadFeedRangesOptions, SessionToken,
},
PartitionKey, Query, ResourceIdentity,
};
#[cfg(feature = "preview_patch")]
use crate::{models::PatchInstructions, options::PatchItemOptions};

use azure_data_cosmos_driver::models::{
ContainerReference, CosmosOperation, ItemReference, PartitionKeyKind,
Expand Down Expand Up @@ -524,6 +526,9 @@ impl ContainerClient {
/// Applies a JSON-PATCH-style update to an item by reading it, applying
/// the [`PatchInstructions`] locally, and issuing an ETag-guarded Replace.
///
/// **Preview.** Requires the `preview_patch` feature. This API is not
/// production-ready — see [Failure Semantics](#failure-semantics) below.
///
/// The handler refuses to PATCH paths that overlap the container's
/// partition-key paths: rewriting the partition key would move the
/// document to a different physical partition, so such requests are
Expand Down Expand Up @@ -590,6 +595,7 @@ impl ContainerClient {
/// appends should either build idempotent ops (`PatchOperation::set` on a
/// caller-computed value) or detect duplicate-application via a
/// monotonic application-level sequence number.
#[cfg(feature = "preview_patch")]
pub async fn patch_item(
&self,
partition_key: impl Into<PartitionKey>,
Expand Down Expand Up @@ -1500,9 +1506,13 @@ fn _assert_futures_are_send() {
let client: &ContainerClient = todo!();
let partition_key: PartitionKey = todo!();
let item_id: &str = todo!();
let patch: PatchInstructions = todo!();
let options: Option<PatchItemOptions> = todo!();
assert_send(client.patch_item(partition_key, item_id, patch, options));
assert_send(client.read_item(partition_key.clone(), item_id, None));
#[cfg(feature = "preview_patch")]
{
let patch: PatchInstructions = todo!();
let options: Option<PatchItemOptions> = todo!();
assert_send(client.patch_item(partition_key, item_id, patch, options));
}
}

#[cfg(test)]
Expand Down
6 changes: 6 additions & 0 deletions sdk/cosmos/azure_data_cosmos/src/options/item.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ impl ItemWriteOptions {

/// Options for [`ContainerClient::patch_item()`](crate::clients::ContainerClient::patch_item()).
///
/// **Preview.** Requires the `preview_patch` feature. PATCH is not
/// production-ready: an interrupted patch may re-apply non-idempotent
/// operations. See [Failure Semantics](crate::clients::ContainerClient::patch_item()).
///
/// PATCH is implemented driver-side as a Read-Modify-Write (RMW) loop:
/// the driver reads the current item, applies your [`PatchInstructions`](crate::models::PatchInstructions)
/// locally, and issues an ETag-guarded Replace. If the Replace returns
Expand Down Expand Up @@ -143,6 +147,7 @@ impl ItemWriteOptions {
/// 412 retries you want to tolerate. Setting the budget too low can
/// cancel the RMW between the Read and the Replace, producing a
/// timeout error even when the service is healthy.
#[cfg(feature = "preview_patch")]
#[derive(Clone, Default)]
#[non_exhaustive]
pub struct PatchItemOptions {
Expand All @@ -158,6 +163,7 @@ pub struct PatchItemOptions {
pub max_attempts: Option<std::num::NonZeroU8>,
}

#[cfg(feature = "preview_patch")]
impl PatchItemOptions {
/// Sets the session token for this request.
pub fn with_session_token(mut self, session_token: impl Into<SessionToken>) -> Self {
Expand Down
4 changes: 3 additions & 1 deletion sdk/cosmos/azure_data_cosmos/src/options/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ pub use database::{
};
pub use feed::{FeedOptions, QueryOptions};
pub use feed_ranges::ReadFeedRangesOptions;
pub use item::{ItemReadOptions, ItemWriteOptions, PatchItemOptions};
#[cfg(feature = "preview_patch")]
pub use item::PatchItemOptions;
pub use item::{ItemReadOptions, ItemWriteOptions};
pub use routing_strategy::RoutingStrategy;
#[cfg(feature = "control_plane")]
pub use throughput::ThroughputOptions;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ pub async fn hpk_item_delete_full_key() -> Result<(), Box<dyn Error>> {
}

/// A5: patch a field on an item addressed by a full 2-level key.
#[cfg(feature = "preview_patch")]
#[tokio::test]
#[cfg_attr(
not(any(test_category = "emulator", test_category = "emulator_vnext")),
Expand Down
1 change: 1 addition & 0 deletions sdk/cosmos/azure_data_cosmos/tests/emulator_tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mod cosmos_hpk;
mod cosmos_items;
mod cosmos_offers;
mod cosmos_partition_key_types;
#[cfg(feature = "preview_patch")]
mod cosmos_patch;
mod cosmos_proxy;
mod cosmos_query;
Expand Down
1 change: 1 addition & 0 deletions sdk/cosmos/azure_data_cosmos_driver/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

### Breaking Changes

- `CosmosOperation::patch_item()`, `with_patch_max_attempts()`, and `patch_max_attempts()` are now gated behind the new, non-default `preview_patch` feature. The read-modify-write loop is not exactly-once under transport failures: an interrupted inner Replace can be retried by the pipeline, observed as a lost race, and re-applied. See `docs/PATCH_HANDLER_SPEC.md`. ([#5133](https://github.com/Azure/azure-sdk-for-rust/pull/5133))
- Resource-reference accessors now return `Option` to account for RID-addressed references that have no name. `DatabaseReference::name_based_path`, `ContainerReference::database_name`, and `ContainerReference::name_based_path` return `None` when the reference is addressed by RID (previously they returned `&str`/`String` and assumed a name was always present). Use the new `ContainerReference::base_path` to obtain the addressing-appropriate path (RID-based or name-based) when building request URLs. ([#4640](https://github.com/Azure/azure-sdk-for-rust/pull/4640))
- `AccountReference`, `DatabaseReference`, `ContainerReference`, and `ItemReference` are now tuple structs wrapping private shared state, so wildcard struct patterns such as `AccountReference { .. }` no longer compile. All accessors are unchanged. `DatabaseReference::into_account` was removed; use `DatabaseReference::account` and clone. ([#4908](https://github.com/Azure/azure-sdk-for-rust/pull/4908))
- `CosmosDriver::plan_operation` now takes an additional `plan_options: &PlanOptions` argument (after `continuation`). The continuation token remains its own argument. ([#4855](https://github.com/Azure/azure-sdk-for-rust/pull/4855))
Expand Down
2 changes: 2 additions & 0 deletions sdk/cosmos/azure_data_cosmos_driver/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ rustls = ["reqwest", "reqwest/rustls", "__tls"]
native_tls = ["reqwest", "reqwest/native-tls", "__tls"]
fault_injection = []
preview_dtx = [] # Enables preview Distributed Transaction APIs. Disabled by default and not production-ready.
preview_patch = [] # Enables the preview PATCH API. Disabled by default and not production-ready: an interrupted patch may re-apply non-idempotent operations. See docs/PATCH_HANDLER_SPEC.md.
# `__internal_in_memory_emulator` exposes the in-memory query evaluator
# (`crate::query::eval`, `crate::query::value`) used by the in-memory Cosmos DB
# emulator. The evaluator intentionally trades full Cosmos parity for emulator
Expand Down Expand Up @@ -114,6 +115,7 @@ __tls = []
features = [
"fault_injection",
"native_tls",
"preview_patch",
"rustls",
"tokio",
]
Expand Down
46 changes: 31 additions & 15 deletions sdk/cosmos/azure_data_cosmos_driver/docs/PATCH_HANDLER_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
This document describes the contract for `OperationType::Patch` in
`azure_data_cosmos_driver`.

## Status: preview, not production-ready

PATCH is gated behind the **`preview_patch`** Cargo feature in both
`azure_data_cosmos_driver` and `azure_data_cosmos`. It is off by default and
is not part of either crate's supported surface.

The gate exists because the handler does not deliver exactly-once semantics
under transport failures: an interrupted patch may re-apply non-idempotent
operations (`Increment`, `Add` on an array, `Move`). See
[Invariants](#invariants) for the exact interleaving. The feature will stay
gated until that hole is closed.

## Overview

`Patch` is a *virtual* operation type: the Cosmos DB REST endpoint does not
Expand Down Expand Up @@ -199,18 +211,21 @@ as a JSON number without precision loss.

## Errors

- All sub-operation errors are surfaced verbatim — including the
`DiagnosticsContext` and request-tracking info from the internal Read or
Replace.
- Sub-operation errors preserve the failing error's status, sub-status, raw
response, and source. The attached diagnostics are re-stamped with the
virtual PATCH operation's `patch_item` name before the error is surfaced.
- A non-412 failure after earlier sub-operations carries an aggregated
`DiagnosticsContext` containing those prior contexts plus the failing
sub-operation's context, in dispatch order. When the first sub-operation
fails, its context is retained with only the operation name rewritten.
- The handler never retries beyond `max_attempts` and never converts a 412
into success; the final outcome is whichever of "internal sub-op error",
"successful PATCH", or "exhausted RMW attempts (412)" terminated the
loop.
- The aggregated `DiagnosticsContext` described in "Response Synthesis"
applies to the *successful* path. On error paths the surfaced
`DiagnosticsContext` is whatever the failing sub-op already carried —
the handler does not synthesize an aggregated context for partial
failures.
- When 412 retries exhaust `max_attempts`, the final error carries an
aggregated `DiagnosticsContext` containing every accumulated Read and
failed Replace context. Thus both successful and failed PATCH operations
follow the "one PATCH operation = one `DiagnosticsContext`" contract.

## Why Driver-Side?

Expand Down Expand Up @@ -250,10 +265,11 @@ as a JSON number without precision loss.
already replicated the original commit returns 412, which the RMW loop
treats as a normal race-lost and recovers by re-Reading and re-applying.
Non-idempotent ops (`Increment`, `Add` on an array, `Move`) may therefore
be applied **more than once** under this scenario. Lifting this caveat
requires marking the internal Replace as non-idempotent for retry
purposes (e.g. a per-op idempotency override on `CosmosOperation`); that
is tracked as a follow-up because it interacts with PPAF write-retry
semantics. Callers needing exactly-once should either use idempotent ops
(`Set` on a caller-computed value) or detect duplicate-application via a
monotonic application-level sequence number.
be applied **more than once** under this scenario. This is the reason the
whole feature is gated behind `preview_patch`. Closing the hole requires
the RMW loop to be able to *recognize its own committed write* rather than
mistaking it for a concurrent writer — i.e. stamping each attempt with a
marker the loop can look for on the verification read. Until then, callers
needing exactly-once should either use idempotent ops (`Set` on a
caller-computed value) or detect duplicate-application via a monotonic
application-level sequence number.
Loading
Loading