Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0efa7b8
Restrict cross-partition query fan-out to feed-range scope
simorenoh Jul 7, 2026
ba795b8
Add PR link to changelog entry
simorenoh Jul 7, 2026
6c81640
Make feed-range fan-out tests observe the clip
simorenoh Jul 7, 2026
ad9e832
Mirror feed-range fan-out fix in driver CHANGELOG
simorenoh Jul 7, 2026
96390fd
Pin disjoint-target error behavior with a test
simorenoh Jul 8, 2026
b97b0cf
Merge branch 'main' into simorenoh/cosmos-feedrange-query-investigation
simorenoh Jul 8, 2026
19bb6d4
Add e2e regression test for feed-range-scoped queries
simorenoh Jul 8, 2026
1d74c9d
Add feed-range test words to Cosmos cspell ignore list
simorenoh Jul 8, 2026
9f3e986
Merge branch 'main' into simorenoh/cosmos-feedrange-query-investigation
simorenoh Jul 8, 2026
6a29dde
Merge remote-tracking branch 'origin/main' into simorenoh/cosmos-feed…
simorenoh Jul 9, 2026
b817f11
Reframe #4705 changelog entries as regression tests
simorenoh Jul 9, 2026
5c9b577
Attribute feed-range bugfix changelog to #4319
simorenoh Jul 9, 2026
9d90f1a
Merge remote-tracking branch 'origin/main' into simorenoh/cosmos-feed…
simorenoh Jul 9, 2026
0d29d2e
Assert per-range results in feed-range e2e test
simorenoh Jul 9, 2026
0d3c8dc
Merge branch 'main' into simorenoh/cosmos-feedrange-query-investigation
simorenoh Jul 13, 2026
194b406
Merge remote-tracking branch 'origin/main' into simorenoh/cosmos-feed…
simorenoh Jul 13, 2026
140feda
Assert full per-range grouping in feed-range e2e test
simorenoh Jul 13, 2026
ab07e94
Hardcode per-range grouping in feed-range e2e test
simorenoh Jul 14, 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
2 changes: 2 additions & 0 deletions sdk/cosmos/.cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@
"mysproc",
"myudf",
"napi",
"neighbouring",
"newtypes",
"Nghttp",
"nocoll",
Expand Down Expand Up @@ -304,6 +305,7 @@
"uksouth",
"ukwest",
"unavail",
"unclipped",
"uncollapsed",
"uncontended",
"undrained",
Expand Down
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 @@ -16,6 +16,7 @@

### Bugs Fixed

- Fixed cross-partition queries scoped to an explicit feed range (`ContainerClient::query_items` with `FeedScope::range`) fanning out to partitions outside the requested range. The query planner now intersects the query plan's ranges with the requested feed-range target, so only the partitions overlapping the requested range are queried. This prevents returning documents outside the requested feed range and the extra scans / RU usage that occurred when enumerating `read_feed_ranges()` and querying each range. ([#4319](https://github.com/Azure/azure-sdk-for-rust/pull/4319))
- `http://` (non-HTTPS) account endpoints are now rejected unless the host is a known Cosmos DB emulator host, failing fast during client construction with an "invalid account endpoint" error instead of attempting an insecure connection to a production account. This validation also covers configured backup endpoints. ([#4757](https://github.com/Azure/azure-sdk-for-rust/pull/4757))
- Fixed hierarchical-partition-key (HPK) queries. A `FeedScope::partition` scope with only a *prefix* of the key hierarchy (e.g. `("USA", "CA")` on a `/country/state/city` container) now filters to that prefix instead of returning every item in the physical partition (issue [#4680](https://github.com/Azure/azure-sdk-for-rust/issues/4680)), and cross-partition queries over an HPK container no longer fail with `400 Bad Request` (issue [#4681](https://github.com/Azure/azure-sdk-for-rust/issues/4681)). ([#4729](https://github.com/Azure/azure-sdk-for-rust/pull/4729))
- Fixed the `AZURE_COSMOS_PPCB_*` environment variables (including `AZURE_COSMOS_PPCB_ENABLED` and the `AZURE_COSMOS_PPCB_ENABLED_OVERRIDE` kill switch) being ignored when a `CosmosClient` was built without calling `CosmosClientBuilder::with_partition_failover_options`. The per-partition circuit breaker (PPCB) stayed enabled even with `AZURE_COSMOS_PPCB_ENABLED=false`. The client's driver now resolves these options from the environment when they are not supplied explicitly. ([#4655](https://github.com/Azure/azure-sdk-for-rust/pull/4655))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ use std::error::Error;

use azure_data_cosmos::feed::ContinuationToken;
use azure_data_cosmos::{
clients::DatabaseClient,
clients::{ContainerClient, DatabaseClient},
feed::FeedScope,
models::CosmosStatus,
models::{CosmosStatus, ThroughputProperties},
options::{MaxItemCountHint, QueryOptions},
Query,
};
Expand Down Expand Up @@ -119,6 +119,22 @@ struct ItemProjection {
merge_order: usize,
}

/// Drains `select value c.id from c` for the given scope and returns the ids.
async fn collect_ids_for_scope(
container_client: &ContainerClient,
scope: FeedScope,
) -> Result<Vec<String>, Box<dyn Error>> {
let mut pages = container_client
.query_items::<String>("select value c.id from c", scope, None)
.await?
.into_pages();
let mut ids = Vec::new();
while let Some(page) = pages.next().await {
ids.extend(page?.into_items());
}
Ok(ids)
}

#[tokio::test]
#[cfg_attr(
not(any(test_category = "emulator", test_category = "emulator_vnext")),
Expand Down Expand Up @@ -480,6 +496,84 @@ pub async fn cross_partition_query_pagination() -> Result<(), Box<dyn Error>> {
.await
}

#[tokio::test]
#[cfg_attr(
not(any(test_category = "emulator", test_category = "emulator_vnext")),
ignore = "requires test_category 'emulator' or 'emulator_vnext'"
)]
#[cfg_attr(
test_category = "emulator_vnext",
ignore = "skipped on vnext emulator: behavioral divergence"
)]
pub async fn feed_range_scoped_query_honors_range() -> Result<(), Box<dyn Error>> {
TestClient::run_with_unique_db(
async |_, db_client| {
// 10 logical partitions × 2 items. Provision 11000 RU/s so the
// container splits into 2 physical partitions — a scoped query then
// has a neighbouring range it must NOT touch.
let items = test_data::generate_mock_items(10, 2);
let throughput = ThroughputProperties::manual(11000);
let container_client =
test_data::create_container_with_items(db_client, items.clone(), Some(throughput))
.await?;

let ranges = container_client.read_feed_ranges(None).await?;
assert_eq!(
ranges.len(),
2,
"expected exactly 2 physical partitions with 11000 RU/s, got {}",
ranges.len()
);

// Query each feed range in isolation — exactly the customer scenario
// (`read_feed_ranges()` then query each range) — and collect the ids
// each range returns. Pair each result set with its range so we can
// order the outer list by the range's lower bound, independent of the
// order `read_feed_ranges` happens to return.
let mut per_range: Vec<(String, Vec<String>)> = Vec::new();
for range in &ranges {
let mut ids =
collect_ids_for_scope(&container_client, FeedScope::range(range.clone()))
.await?;
ids.sort_by_key(|id| id.parse::<u32>().expect("mock ids are numeric"));
per_range.push((range.min_inclusive().to_hex(), ids));
}
per_range.sort_by(|a, b| a.0.cmp(&b.0));
let per_range_ids: Vec<Vec<&str>> = per_range
.iter()
.map(|(_, ids)| ids.iter().map(String::as_str).collect())
.collect();

// The container deterministically splits into two physical ranges at
// EPK `0x1FFF…FF` (`[00, 1FFF…FF)` and `[1FFF…FF, FF)`), so each of the
// 10 partition keys maps to exactly one range and a correctly-scoped
// query returns only that range's ids. These groupings were captured
// from a real Cosmos DB account and are identical on the emulator —
// the effective-partition-key hash and the split boundary are
// algorithm-driven, not environment-specific. Before the fan-out clip
// fix each `FeedScope::range` query fanned out across the whole
// container, so every range returned all 20 ids instead of its slice.
let expected: Vec<Vec<&str>> = vec![
vec![
"0", "1", "20", "21", "30", "31", "40", "41", "50", "51", "70", "71", "90",
"91",
],
vec!["10", "11", "60", "61", "80", "81"],
];

assert_eq!(
expected, per_range_ids,
"each feed range must return exactly the items whose partition key \
maps into it — no over-scan, no missing items, no empty ranges"
);

Ok(())
},
Some(TestOptions::for_emulator()),
)
.await
}

#[tokio::test]
#[cfg_attr(
not(test_category = "emulator"),
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 @@ -16,6 +16,7 @@

### Bugs Fixed

- Fixed cross-partition query fan-out ignoring an operation's explicit feed-range target. When a query was scoped to an explicit EPK range (`CosmosOperation::query_items` with a `FeedRange` target, surfaced as `FeedScope::range`), the query planner built request leaves from the query plan's ranges — derived from the query text alone — without intersecting them against the requested target. Both fan-out paths (`plan_fresh` and the resume path) now intersect each query-plan range with the operation's target, so only partitions overlapping the requested range are queried; operations with no target (full-container scope) are unaffected. ([#4319](https://github.com/Azure/azure-sdk-for-rust/pull/4319))
- `http://` (non-HTTPS) account endpoints are now rejected unless the host is a known Cosmos DB emulator host (as determined by the existing emulator-host detection). The check runs in `CosmosDriver::new` (reached via `CosmosDriverRuntime::create_driver`), so an invalid endpoint fails fast with a `CLIENT_INVALID_ACCOUNT_ENDPOINT_URL` error before any network I/O. Both the primary endpoint and any backup endpoints are validated. ([#4757](https://github.com/Azure/azure-sdk-for-rust/pull/4757))
- Fixed `DiagnosticsContext` debug rendering to emit valid diagnostics JSON instead of Rust struct dumps that leaked internal timers, caches, and CPU monitor state. Debug rendering now uses the configured default diagnostics verbosity, which defaults to summary. ([#4733](https://github.com/Azure/azure-sdk-for-rust/pull/4733))
- Fixed hierarchical-partition-key (HPK / MultiHash) queries emitting the wrong `x-ms-read-key-type` for effective-partition-key range-scoped requests. The driver sent the point value `EffectivePartitionKey` alongside `x-ms-start-epk`/`x-ms-end-epk`, which the gateway rejects with `400 "One of the input values is invalid"`; it now sends `EffectivePartitionKeyRange`. This enables filtered partition-key *prefix* queries (issue [#4680](https://github.com/Azure/azure-sdk-for-rust/issues/4680)) and fixes cross-partition queries over HPK containers failing with `400 Bad Request` (issue [#4681](https://github.com/Azure/azure-sdk-for-rust/issues/4681)). ([#4729](https://github.com/Azure/azure-sdk-for-rust/pull/4729))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,45 @@ impl TopologyProvider for MockTopologyProvider {
}
}

/// A range-aware topology provider backed by a fixed physical partition layout.
///
/// Unlike [`MockTopologyProvider`] (which ignores the requested range and
/// replays a queue), this resolves *against* the requested range:
/// `resolve_ranges(range)` returns exactly the configured physical partitions
/// whose EPK span overlaps `range`, mirroring real routing. This lets fan-out
/// tests observe when the planner resolves partitions outside the requested
/// scope — a plain replay mock cannot, because it returns the same partitions
/// regardless of the range it is asked about.
pub(crate) struct PhysicalTopologyProvider {
partitions: Vec<ResolvedRange>,
}

impl PhysicalTopologyProvider {
pub fn new(partitions: Vec<ResolvedRange>) -> Self {
Self { partitions }
}
}

impl TopologyProvider for PhysicalTopologyProvider {
fn resolve_ranges<'a>(
&'a mut self,
range: &'a FeedRange,
_refresh: PartitionRoutingRefresh,
) -> BoxFuture<'a, crate::error::Result<Vec<ResolvedRange>>> {
let resolved: Vec<ResolvedRange> = self
.partitions
.iter()
.filter(|p| {
// Half-open overlap: [a, b) intersects [c, d) iff a < d && c < b.
p.range.min_inclusive() < range.max_exclusive()
&& range.min_inclusive() < p.range.max_exclusive()
})
.cloned()
.collect();
Box::pin(async move { Ok(resolved) })
}
}

// ── Test helpers ────────────────────────────────────────────────────────────

/// Extracts the `CosmosResponse` from a `PageResult::Page`, panicking otherwise.
Expand Down
144 changes: 144 additions & 0 deletions sdk/cosmos/azure_data_cosmos_driver/src/driver/dataflow/planner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,18 @@ mod tests {
.with_body(br#"{"query":"SELECT * FROM c"}"#.to_vec())
}

/// A cross-partition query scoped to an explicit EPK feed-range target,
/// e.g. `FeedScope::range([min, max))`.
fn query_operation_with_target(min: &str, max: &str) -> CosmosOperation {
let target = FeedRange::new(
EffectivePartitionKey::from(min),
EffectivePartitionKey::from(max),
)
.unwrap();
CosmosOperation::query_items(test_container(), Some(target))
.with_body(br#"{"query":"SELECT * FROM c"}"#.to_vec())
}

// --- build_trivial_pipeline tests ---

#[test]
Expand Down Expand Up @@ -1202,6 +1214,117 @@ mod tests {
assert_drain_requests_with_partitions(pipeline, &[("20", "80", "pkrange-wide", "", "FF")]);
}

#[tokio::test]
async fn restricts_fanout_to_explicit_target_range() {
// The reported bug: query plan spans the whole space `[, FF)` but the
// caller scoped the query to `[00, 80)` via `FeedScope::range`. Only
// the requested slice must be queried, not the neighbouring `[80, FF)`
// partition. The physical topology actually contains both partitions,
// so a planner that ignores the target would resolve and emit a leaf
// for `[80, FF)` as well.
let plan = plan_with_ranges(vec![qr("", "FF")]);
let op = query_operation_with_target("00", "80");
let mut topology = PhysicalTopologyProvider::new(vec![
rr("00", "80", "pkrange-left"),
rr("80", "FF", "pkrange-right"),
]);

let pipeline = build_sequential_drain(&plan, &mut topology, &Arc::new(op), None)
.await
.unwrap();
assert_drain_requests(pipeline, &[("00", "80", "pkrange-left")]);
}

#[tokio::test]
async fn drops_query_ranges_outside_target() {
// Two disjoint query-plan ranges; the target only overlaps the first.
// The second range must contribute no request leaves (and its topology
// must never be resolved).
let plan = plan_with_ranges(vec![qr("", "40"), qr("80", "FF")]);
let op = query_operation_with_target("", "40");
let mut topology = MockTopologyProvider::new(vec![Ok(vec![rr("", "40", "pkrange-A")])]);

let pipeline = build_sequential_drain(&plan, &mut topology, &Arc::new(op), None)
.await
.unwrap();
assert_drain_requests(pipeline, &[("", "40", "pkrange-A")]);
}

#[tokio::test]
async fn clips_query_range_to_partial_target_overlap() {
// The target `[20, 60)` partially overlaps a single query-plan range
// spanning several partitions. Only partitions within the target,
// clipped to its bounds, may be queried.
let plan = plan_with_ranges(vec![qr("", "FF")]);
let op = query_operation_with_target("20", "60");
let mut topology = MockTopologyProvider::new(vec![Ok(vec![
rr("00", "40", "pkrange-1"),
rr("40", "80", "pkrange-2"),
])]);

let pipeline = build_sequential_drain(&plan, &mut topology, &Arc::new(op), None)
.await
.unwrap();
assert_drain_requests_with_partitions(
pipeline,
&[
("20", "40", "pkrange-1", "00", "40"),
("40", "60", "pkrange-2", "40", "80"),
],
);
}

#[tokio::test]
async fn drops_query_range_touching_target_boundary() {
// A query-plan range that only *touches* the target's exclusive upper
// bound (target `[, 40)`, range `[40, FF)`) shares no EPKs with the
// target and must be dropped, not queried. Exercises the exact
// boundary case where `intersect_feed_ranges` collapses to empty.
let plan = plan_with_ranges(vec![qr("", "40"), qr("40", "FF")]);
let op = query_operation_with_target("", "40");
let mut topology = PhysicalTopologyProvider::new(vec![
rr("", "40", "pkrange-A"),
rr("40", "FF", "pkrange-B"),
]);

let pipeline = build_sequential_drain(&plan, &mut topology, &Arc::new(op), None)
.await
.unwrap();
assert_drain_requests(pipeline, &[("", "40", "pkrange-A")]);
}

#[tokio::test]
async fn resume_restricts_fanout_to_target_range() {
// Resuming a target-scoped query must also honour the target: the
// `[80, FF)` partition lies outside `[00, 80)` and must not be queried
// even though the query plan spans `[, FF)` and that partition exists
// in the physical topology. An unclipped resume would emit a second,
// fresh-start leaf for `[80, FF)`.
let plan = plan_with_ranges(vec![qr("", "FF")]);
let op = query_operation_with_target("00", "80");
let mut topology = PhysicalTopologyProvider::new(vec![
rr("00", "80", "pkrange-left"),
rr("80", "FF", "pkrange-right"),
]);

let resume = saved_drain(vec![("00", "80", saved_request(Some("server-token-xyz")))]);

let pipeline = build_sequential_drain(&plan, &mut topology, &Arc::new(op), Some(resume))
.await
.unwrap();
assert_drain_requests_with_partitions_and_continuation(
pipeline,
&[(
"00",
"80",
"pkrange-left",
"00",
"80",
Some("server-token-xyz"),
)],
);
}

#[tokio::test]
async fn rejects_query_plan_with_top() {
let plan = QueryPlan {
Expand Down Expand Up @@ -1368,6 +1491,27 @@ mod tests {
);
}

#[tokio::test]
async fn rejects_target_disjoint_from_query_ranges() {
// A target that shares no EPKs with any query-plan range clips every
// range away, leaving zero request leaves. On the fresh path this is
// reported as the same hard error as an empty query plan — the clip
// does not silently swallow the query. `NoopTopologyProvider` asserts
// no partition is ever resolved (the clip skips before resolution).
let plan = plan_with_ranges(vec![qr("80", "FF")]);
let op = query_operation_with_target("00", "40");
let mut topology = NoopTopologyProvider;

let err = build_sequential_drain(&plan, &mut topology, &Arc::new(op), None)
.await
.unwrap_err();
let rendered = err.to_string();
assert!(
rendered.ends_with("query plan produced no partition ranges to query"),
"unexpected: {rendered}"
);
}

#[tokio::test]
async fn propagates_topology_resolution_error() {
let plan = plan_with_ranges(vec![qr("", "FF")]);
Expand Down
Loading