Skip to content

Docs: Add Distributed Transactions REST wire contract - #6002

Open
Abhijeet Mohanty (jeet1995) wants to merge 10 commits into
mainfrom
jeet1995/dtx-wire-contract
Open

Docs: Add Distributed Transactions REST wire contract#6002
Abhijeet Mohanty (jeet1995) wants to merge 10 commits into
mainfrom
jeet1995/dtx-wire-contract

Conversation

@jeet1995

@jeet1995 Abhijeet Mohanty (jeet1995) commented Jul 6, 2026

Copy link
Copy Markdown
Member

Adds the authoritative wire-contract document describing the I/O interactions between CosmosClient and the Distributed Transactions Coordinator (DTC).

Covers request/response headers, request payloads, the read-transaction aggregate status model, and the 408 / 429 / 449 retry policies as implemented by CosmosClient (verified against SDK source and the backend DTC coordinator).

Add the authoritative wire-contract document describing the I/O
interactions between CosmosClient and the Distributed Transactions
Coordinator (DTC). Documents request/response headers, payloads,
the read-transaction aggregate status model, and the 408/429/449
retry policies as implemented by CosmosClient.

Intended as the source of truth for porting the DTX wire contract to
azure-sdk-for-rust/sdk/cosmos.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please follow the required format: "[Internal] Category: (Adds|Fixes|Refactors|Removes) Description"
Regex: (\[Internal\]|\[v4\] )?.{3}.+: (Adds|Fixes|Refactors|Removes) .{3}.+

The [Internal] prefix is a review hint indicating no customer-observable impact (test pipelines, CI, doc-only changes, pure internal refactors). It does not affect changelog generation — the changelog is maintained per-PR in ### Unreleased of changelog.md. See CONTRIBUTING.md for the full [Internal] definition and the preview-feature carve-out.
Examples:

  • Diagnostics: Adds GetElapsedClientLatency to CosmosDiagnostics
  • PartitionKey: Fixes null reference when using default(PartitionKey)
  • [v4] Client Encryption: Refactors code to external project
  • [Internal] Query: Adds code generator for CosmosNumbers for easy additions in the future

…regate section

CosmosClient unwraps a 207 Multi-Status envelope into the first per-op
result that is not 424 and is >= 400, inheriting its statusCode and
subStatusCode. Add a §6 subsection with pseudocode, a worked example,
a retry-classification note, and a Rust port note so the wire-contract
doc fully captures the client-facing promotion behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Document that the outer committer loop retries any 449 whose body sets
isRetriable:true (sub-status-agnostic), while the inner ClientRetryPolicy
classifier only treats a 449 as coordinator-retriable when sub-status is
5352. Explain why this split is safe given the coordinator's 449
vocabulary, clarify that a read 449/Unknown is retried by the outer loop
(not dropped), and add a Rust-port note against collapsing 449 handling
into a single rule.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…veat

Rewrite the intro paragraph so it presents both the write-transaction
(200/452/449/408/429/400/403/500) and read-transaction (200/304/207/404/449/408)
response-code contracts symmetrically, calling out 452 as write-only and 207 as
read-only. Remove the activity-id caveat, which is not accurate.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…tion

The wire-contract doc is meant to be an external, self-contained source
of truth (e.g. for the Rust SDK port). The internal ADO PR 2154806 link
is not resolvable outside the org and adds no technical value, so drop
both references while preserving the surrounding technical content.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…edup

Name a bodyless transport-level timeout as the sharpest case of the
ordinary-write double-apply danger, and replace the vague 'coordinator
deduplicates by token' with the concrete contract mechanism: the
coordinator keys the transaction record on the idempotency token
(create-first, read-on-conflict; empty token rejected), so a replay
converges on the one transaction and returns its authoritative state.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…reference

DTX operations run in gateway mode through the Compute Gateway, which
handles address requests internally, so client-side address resolution
does not apply. Also removes the Rust-port porting notes, keeping the
document focused on documenting CosmosClient behavior.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - Thanks!

Fabian Meiswinkel (FabianMeiswinkel) added a commit to Azure/azure-sdk-for-rust that referenced this pull request Jul 7, 2026
## Summary

This PR adds **preview Distributed Transactions (DTX)** support to the
Rust Cosmos driver and SDK.

Distributed Transactions extend Cosmos DB atomicity beyond
`TransactionalBatch`'s single-container / single-partition-key boundary.
With this change, Rust clients can prepare and execute multi-partition,
multi-container transactions within a single Cosmos DB account through
the service-side Distributed Transactions Coordinator (DTC).

The implementation is intentionally gated behind a disabled-by-default
`preview_dtx` feature because the DTX service feature is still in
preview/test-account rollout and is **not production-ready**.

## Important References

- .NET SDK source of truth:
[Azure/azure-cosmos-dotnet-v3](https://github.com/Azure/azure-cosmos-dotnet-v3)
- .NET DTX OpenSpec / proposal:
[Azure/azure-cosmos-dotnet-v3#5781](Azure/azure-cosmos-dotnet-v3#5781)
- DTX REST wire contract between SDK and service:
[Azure/azure-cosmos-dotnet-v3#6002](Azure/azure-cosmos-dotnet-v3#6002)
- .NET DTX API review:
[Azure/azure-cosmos-dotnet-v3#5877](Azure/azure-cosmos-dotnet-v3#5877)
- Response reordering / fail-closed handling:
[Azure/azure-cosmos-dotnet-v3#5974](Azure/azure-cosmos-dotnet-v3#5974)
- DTX 429 / RUBudgetExceeded retry handling:
[Azure/azure-cosmos-dotnet-v3#5989](Azure/azure-cosmos-dotnet-v3#5989)
- Patch filter predicate support:
[Azure/azure-cosmos-dotnet-v3#5995](Azure/azure-cosmos-dotnet-v3#5995)
- Malformed DTX session-token handling:
[Azure/azure-cosmos-dotnet-v3#5958](Azure/azure-cosmos-dotnet-v3#5958)
- Rust design spec with ADRs:
`sdk/cosmos/azure_data_cosmos_driver/docs/DISTRIBUTED_TRANSACTIONS_SPEC.md`

## What Changed

### Driver (`azure_data_cosmos_driver`)

Adds preview DTX driver support behind `preview_dtx`:

- DTX wire models:
  - `DistributedTransactionType`
  - `DistributedTransactionOperationKind`
  - `DistributedTransactionTarget`
  - `DistributedTransactionOperation`
  - `DistributedTransactionRequest`
  - `DistributedTransactionResponse`
  - `DistributedTransactionOperationResult`
  - `DistributedTransactionResultBody`
- Request serialization for the DTC wire contract:
  - `POST /operations/dtc`
  - `x-ms-cosmos-idempotency-token`
  - `x-ms-cosmos-operation-type`
  - `x-ms-cosmos-resource-type: DistributedTransactionBatch`
  - JSON `operations[]` payload
- Response parsing:
  - reorder by operation `index`
  - fail closed on malformed successful coordinator responses
  - preserve coordinator `isRetriable` and `diagnosticString`
- preserve raw coordinator headers and raw per-operation response
payloads for diagnostics and parity testing
  - promote `207 MultiStatus` according to the DTX contract
- Two-tier retry behavior:
- outer loop for body-bearing coordinator responses using `isRetriable`
- inner retry classification for bodyless coordinator/infrastructure
failures
  - DTX-specific retry budgets matching the .NET implementation
- bodyless `429` uses the shared throttle retry path; body-bearing DTX
`429` is handled by the outer coordinator retry path
- Session handling:
  - pre-send session-token resolution for DTX operations
- per-partition-key-range token resolution through the driver PKRange
cache
  - parent-range fallback for freshly split children
  - response-side per-operation session-token merge
  - strict malformed-token rejection under Session consistency
- DTC sub-status constants:
  - `DTC_COORDINATOR_RACE_CONFLICT`
  - `DTC_LEDGER_FAILURE`
  - `DTC_ACCOUNT_CONFIG_FAILURE`
  - `DTC_DISPATCH_FAILURE`
  - `DTC_OPERATION_ROLLED_BACK`

### SDK (`azure_data_cosmos`)

Adds preview public SDK transaction builders behind `preview_dtx`.

The SDK API now follows the same broad shape as `TransactionalBatch`:
callers prepare a data-only transaction document and pass it to the
account client for execution.

- `DistributedWriteTransaction::new()`
- `DistributedReadTransaction::new()`
- `CosmosClient::commit_distributed_write(...)`
- `CosmosClient::execute_distributed_read(...)`
- Builder operation methods target `&ContainerClient`; the SDK resolves
the underlying driver `ContainerReference` internally.
- Same-account enforcement runs when the transaction is executed by
`CosmosClient`.
- `DistributedWriteTransaction`
  - `create_item`
  - `replace_item`
  - `upsert_item`
  - `delete_item`
  - `patch_item`
- `DistributedReadTransaction`
  - `read_item`
- `DistributedTransactionOperationOptions`
  - per-operation `session_token`
  - per-operation ETag `precondition`
- `DistributedTransactionPatchOperationOptions`
  - per-operation `session_token`
  - per-operation ETag `precondition`
  - patch `filter_predicate`
- `DistributedTransactionResponse`
  - `status`
  - `is_success_status_code`
  - `is_completed_status_code`
  - `len`
  - `is_empty`
  - `operation_result`
  - `headers`
  - `diagnostic_string`
  - `idempotency_token`
  - `is_retriable`
  - `error_message`
  - `diagnostics`
  - `activity_id`
  - `request_charge`
  - `retry_after_ms`
- `DistributedTransactionOperationResult`
  - `index`
  - `status_code`
  - `sub_status_code`
  - `is_success_status_code`
  - `is_completed_status_code`
  - `etag`
  - `session_token`
  - `partition_key_range_id`
  - `request_charge`
  - `resource<T>()`

`DistributedWriteTransaction` and `DistributedReadTransaction` are
intentionally data-only builders. They do not carry a client and do not
execute themselves. Execution happens through
`CosmosClient::commit_distributed_write(...)` or
`CosmosClient::execute_distributed_read(...)`, which consumes the
prepared transaction and validates that every operation targets the same
Cosmos DB account as the executing client.

### In-Memory Emulator Support

DTX is not available in the normal emulator or emulator vnext today, and
live DTX testing requires special test accounts. To enable local
iteration, this PR adds DTX support to the in-memory emulator:

- `POST /operations/dtc`
- write transaction prepare / commit / rollback behavior
- read transaction snapshot result rewriting
- multi-container DTX sanity coverage
- DTX Patch support, including filter predicate evaluation
- prepared-operation rollback surfaced as `453 / 5415`
- read snapshot failure rewrite to `424 FailedDependency`
- no-partial-commit checks
- `304 NotModified` read transaction handling

## Public API Example

```rust
use azure_data_cosmos::{
    CosmosClient,
    DistributedReadTransaction,
    DistributedWriteTransaction,
    PartitionKey,
};
use azure_data_cosmos::clients::{
    ContainerClient,
    DistributedTransactionOperationOptions,
    DistributedTransactionPatchOperationOptions,
};
use azure_data_cosmos::models::{PatchInstructions, PatchOperation};
use azure_data_cosmos::options::Precondition;

#[derive(serde::Serialize, serde::Deserialize)]
struct Account {
    id: String,
    pk: String,
    balance: i64,
}

#[derive(serde::Serialize)]
struct AuditEvent {
    id: String,
    pk: String,
    from: String,
    to: String,
    amount: i64,
}

async fn transfer_between_accounts(
    client: &CosmosClient,
    accounts: &ContainerClient,
    audit: &ContainerClient,
    from: Account,
    to: Account,
    from_etag: azure_core::http::Etag,
    to_etag: azure_core::http::Etag,
) -> azure_data_cosmos::Result<()> {
    let amount = 100;

    let updated_from = Account {
        balance: from.balance - amount,
        ..from
    };

    let updated_to = Account {
        balance: to.balance + amount,
        ..to
    };

    let write_transaction = DistributedWriteTransaction::new()
        .replace_item(
            accounts,
            PartitionKey::from(updated_from.pk.clone()),
            updated_from.id.clone(),
            &updated_from,
            Some(
                DistributedTransactionOperationOptions::default()
                    .with_precondition(Precondition::if_match(from_etag)),
            ),
        )?
        .replace_item(
            accounts,
            PartitionKey::from(updated_to.pk.clone()),
            updated_to.id.clone(),
            &updated_to,
            Some(
                DistributedTransactionOperationOptions::default()
                    .with_precondition(Precondition::if_match(to_etag)),
            ),
        )?
        .create_item(
            audit,
            PartitionKey::from("transfers"),
            "transfer-0001",
            AuditEvent {
                id: "transfer-0001".to_owned(),
                pk: "transfers".to_owned(),
                from: updated_from.id.clone(),
                to: updated_to.id.clone(),
                amount,
            },
            None,
        )?;

    let write_response = client
        .commit_distributed_write(write_transaction)
        .await?;

    if write_response.is_completed_status_code() {
        tracing::info!(
            idempotency_token = %write_response.idempotency_token(),
            activity_id = ?write_response.activity_id(),
            request_charge = ?write_response.request_charge(),
            "distributed transaction committed"
        );

        return Ok(());
    }

    for index in 0..write_response.len() {
        if let Some(result) = write_response.operation_result(index) {
            if result.status_code().as_u16() == 424 {
                continue;
            }

            tracing::warn!(
                operation_index = result.index(),
                status = ?result.status_code(),
                sub_status = ?result.sub_status_code(),
                pk_range = ?result.partition_key_range_id(),
                "distributed transaction operation failed"
            );
        }
    }

    if write_response.is_retriable() {
        tracing::warn!(
            idempotency_token = %write_response.idempotency_token(),
            diagnostic = ?write_response.diagnostic_string(),
            "distributed transaction ended retriable after SDK retry budget; reconcile before retrying"
        );
    }

    Err(azure_data_cosmos::Error::message(format!(
        "distributed transaction failed: status={:?}, diagnostic={:?}, error={:?}",
        write_response.status(),
        write_response.diagnostic_string(),
        write_response.error_message(),
    )))
}

async fn conditional_patch_example(
    client: &CosmosClient,
    container: &ContainerClient,
) -> azure_data_cosmos::Result<()> {
    let patch = PatchInstructions::from(vec![
        PatchOperation::set("/status", serde_json::json!("completed")),
    ]);

    let write_transaction = DistributedWriteTransaction::new()
        .patch_item(
            container,
            PartitionKey::from("order-123"),
            "order-123",
            patch,
            Some(
                DistributedTransactionPatchOperationOptions::default()
                    .with_filter_predicate("from c where c.status = 'pending'"),
            ),
        )?;

    let response = client
        .commit_distributed_write(write_transaction)
        .await?;

    if !response.is_completed_status_code() {
        for index in 0..response.len() {
            if let Some(result) = response.operation_result(index) {
                if result.status_code().as_u16() != 424 {
                    tracing::warn!(
                        index = result.index(),
                        status = ?result.status_code(),
                        sub_status = ?result.sub_status_code(),
                        "patch DTX operation failed"
                    );
                }
            }
        }
    }

    Ok(())
}

async fn distributed_read_example<T>(
    client: &CosmosClient,
    container_a: &ContainerClient,
    container_b: &ContainerClient,
) -> azure_data_cosmos::Result<(Option<T>, Option<T>)>
where
    T: serde::de::DeserializeOwned,
{
    let read_transaction = DistributedReadTransaction::new()
        .read_item(
            container_a,
            PartitionKey::from("tenant-1"),
            "item-1",
            None,
        )
        .read_item(
            container_b,
            PartitionKey::from("tenant-2"),
            "item-2",
            None,
        );

    let response = client
        .execute_distributed_read(read_transaction)
        .await?;

    let first = response
        .operation_result(0)
        .map(|result| result.resource::<T>())
        .transpose()?
        .flatten();

    let second = response
        .operation_result(1)
        .map(|result| result.resource::<T>())
        .transpose()?
        .flatten();

    Ok((first, second))
}

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: FabianMeiswinkel <19165014+FabianMeiswinkel@users.noreply.github.com>
Comment thread docs/DistributedTransactionsRestContract.md Outdated
Comment thread docs/DistributedTransactionsRestContract.md Outdated
Comment thread docs/DistributedTransactionsRestContract.md Outdated
Comment thread docs/DistributedTransactionsRestContract.md Outdated
Comment thread docs/DistributedTransactionsRestContract.md Outdated
Comment thread docs/DistributedTransactionsRestContract.md Outdated
Addresses review comments on the Distributed Transactions REST wire
contract doc:
- Corrects MaxCumulativeRetryDelay from 120 s to 30 s (table + prose) and
  the resulting retry-count estimate (~4-5 retries).
- Corrects the response ETag field name to Etag (matches
  DistributedTransactionSerializer.ResponseETag).
- Notes id is required/always emitted in the request body.
- Notes localLsn is emitted by the coordinator but not consumed by the SDK.
- Adds .ToResourceTypeString() note to the resource-type header row.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f20581f3-35a0-41c0-a3f2-e3b91dfd3fba
Removes all PR-number callouts and folds merged behavior (conditional patch, response reordering + fail-closed, bodyless throttle fall-through) into the core contract prose. Drops the still-open stricter session-token note since it is not yet shipped.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f20581f3-35a0-41c0-a3f2-e3b91dfd3fba
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: f20581f3-35a0-41c0-a3f2-e3b91dfd3fba
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants