Skip to content
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4d58b00
Return ChangeFeedItem envelope from change feed
yumnahussain Jul 8, 2026
1111a21
Make ChangeFeedMetadata.operation_type optional
yumnahussain Jul 8, 2026
4fac43f
Merge branch 'main' into yumnahussain/cosmos-changefeed-envelope
yumnahussain Jul 9, 2026
9e3a20c
Test that the iterator preserves envelope metadata
yumnahussain Jul 9, 2026
86abbd8
Potential fix for pull request finding
yumnahussain Jul 9, 2026
2e3e090
Potential fix for pull request finding
yumnahussain Jul 9, 2026
2032bc1
Add AllVersionsAndDeletes change feed mode
yumnahussain Jul 9, 2026
3b34b1b
Tolerate non-enveloped change feed items
yumnahussain Jul 9, 2026
8a6e293
Add ChangeFeedPolicy and AVAD integration tests
yumnahussain Jul 9, 2026
5e3c326
Resolve stacked PR merge conflict
Copilot Jul 9, 2026
c69739e
Split long changelog entry
Copilot Jul 9, 2026
e8cf6c0
Merge branch 'main' into yumnahussain/cosmos-changefeed-envelope
yumnahussain Jul 13, 2026
4e30d25
Merge remote-tracking branch 'origin/yumnahussain/cosmos-changefeed-e…
Copilot Jul 13, 2026
1b7152f
Resolve merge conflicts with main
Copilot Jul 16, 2026
fc683d6
Trigger CI
yumnahussain Jul 16, 2026
2559c10
Fix AVAD emulator test timeout
yumnahussain Jul 17, 2026
4f5ea8f
Potential fix for pull request finding
yumnahussain Jul 17, 2026
b2f1be7
Potential fix for pull request finding
yumnahussain Jul 17, 2026
3736c80
Potential fix for pull request finding
yumnahussain Jul 17, 2026
7832b84
Potential fix for pull request finding
yumnahussain Jul 17, 2026
53b1aaa
Potential fix for pull request finding
yumnahussain Jul 17, 2026
b95da6d
Address AVAD review feedback + Gateway 2.0 routing fix
yumnahussain Jul 17, 2026
fee105a
Reject PointInTime start for AVAD change feed
yumnahussain Jul 17, 2026
3dd6d68
Address AVAD review feedback: service-gate starts, encode mode
yumnahussain Jul 20, 2026
95a4dd0
Clarify LatestVersion metadata in query_change_feed docs
yumnahussain Jul 20, 2026
bbfe0fc
Address AVAD change-feed review comments
yumnahussain Jul 20, 2026
59ea62d
Address nalu approve-with-nits change-feed comments
yumnahussain Jul 23, 2026
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 @@ -12,6 +12,7 @@
- Added preview distributed transaction SDK builders, patch operations, diagnostics, and response accessors behind the disabled-by-default `preview_dtx` feature. ([#4702](https://github.com/Azure/azure-sdk-for-rust/pull/4702))
- Added change feed pull support via `ContainerClient::query_change_feed()`, which takes a required `ChangeFeedStartFrom` start position (`Beginning`, `Now`, `PointInTime`) and returns a `ChangeFeedPageIterator<T>` that streams `FeedPage<T>` results. New `feed` types `ChangeFeedPageIterator`, `FeedScope`, and `ContinuationToken`, plus `options` types `ChangeFeedOptions` and `ChangeFeedMode` (currently `LatestVersion`); supports single-partition, per-partition-key, and full-container (cross-partition fan-out) reads with continuation-token resumption that persists the original start position so never-polled partitions don't replay history on resume. ([#4621](https://github.com/Azure/azure-sdk-for-rust/pull/4621))
- Change feed items are now surfaced as an envelope. `ContainerClient::query_change_feed::<YourDoc>()` yields `ChangeFeedItem<YourDoc>`, binding the envelope into the return type so the post-change document is read via `ChangeFeedItem::current()` and cannot be silently deserialized away. The envelope also exposes the pre-change document (`previous()`) and per-change `metadata()` (populated by full-fidelity reads; absent for `LatestVersion`). A full-fidelity delete returns an empty `current` object, which maps to `None` so callers with strict document types still deserialize the delete; the deleted item's identity is available via `ChangeFeedMetadata::id()` and `ChangeFeedMetadata::partition_key()`. `ChangeFeedOperationType` includes an `Unknown` catch-all so a future operation type cannot fail a page and stall the feed. A backend that does not envelope change feed items (such as the Cosmos emulator) returns the bare document, which is mapped onto `current()` so no data is lost. Added the `models` types `ChangeFeedItem<T>`, `ChangeFeedMetadata`, `ChangeFeedOperationType`, and `LogicalSequenceNumber`. ([#4723](https://github.com/Azure/azure-sdk-for-rust/pull/4723))
- Added `ChangeFeedMode::AllVersionsAndDeletes` ("full fidelity") to `ContainerClient::query_change_feed()`. In this mode the service returns every intermediate version and delete, and each `ChangeFeedItem<T>` envelope carries per-change `metadata()` (operation type, LSN, conflict resolution timestamp, previous-image LSN, and a time-to-live-expiry flag); the pre-change document (`previous()`) is populated only when the container also retains pre-images. `ChangeFeedStartFrom::Beginning` and `ChangeFeedStartFrom::PointInTime` are rejected by the service for this mode (with `400 Bad Request`) because intermediate versions and deletes are only retained within the container's retention window; use `Now` or resume from a continuation token. The continuation token records the feed mode, so a resume that switches modes is rejected. Added the `models` type `ChangeFeedPolicy` and `ContainerProperties::with_change_feed_policy()` to configure a container's full-fidelity retention window (required to read in this mode). ([#4706](https://github.com/Azure/azure-sdk-for-rust/pull/4706))
- Added `TlsBackend` (re-exported) and a `tls_backend` option on `ConnectionPoolOptions` (`ConnectionPoolOptionsBuilder::with_tls_backend`), defaulting to `TlsBackend::Rustls`, available under the `rustls` feature, to pin the TLS backend used by the transport. This is additive and changes no behavior for the default (rustls) build; it only has an effect in builds that compile in multiple reqwest TLS backends, where reqwest would otherwise default to native-tls and the driver now pins rustls instead. ([#4649](https://github.com/Azure/azure-sdk-for-rust/pull/4649))

### Breaking Changes
Expand Down
122 changes: 107 additions & 15 deletions sdk/cosmos/azure_data_cosmos/src/clients/container_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ use crate::{
models::{BatchResponse, ChangeFeedItem, ItemResponse, ResourceResponse},
models::{ContainerProperties, PatchInstructions, ThroughputProperties},
options::{
BatchOptions, ChangeFeedOptions, ChangeFeedStartFrom, DeleteContainerOptions,
ItemReadOptions, ItemWriteOptions, PatchItemOptions, Precondition, QueryOptions,
ReadContainerOptions, ReadFeedRangesOptions, ReplaceContainerOptions, SessionToken,
ThroughputOptions,
BatchOptions, ChangeFeedMode, ChangeFeedOptions, ChangeFeedStartFrom,
DeleteContainerOptions, ItemReadOptions, ItemWriteOptions, PatchItemOptions, Precondition,
QueryOptions, ReadContainerOptions, ReadFeedRangesOptions, ReplaceContainerOptions,
SessionToken, ThroughputOptions,
},
PartitionKey, Query,
};
Expand Down Expand Up @@ -863,10 +863,24 @@ impl ContainerClient {

/// Queries the change feed for a container, returning a stream of pages.
///
/// The change feed provides an ordered list of changes (creates and
/// replaces) made to items in the container. Each change is yielded as a
/// [`ChangeFeedItem<T>`](crate::models::ChangeFeedItem); see that type for
/// how to read the changed document.
/// The change feed provides an ordered list of changes made to items in the
/// container. Every change is returned as a
/// [`ChangeFeedItem<T>`](crate::models::ChangeFeedItem) wire-format
/// envelope, so bind `T = YourDoc` and read the post-change
/// document via [`current()`](crate::models::ChangeFeedItem::current).
///
/// The [`mode`](crate::options::ChangeFeedOptions::mode) selects what each
/// change carries:
///
/// * [`ChangeFeedMode::LatestVersion`] (default) — the latest version of
/// each created or replaced item. `current()` holds the item and
/// `metadata()` may also be present (for example `lsn` and the commit
/// timestamp), while `previous()` is not populated.
Comment thread
yumnahussain marked this conversation as resolved.
/// * [`ChangeFeedMode::AllVersionsAndDeletes`] — every intermediate version
/// plus deletes. The envelope additionally exposes the
/// pre-change document
/// ([`previous()`](crate::models::ChangeFeedItem::previous)) and change
/// [`metadata()`](crate::models::ChangeFeedItem::metadata).
///
/// # Arguments
/// * `scope` - Determines which partitions to read changes from.
Expand All @@ -875,12 +889,32 @@ impl ContainerClient {
/// the token holds its own position.
/// * `options` - Optional parameters controlling mode, session token, and paging.
///
/// # AllVersionsAndDeletes limitations
///
/// * Only [`ChangeFeedStartFrom::Now`] or resuming from a continuation token
/// is supported. [`ChangeFeedStartFrom::Beginning`] and
/// [`ChangeFeedStartFrom::PointInTime`] are **not** supported, because
/// intermediate versions and deletes are only retained within the
/// container's retention / continuous-backup window. The service gates
/// this and rejects such a request with `400 Bad Request`.
/// * [`ChangeFeedStartFrom::Now`] is re-evaluated per range the first time a
/// range is polled, so a range that is never polled before a checkpoint
/// resumes from resume-time and can drop the intermediate versions and
Comment thread
yumnahussain marked this conversation as resolved.
Outdated
/// deletes that occurred between the original start and the resume. `Now`
/// is deliberately **not** converted to a concrete `PointInTime`, because
/// that would change its semantics; lossless per-range `Now` resolution is
/// a future improvement.
/// * The feed mode is encoded in the continuation token, so a token issued in
/// one mode cannot be used to resume in another; attempting to do so is
/// rejected. Re-pass [`ChangeFeedMode::AllVersionsAndDeletes`] on resume to
/// match the original mode.
Comment thread
yumnahussain marked this conversation as resolved.
///
/// # Examples
///
/// Read the latest version of each change from the beginning:
///
/// ```rust,no_run
/// use azure_data_cosmos::{
/// clients::ContainerClient, feed::FeedScope, options::ChangeFeedStartFrom,
/// };
/// use azure_data_cosmos::{clients::ContainerClient, feed::FeedScope, options::ChangeFeedStartFrom};
/// use futures::StreamExt;
/// use serde::Deserialize;
///
Expand Down Expand Up @@ -908,6 +942,45 @@ impl ContainerClient {
/// # Ok(())
/// # }
/// ```
///
/// Read every version and delete:
///
/// ```rust,no_run
/// use azure_data_cosmos::{
/// clients::ContainerClient,
/// feed::FeedScope,
/// options::{ChangeFeedMode, ChangeFeedOptions, ChangeFeedStartFrom},
/// };
/// use futures::StreamExt;
/// use serde::Deserialize;
///
/// // A delete envelope may omit non-key fields, so keep them optional.
/// #[derive(Debug, Deserialize)]
/// struct MyItem {
/// id: String,
/// #[serde(default)]
/// value: Option<i64>,
/// }
///
/// # async fn example(container: ContainerClient) -> Result<(), Box<dyn std::error::Error>> {
/// let options = ChangeFeedOptions::default().with_mode(ChangeFeedMode::AllVersionsAndDeletes);
/// let mut pages = container
/// .query_change_feed::<MyItem>(
/// FeedScope::full_container(),
/// ChangeFeedStartFrom::Now,
/// Some(options),
/// )
/// .await?;
///
/// while let Some(page) = pages.next().await {
/// for item in page?.items() {
/// println!("{:?}: current={:?} previous={:?}",
/// item.operation_type(), item.current(), item.previous());
/// }
/// }
/// # Ok(())
/// # }
/// ```
pub async fn query_change_feed<T: DeserializeOwned + Send + 'static>(
&self,
scope: FeedScope,
Expand All @@ -916,10 +989,29 @@ impl ContainerClient {
) -> crate::Result<ChangeFeedPageIterator<ChangeFeedItem<T>>> {
let options = options.unwrap_or_default();

let mut initial_operation = CosmosOperation::change_feed(
self.container_ref.clone(),
Some(scope.into_feed_range(self.container_ref.partition_key_definition())),
);
let feed_range = scope.into_feed_range(self.container_ref.partition_key_definition());

// The mode selects the base operation, i.e. which `A-IM` header is sent.
// Both modes return `ChangeFeedItem<T>` envelopes; AllVersionsAndDeletes
// additionally populates `previous` and `metadata`.
let mut initial_operation = match options.mode {
ChangeFeedMode::AllVersionsAndDeletes => {
// AllVersionsAndDeletes can only read within the container's
// retention / continuous-backup window, so it supports starting
// only from "now" or by resuming a continuation token. Reading
// from the beginning of the container or from an arbitrary point
// in time is not supported in this mode; the service gates this
// and returns a `400 Bad Request`, so it is not re-validated
// client-side here.
Comment thread
yumnahussain marked this conversation as resolved.
CosmosOperation::change_feed_all_versions_and_deletes(
self.container_ref.clone(),
Some(feed_range),
)
}
ChangeFeedMode::LatestVersion => {
CosmosOperation::change_feed(self.container_ref.clone(), Some(feed_range))
}
};

if let Some(token) = options.session_token {
initial_operation = initial_operation.with_session_token(token);
Expand Down
90 changes: 82 additions & 8 deletions sdk/cosmos/azure_data_cosmos/src/feed/change_feed_iterator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,18 @@ impl LiveState {
}
}

/// Deserializes a change feed response body into the caller's item type `T`
/// (bound to [`ChangeFeedItem<Doc>`](crate::models::ChangeFeedItem)).
/// Deserializes a change feed response body into the caller's item type.
///
/// Every change feed item is returned as a wire-format envelope
/// (`{ current, ... }`) because the SDK always sends the
/// `x-ms-cosmos-changefeed-wire-format-version` header (see
/// [`CosmosOperation::change_feed`]). Each entry in the feed body is
/// deserialized directly into `T` — which
/// [`ContainerClient::query_change_feed`](crate::clients::ContainerClient::query_change_feed)
/// binds to [`ChangeFeedItem<Doc>`](crate::models::ChangeFeedItem) — so the whole
/// envelope is preserved rather than stripped.
///
/// [`CosmosOperation::change_feed`]: azure_data_cosmos_driver::models::CosmosOperation
fn deserialize_change_feed_items<T: DeserializeOwned>(
response: CosmosResponse,
) -> crate::Result<Vec<T>> {
Expand All @@ -163,8 +173,9 @@ fn deserialize_change_feed_items<T: DeserializeOwned>(
/// A stream of pages from a Cosmos DB change feed operation.
///
/// Yields [`FeedPage<T>`] instances where `T` is
/// [`ChangeFeedItem<YourDoc>`](crate::models::ChangeFeedItem); read the
/// changed document via [`current()`](crate::models::ChangeFeedItem::current).
/// [`ChangeFeedItem<YourDoc>`](crate::models::ChangeFeedItem): every item is
/// returned as the wire-format envelope, so the caller reads the post-change
/// document via [`current()`](crate::models::ChangeFeedItem::current).
///
/// The stream is conceptually infinite: when a partition has no new changes
/// (304 Not Modified), an empty page is returned instead of terminating the
Expand All @@ -176,9 +187,7 @@ fn deserialize_change_feed_items<T: DeserializeOwned>(
/// # Examples
///
/// ```rust,no_run
/// use azure_data_cosmos::{
/// clients::ContainerClient, feed::FeedScope, options::ChangeFeedStartFrom,
/// };
/// use azure_data_cosmos::{clients::ContainerClient, feed::FeedScope, options::ChangeFeedStartFrom};
/// use futures::StreamExt;
/// use serde::Deserialize;
///
Expand Down Expand Up @@ -267,7 +276,7 @@ impl<T: Send + DeserializeOwned + 'static> Stream for ChangeFeedPageIterator<T>
#[cfg(test)]
mod tests {
use super::deserialize_change_feed_items;
use crate::models::{ChangeFeedItem, CosmosResponse};
use crate::models::{ChangeFeedItem, ChangeFeedOperationType, CosmosResponse};
use azure_core::http::StatusCode;
use azure_data_cosmos_driver::diagnostics::DiagnosticsContext;
use azure_data_cosmos_driver::models::{
Expand Down Expand Up @@ -325,6 +334,71 @@ mod tests {
);
}

#[test]
Comment thread
yumnahussain marked this conversation as resolved.
fn deserializes_all_versions_and_deletes_page() {
// AllVersionsAndDeletes binds `T = ChangeFeedItem<Doc>` and keeps the
// whole envelope: create + replace + delete, with metadata and a
// pre-image preserved rather than stripped.
let body = json!({
"Documents": [
{
"current": { "id": "1" },
"metadata": { "operationType": "create", "lsn": 10 }
},
{
"current": { "id": "2" },
"previous": { "id": "2" },
"metadata": { "operationType": "replace", "lsn": 11, "previousImageLSN": 10 }
},
{
"previous": { "id": "3" },
"metadata": { "operationType": "delete", "lsn": 12, "timeToLiveExpired": true }
}
],
"_count": 3
});

let items: Vec<ChangeFeedItem<Doc>> =
deserialize_change_feed_items(make_response(body)).unwrap();
assert_eq!(items.len(), 3);

// Create: current present, no previous.
assert_eq!(
items[0].operation_type(),
Some(ChangeFeedOperationType::Create)
);
assert_eq!(items[0].current(), Some(&Doc { id: "1".into() }));
assert!(items[0].previous().is_none());
assert_eq!(
items[0].metadata().and_then(|m| m.lsn()),
Some(crate::models::LogicalSequenceNumber::from(10))
);

// Replace: both current and previous present.
assert_eq!(
items[1].operation_type(),
Some(ChangeFeedOperationType::Replace)
);
assert_eq!(items[1].current(), Some(&Doc { id: "2".into() }));
assert_eq!(items[1].previous(), Some(&Doc { id: "2".into() }));
assert_eq!(
items[1].metadata().and_then(|m| m.previous_image_lsn()),
Some(crate::models::LogicalSequenceNumber::from(10))
);

// Delete: current absent, previous (pre-image) preserved, TTL flag set.
assert_eq!(
items[2].operation_type(),
Some(ChangeFeedOperationType::Delete)
);
assert!(items[2].current().is_none());
assert_eq!(items[2].previous(), Some(&Doc { id: "3".into() }));
assert_eq!(
items[2].metadata().and_then(|m| m.time_to_live_expired()),
Some(true)
);
}

/// Builds an SDK [`CosmosResponse`] wrapping the given JSON body so the
/// deserialize helper can be exercised end to end.
fn make_response(body: serde_json::Value) -> CosmosResponse {
Expand Down
Loading
Loading