Skip to content

Commit efdcf66

Browse files
yumnahussainCopilot
andcommitted
Add ChangeFeed AllVersionsAndDeletes (full-fidelity) mode
Implements the AllVersionsAndDeletes (AVAD / full-fidelity) change feed mode, stacked on the LatestVersion change-feed work (#4621). Driver (azure_data_cosmos_driver): - Add FULL_FIDELITY_FEED = "Full-Fidelity Feed" and a full_fidelity_feed flag on CosmosRequestHeaders; write_to_headers emits A-IM: Full-Fidelity Feed for AVAD (mutually exclusive with Incremental Feed). - Add CosmosOperation::change_feed_all_versions_and_deletes factory sharing a private helper with change_feed; both set the changefeed wire-format version. Unit tests cover both header values and factories. SDK (azure_data_cosmos): - New public models ChangeFeedItem<T>, ChangeFeedMetadata, and ChangeFeedOperationType describing the full-fidelity envelope (current/previous/metadata), with accessors and thorough unit tests. - Thread an unwrap_current flag through ChangeFeedPageIterator so AVAD pages deserialize the whole envelope (T = ChangeFeedItem<Doc>) instead of stripping current as LatestVersion does. - Wire AVAD into ContainerClient::query_change_feed, dispatched on options.mode, and reject ChangeFeedStartFrom::Beginning for AVAD since intermediate versions/deletes are only retained within the container's retention window. - Update ChangeFeedMode docs and both CHANGELOGs. Deferred (documented as follow-ups): emulator/live AVAD end-to-end tests, lossless per-range Now resume, and persisting the feed mode in the continuation token. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3021859 commit efdcf66

9 files changed

Lines changed: 667 additions & 50 deletions

File tree

sdk/cosmos/azure_data_cosmos/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Features Added
66

77
- 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` (`LatestVersion` default; `AllVersionsAndDeletes` is reserved for a future release and currently returns an error); 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))
8+
- Added `ChangeFeedMode::AllVersionsAndDeletes` ("full fidelity") support to `ContainerClient::query_change_feed()`. In this mode each change is delivered as a `ChangeFeedItem<T>` envelope exposing the post-change document (`current`), the pre-change document (`previous`, when available), and a `ChangeFeedMetadata` describing the change (`ChangeFeedOperationType``Create`/`Replace`/`Delete` — plus `lsn`, commit timestamp, previous-image LSN, and a time-to-live-expiry flag). New public model types `ChangeFeedItem<T>`, `ChangeFeedMetadata`, and `ChangeFeedOperationType`. `ChangeFeedStartFrom::Beginning` is rejected for this mode because intermediate versions and deletes are only retained within the container's retention window; use `Now` or an in-retention `PointInTime`. The continuation token does not persist the feed mode, so callers must re-pass `AllVersionsAndDeletes` on resume.
89
- 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))
910

1011
### Breaking Changes

sdk/cosmos/azure_data_cosmos/src/clients/container_client.rs

Lines changed: 111 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -857,8 +857,18 @@ impl ContainerClient {
857857

858858
/// Queries the change feed for a container, returning a stream of pages.
859859
///
860-
/// The change feed provides an ordered list of changes (creates and
861-
/// replaces in LatestVersion mode) made to items in the container.
860+
/// The change feed provides an ordered list of changes made to items in the
861+
/// container. The [`mode`](crate::options::ChangeFeedOptions::mode) selects
862+
/// what each change carries and how the item type `T` is bound:
863+
///
864+
/// * [`ChangeFeedMode::LatestVersion`] (default) — returns the latest
865+
/// version of each created or replaced item. Bind `T` to your document
866+
/// type; the SDK unwraps the wire-format envelope for you.
867+
/// * [`ChangeFeedMode::AllVersionsAndDeletes`] — returns every intermediate
868+
/// version plus deletes ("full fidelity"). Bind `T` to
869+
/// [`ChangeFeedItem<YourDoc>`](crate::models::ChangeFeedItem); the SDK
870+
/// preserves the whole envelope so you can inspect `current`, `previous`,
871+
/// and the change [`metadata`](crate::models::ChangeFeedMetadata).
862872
///
863873
/// # Arguments
864874
/// * `scope` - Determines which partitions to read changes from.
@@ -867,8 +877,28 @@ impl ContainerClient {
867877
/// the token holds its own position.
868878
/// * `options` - Optional parameters controlling mode, session token, and paging.
869879
///
880+
/// # AllVersionsAndDeletes limitations
881+
///
882+
/// * [`ChangeFeedStartFrom::Beginning`] is **rejected** for this mode:
883+
/// intermediate versions and deletes are only retained within the
884+
/// container's retention / continuous-backup window, so there is no
885+
/// "beginning of all history" to read from. Use
886+
/// [`ChangeFeedStartFrom::Now`] or a [`ChangeFeedStartFrom::PointInTime`]
887+
/// within the retention window.
888+
/// * [`ChangeFeedStartFrom::Now`] is re-evaluated per range the first time a
889+
/// range is polled, so a range that is never polled before a checkpoint
890+
/// resumes from resume-time and can drop the intermediate versions and
891+
/// deletes that occurred between the original start and the resume. `Now`
892+
/// is deliberately **not** converted to a concrete `PointInTime`, because
893+
/// that would change its semantics; lossless per-range `Now` resolution is
894+
/// a future improvement.
895+
/// * The continuation token does not persist the feed mode, so callers must
896+
/// re-pass [`ChangeFeedMode::AllVersionsAndDeletes`] on resume.
897+
///
870898
/// # Examples
871899
///
900+
/// Read the latest version of each change from the beginning:
901+
///
872902
/// ```rust,no_run
873903
/// use azure_data_cosmos::{clients::ContainerClient, feed::FeedScope, options::ChangeFeedStartFrom};
874904
/// use futures::StreamExt;
@@ -894,6 +924,46 @@ impl ContainerClient {
894924
/// # Ok(())
895925
/// # }
896926
/// ```
927+
///
928+
/// Read every version and delete ("full fidelity"):
929+
///
930+
/// ```rust,no_run
931+
/// use azure_data_cosmos::{
932+
/// clients::ContainerClient,
933+
/// feed::FeedScope,
934+
/// models::ChangeFeedItem,
935+
/// options::{ChangeFeedMode, ChangeFeedOptions, ChangeFeedStartFrom},
936+
/// };
937+
/// use futures::StreamExt;
938+
/// use serde::Deserialize;
939+
///
940+
/// // A delete envelope may omit non-key fields, so keep them optional.
941+
/// #[derive(Debug, Deserialize)]
942+
/// struct MyItem {
943+
/// id: String,
944+
/// #[serde(default)]
945+
/// value: Option<i64>,
946+
/// }
947+
///
948+
/// # async fn example(container: ContainerClient) -> Result<(), Box<dyn std::error::Error>> {
949+
/// let options = ChangeFeedOptions::default().with_mode(ChangeFeedMode::AllVersionsAndDeletes);
950+
/// let mut pages = container
951+
/// .query_change_feed::<ChangeFeedItem<MyItem>>(
952+
/// FeedScope::full_container(),
953+
/// ChangeFeedStartFrom::Now,
954+
/// Some(options),
955+
/// )
956+
/// .await?;
957+
///
958+
/// while let Some(page) = pages.next().await {
959+
/// for item in page?.items() {
960+
/// println!("{:?}: current={:?} previous={:?}",
961+
/// item.operation_type(), item.current(), item.previous());
962+
/// }
963+
/// }
964+
/// # Ok(())
965+
/// # }
966+
/// ```
897967
pub async fn query_change_feed<T: DeserializeOwned + Send + 'static>(
898968
&self,
899969
scope: FeedScope,
@@ -902,39 +972,45 @@ impl ContainerClient {
902972
) -> crate::Result<ChangeFeedPageIterator<T>> {
903973
let options = options.unwrap_or_default();
904974

905-
// AllVersionsAndDeletes is reserved for a future release. Reject it
906-
// explicitly rather than silently behaving like LatestVersion.
907-
//
908-
// TODO: When enabling AllVersionsAndDeletes, revisit start/resume
909-
// semantics — they differ from LatestVersion:
910-
// - `Beginning` is invalid under AVAD (bounded retention window);
911-
// require `Now` or a `PointInTime` within retention and reject
912-
// `Beginning` for this mode.
913-
// - `Now` is re-evaluated per request, so a range never polled before
914-
// a checkpoint resumes from resume-time and would drop the
915-
// intermediate versions/deletes between the original start and the
916-
// resume. Resolve `Now` to a concrete per-range start position (or
917-
// snapshot the start time as a `PointInTime`) so AVAD resume is
918-
// lossless.
919-
// - The continuation token does not persist the feed mode; callers
920-
// must re-pass AllVersionsAndDeletes on resume.
921-
if options.mode == ChangeFeedMode::AllVersionsAndDeletes {
922-
return Err(crate::DriverCosmosError::builder()
923-
.with_status(crate::error::CosmosStatus::new(
924-
azure_core::http::StatusCode::NotImplemented,
925-
))
926-
.with_message(
927-
"AllVersionsAndDeletes change feed mode is not yet supported; \
928-
use ChangeFeedMode::LatestVersion",
975+
let feed_range = scope.into_feed_range(self.container_ref.partition_key_definition());
976+
977+
// The mode selects the base operation (which `A-IM` header is sent) and
978+
// whether the iterator unwraps `current` (LatestVersion) or preserves
979+
// the whole envelope (AllVersionsAndDeletes → `ChangeFeedItem<T>`).
980+
let (mut initial_operation, unwrap_current) = match options.mode {
981+
ChangeFeedMode::AllVersionsAndDeletes => {
982+
// AllVersionsAndDeletes can only read within the container's
983+
// retention window, so "from the beginning of all history" is
984+
// not a valid start. Reject it with a clear client error rather
985+
// than issuing a request the service would refuse.
986+
if start_from == ChangeFeedStartFrom::Beginning {
987+
return Err(crate::DriverCosmosError::builder()
988+
.with_status(crate::error::CosmosStatus::new(
989+
azure_core::http::StatusCode::BadRequest,
990+
))
991+
.with_message(
992+
"ChangeFeedStartFrom::Beginning is not supported for \
993+
ChangeFeedMode::AllVersionsAndDeletes because intermediate versions \
994+
and deletes are only retained within the container's retention / \
995+
continuous-backup window; use ChangeFeedStartFrom::Now or a \
996+
ChangeFeedStartFrom::PointInTime within the retention window",
997+
)
998+
.build()
999+
.into());
1000+
}
1001+
(
1002+
CosmosOperation::change_feed_all_versions_and_deletes(
1003+
self.container_ref.clone(),
1004+
Some(feed_range),
1005+
),
1006+
false,
9291007
)
930-
.build()
931-
.into());
932-
}
933-
934-
let mut initial_operation = CosmosOperation::change_feed(
935-
self.container_ref.clone(),
936-
Some(scope.into_feed_range(self.container_ref.partition_key_definition())),
937-
);
1008+
}
1009+
ChangeFeedMode::LatestVersion => (
1010+
CosmosOperation::change_feed(self.container_ref.clone(), Some(feed_range)),
1011+
true,
1012+
),
1013+
};
9381014

9391015
if let Some(token) = options.session_token {
9401016
initial_operation = initial_operation.with_session_token(token);
@@ -966,6 +1042,7 @@ impl ContainerClient {
9661042
Some(self.container_ref.clone()),
9671043
plan,
9681044
options.operation,
1045+
unwrap_current,
9691046
))
9701047
}
9711048

sdk/cosmos/azure_data_cosmos/src/feed/change_feed_iterator.rs

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ struct LiveState {
2727
plan: Option<OperationPlan>,
2828
in_flight: Option<DriverPageFuture>,
2929
errored: bool,
30+
/// Controls how each page's items are decoded.
31+
///
32+
/// `true` (LatestVersion): strip the `current` field from each enveloped
33+
/// item so `T` is the caller's plain document type. `false`
34+
/// (AllVersionsAndDeletes): deserialize the whole envelope so
35+
/// `T = ChangeFeedItem<Doc>` binds `current`, `previous`, and `metadata`.
36+
unwrap_current: bool,
3037
}
3138

3239
impl LiveState {
@@ -35,6 +42,7 @@ impl LiveState {
3542
container: Option<ContainerReference>,
3643
plan: OperationPlan,
3744
options: OperationOptions,
45+
unwrap_current: bool,
3846
) -> Self {
3947
Self {
4048
driver,
@@ -43,6 +51,7 @@ impl LiveState {
4351
plan: Some(plan),
4452
in_flight: None,
4553
errored: false,
54+
unwrap_current,
4655
}
4756
}
4857

@@ -123,7 +132,15 @@ impl LiveState {
123132
return task::Poll::Ready(Some(Ok(page)));
124133
}
125134

126-
match unwrap_change_feed_items::<T>(response) {
135+
// LatestVersion strips `current` so `T` is the plain document;
136+
// AllVersionsAndDeletes keeps the whole envelope so
137+
// `T = ChangeFeedItem<Doc>`.
138+
let items = if *this.unwrap_current {
139+
unwrap_change_feed_items::<T>(response)
140+
} else {
141+
deserialize_change_feed_items::<T>(response)
142+
};
143+
match items {
127144
Ok(items) => {
128145
let page = FeedPage::new(items, headers, diagnostics);
129146
task::Poll::Ready(Some(Ok(page)))
@@ -178,6 +195,21 @@ fn unwrap_change_feed_items<T: DeserializeOwned>(
178195
.collect()
179196
}
180197

198+
/// Deserializes a change feed response body into the caller's item type
199+
/// **without** unwrapping the wire-format envelope.
200+
///
201+
/// Used for AllVersionsAndDeletes (full-fidelity) mode, where the caller binds
202+
/// `T = ChangeFeedItem<Doc>` so the whole `{ current, previous, metadata }`
203+
/// envelope is preserved. Each entry in the feed body is deserialized directly
204+
/// into `T` rather than having its `current` field stripped (contrast
205+
/// [`unwrap_change_feed_items`], used for LatestVersion).
206+
fn deserialize_change_feed_items<T: DeserializeOwned>(
207+
response: CosmosResponse,
208+
) -> crate::Result<Vec<T>> {
209+
let body: FeedBody<T> = response.into_model()?;
210+
Ok(body.items)
211+
}
212+
181213
/// Unwraps the `current` document from a single change feed wire-format
182214
/// envelope, or returns the value unchanged when it is not enveloped.
183215
///
@@ -205,11 +237,20 @@ fn unwrap_change_feed_item(item: serde_json::Value) -> serde_json::Value {
205237

206238
/// A stream of pages from a Cosmos DB change feed operation.
207239
///
208-
/// Yields [`FeedPage<T>`] instances, where `T` is the user's document type
209-
/// (for LatestVersion mode). The stream is conceptually infinite: when a
210-
/// partition has no new changes (304 Not Modified), an empty page is
211-
/// returned instead of terminating the stream. The consumer decides when
212-
/// to stop polling.
240+
/// Yields [`FeedPage<T>`] instances. The binding of `T` depends on the change
241+
/// feed mode:
242+
///
243+
/// * [`ChangeFeedMode::LatestVersion`](crate::options::ChangeFeedMode::LatestVersion):
244+
/// `T` is the user's document type. Each item's wire-format envelope is
245+
/// unwrapped so the caller sees the plain document.
246+
/// * [`ChangeFeedMode::AllVersionsAndDeletes`](crate::options::ChangeFeedMode::AllVersionsAndDeletes):
247+
/// `T` is [`ChangeFeedItem<YourDoc>`](crate::models::ChangeFeedItem). The full
248+
/// envelope is preserved so the caller can inspect `current`, `previous`, and
249+
/// the change [`metadata`](crate::models::ChangeFeedMetadata).
250+
///
251+
/// The stream is conceptually infinite: when a partition has no new changes
252+
/// (304 Not Modified), an empty page is returned instead of terminating the
253+
/// stream. The consumer decides when to stop polling.
213254
///
214255
/// Use [`to_continuation_token()`](Self::to_continuation_token) to capture
215256
/// the current position for later resumption.
@@ -257,14 +298,26 @@ pub struct ChangeFeedPageIterator<T: Send> {
257298

258299
impl<T: Send + DeserializeOwned + 'static> ChangeFeedPageIterator<T> {
259300
/// Creates a new `ChangeFeedPageIterator` backed by the given operation plan.
301+
///
302+
/// `unwrap_current` selects how each item is decoded: `true` for
303+
/// LatestVersion (strip `current` into the caller's document type), `false`
304+
/// for AllVersionsAndDeletes (keep the whole envelope, `T =
305+
/// ChangeFeedItem<Doc>`).
260306
pub(crate) fn new(
261307
driver: Arc<CosmosDriver>,
262308
container: Option<ContainerReference>,
263309
plan: OperationPlan,
264310
options: OperationOptions,
311+
unwrap_current: bool,
265312
) -> Self {
266313
Self {
267-
state: Box::pin(LiveState::new(driver, container, plan, options)),
314+
state: Box::pin(LiveState::new(
315+
driver,
316+
container,
317+
plan,
318+
options,
319+
unwrap_current,
320+
)),
268321
_marker: PhantomData,
269322
}
270323
}
@@ -301,8 +354,8 @@ impl<T: Send + DeserializeOwned + 'static> Stream for ChangeFeedPageIterator<T>
301354

302355
#[cfg(test)]
303356
mod tests {
304-
use super::{unwrap_change_feed_item, unwrap_change_feed_items};
305-
use crate::models::CosmosResponse;
357+
use super::{deserialize_change_feed_items, unwrap_change_feed_item, unwrap_change_feed_items};
358+
use crate::models::{ChangeFeedItem, ChangeFeedOperationType, CosmosResponse};
306359
use azure_core::http::StatusCode;
307360
use azure_data_cosmos_driver::diagnostics::DiagnosticsContext;
308361
use azure_data_cosmos_driver::models::{
@@ -365,6 +418,53 @@ mod tests {
365418
assert_eq!(items, vec![Doc { id: "1".into() }, Doc { id: "2".into() }]);
366419
}
367420

421+
#[test]
422+
fn deserializes_all_versions_and_deletes_page_without_unwrapping() {
423+
// AllVersionsAndDeletes mode binds `T = ChangeFeedItem<Doc>` and keeps
424+
// the whole envelope: create + replace + delete, with metadata and a
425+
// pre-image preserved rather than stripped.
426+
let body = json!({
427+
"Documents": [
428+
{
429+
"current": { "id": "1" },
430+
"metadata": { "operationType": "create", "lsn": 10 }
431+
},
432+
{
433+
"current": { "id": "2" },
434+
"previous": { "id": "2" },
435+
"metadata": { "operationType": "replace", "lsn": 11, "previousImageLsn": 10 }
436+
},
437+
{
438+
"previous": { "id": "3" },
439+
"metadata": { "operationType": "delete", "lsn": 12, "timeToLiveExpired": true }
440+
}
441+
],
442+
"_count": 3
443+
});
444+
445+
let items: Vec<ChangeFeedItem<Doc>> =
446+
deserialize_change_feed_items(make_response(body)).unwrap();
447+
assert_eq!(items.len(), 3);
448+
449+
// Create: current present, no previous.
450+
assert_eq!(items[0].operation_type(), ChangeFeedOperationType::Create);
451+
assert_eq!(items[0].current(), Some(&Doc { id: "1".into() }));
452+
assert!(items[0].previous().is_none());
453+
assert_eq!(items[0].metadata().lsn(), Some(10));
454+
455+
// Replace: both current and previous present.
456+
assert_eq!(items[1].operation_type(), ChangeFeedOperationType::Replace);
457+
assert_eq!(items[1].current(), Some(&Doc { id: "2".into() }));
458+
assert_eq!(items[1].previous(), Some(&Doc { id: "2".into() }));
459+
assert_eq!(items[1].metadata().previous_image_lsn(), Some(10));
460+
461+
// Delete: current absent, previous (pre-image) preserved, TTL flag set.
462+
assert_eq!(items[2].operation_type(), ChangeFeedOperationType::Delete);
463+
assert!(items[2].current().is_none());
464+
assert_eq!(items[2].previous(), Some(&Doc { id: "3".into() }));
465+
assert_eq!(items[2].metadata().time_to_live_expired(), Some(true));
466+
}
467+
368468
/// Builds an SDK [`CosmosResponse`] wrapping the given JSON body so the
369469
/// unwrap helper can be exercised end to end.
370470
fn make_response(body: serde_json::Value) -> CosmosResponse {

0 commit comments

Comments
 (0)