diff --git a/sdk/cosmos/azure_data_cosmos/Cargo.toml b/sdk/cosmos/azure_data_cosmos/Cargo.toml index 863b6312a5..1b1049503b 100644 --- a/sdk/cosmos/azure_data_cosmos/Cargo.toml +++ b/sdk/cosmos/azure_data_cosmos/Cargo.toml @@ -75,7 +75,7 @@ tokio = { workspace = true, features = [ "test-util", ] } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } -uuid = { workspace = true, features = ["v4", "fast-rng"] } +uuid = { workspace = true, features = ["v4", "v7", "fast-rng"] } [lints] workspace = true @@ -170,6 +170,11 @@ name = "in_memory_emulator" path = "tests/in_memory_emulator.rs" required-features = ["__internal_in_memory_emulator"] +[[test]] +name = "e2e_tests" +path = "tests/e2e_tests.rs" +required-features = ["key_auth", "control_plane"] + # The `cosmos` example CLI exercises control-plane operations (database and # container CRUD, throughput) alongside data-plane commands, so the whole # binary requires the `control_plane` feature to compile. diff --git a/sdk/cosmos/azure_data_cosmos/build.rs b/sdk/cosmos/azure_data_cosmos/build.rs index 60b4f930f4..21d4d62bea 100644 --- a/sdk/cosmos/azure_data_cosmos/build.rs +++ b/sdk/cosmos/azure_data_cosmos/build.rs @@ -10,7 +10,7 @@ fn main() { // Allow `#[cfg_attr(not(test_category = "..."), ignore)]` in `tests/*.rs`. println!( - "cargo:rustc-check-cfg=cfg(test_category, values(\"emulator\", \"emulator_vnext\", \"emulator_inmemory\", \"emulator_inmemory_gateway_v2\", \"multi_write\", \"split\", \"merge\", \"binary_encoding\", \"gateway_v2\", \"gateway_v2_multi_region\"))" + "cargo:rustc-check-cfg=cfg(test_category, values(\"emulator\", \"emulator_vnext\", \"emulator_inmemory\", \"emulator_inmemory_gateway_v2\", \"e2e\", \"multi_write\", \"split\", \"merge\", \"binary_encoding\", \"gateway_v2\", \"gateway_v2_multi_region\"))" ); // Marker cfg set by test setups where the target Cosmos account is provisioned // for AAD data-plane access (local emulator started with /enableaadauthentication, diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs new file mode 100644 index 0000000000..e16ec6b2f7 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/bootstrap_primary.rs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::StatusCode; + +use crate::e2e_test_cases::{ + fixture::{build_client, TestResult}, + support::should_run, +}; + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn bootstrap_primary_endpoint() -> TestResult { + if !should_run("bootstrap.primary-success").await? { + return Ok(()); + } + + // Building the public SDK client against the reachable primary endpoint succeeds. + let client = build_client().await?; + + // The initialized client can complete its first account operation. + let database_id = format!("e2e-bootstrap-{}", azure_core::Uuid::new_v4()); + let response = client.create_database(&database_id, None).await?; + assert_eq!(response.status().status_code(), StatusCode::Created); + response.into_model()?; + + // Remove the resource created only to prove successful bootstrap. + client.database_client(&database_id).delete(None).await?; + Ok(()) +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs new file mode 100644 index 0000000000..d741b549da --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/capabilities.rs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use serde::Deserialize; + +use crate::e2e_test_cases::{fixture::TestResult, support::should_run}; + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn capability_document_is_versioned() -> TestResult { + if !should_run("management.capabilities").await? { + return Ok(()); + } + + // Read the emulator's public management document without using Rust internals. + let management_endpoint = std::env::var("AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT")?; + let response = reqwest::Client::new() + .get(url::Url::parse(&management_endpoint)?.join("capabilities")?) + .send() + .await?; + assert_eq!(response.status(), reqwest::StatusCode::OK); + let capabilities: CapabilityDocument = serde_json::from_slice(&response.bytes().await?)?; + + // The document is versioned and advertises the minimum E2E orchestration surface. + assert_eq!(capabilities.api_version, 1); + assert!(!capabilities.emulator_version.is_empty()); + assert!(capabilities.protocols.gateway_v1); + assert!(capabilities.data_plane.iter().any(|value| value == "item")); + assert!(capabilities + .management_actions + .iter() + .any(|value| value == "partitionSplit")); + + // Gateway V2 advertisement follows the emulator flavor selected by the profile matrix. + let expects_v2 = std::env::var("AZURE_COSMOS_EMULATOR_FLAVOR").as_deref() == Ok("inmemory-v2"); + assert_eq!(capabilities.protocols.gateway_v2, expects_v2); + Ok(()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CapabilityDocument { + api_version: u32, + emulator_version: String, + protocols: ProtocolCapabilities, + data_plane: Vec, + management_actions: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProtocolCapabilities { + gateway_v1: bool, + gateway_v2: bool, +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs new file mode 100644 index 0000000000..701d8b4a38 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/catalog.rs @@ -0,0 +1,760 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::PathBuf, +}; + +use serde::Deserialize; +use serde_json::Value; + +const DEFAULT_PROFILE: &str = "smokeTests"; +const SCENARIO_SCHEMA_REFERENCE: &str = "../../schema/scenario.v1.json"; +const PROFILE_SCHEMA_REFERENCE: &str = "../schema/profile.v1.json"; +const SCENARIO_DIRECTORY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../e2e_tests/scenarios"); +const BACKENDS: [&str; 3] = [ + "azureLive", + "hostedEmulatorGatewayV1", + "hostedEmulatorGatewayV2", +]; + +const SCENARIOS: &[&str] = &[ + include_str!("../../../e2e_tests/scenarios/management/capabilities.json"), + include_str!("../../../e2e_tests/scenarios/bootstrap/primary-success.json"), + include_str!("../../../e2e_tests/scenarios/items/lifecycle.json"), + include_str!("../../../e2e_tests/scenarios/items/upsert-create-update.json"), + include_str!("../../../e2e_tests/scenarios/items/create-conflict.json"), + include_str!("../../../e2e_tests/scenarios/items/not-found-wrong-partition-key.json"), + include_str!("../../../e2e_tests/scenarios/items/optimistic-concurrency.json"), + include_str!("../../../e2e_tests/scenarios/queries/parameterized-filter.json"), + include_str!("../../../e2e_tests/scenarios/queries/invalid-syntax.json"), + include_str!("../../../e2e_tests/scenarios/diagnostics/success-and-error.json"), +]; + +const PROFILES: &[&str] = &[ + include_str!("../../../e2e_tests/profiles/smokeTests.json"), + include_str!("../../../e2e_tests/profiles/lifecycleConsistencyMatrix.json"), + include_str!("../../../e2e_tests/profiles/readConsistencyOverrideMatrix.json"), +]; + +const RUST_IMPLEMENTATIONS: &str = include_str!("../../../e2e_tests/implementations/rust.json"); +const CONSISTENCY_MATRIX: &str = include_str!("../../../e2e-consistency-matrix.json"); +const OVERRIDE_MATRIX: &str = include_str!("../../../e2e-read-consistency-override-matrix.json"); +const SCENARIO_SCHEMA: &str = include_str!("../../../e2e_tests/schema/scenario.v1.json"); +const PROFILE_SCHEMA: &str = include_str!("../../../e2e_tests/schema/profile.v1.json"); + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Scenario { + #[serde(rename = "$schema")] + schema: String, + spec_version: String, + id: String, + title: String, + requirement: String, + #[expect( + dead_code, + reason = "typed schema metadata is validated during deserialization" + )] + maturity: Maturity, + precedents: Vec, + profiles: Vec, + tags: Vec, + backends: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +enum Maturity { + Candidate, + Stable, + Deprecated, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Precedent { + #[expect( + dead_code, + reason = "typed schema metadata is validated during deserialization" + )] + sdk: ReferenceSdk, + path: String, + test: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum ReferenceSdk { + Service, + Rust, + Java, + Dotnet, + Python, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Backend { + applicability: Applicability, + #[expect( + dead_code, + reason = "typed schema metadata is validated during deserialization" + )] + fidelity: Fidelity, + reason: Option, + #[serde(default)] + requires: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "camelCase")] +pub(super) enum Capability { + Capabilities, + GatewayV2, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +enum Applicability { + Required, + Supported, + Simulated, + NotApplicable, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +enum Fidelity { + Full, + Partial, + Simulated, + None, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Profile { + #[serde(rename = "$schema")] + schema: String, + spec_version: String, + pub id: String, + pub accounts: Vec, + pub runtimes: Vec, + pub clients: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AccountDefinition { + pub id: String, + write_mode: String, + pub consistency: String, + regions: Vec, + replication: ReplicationDefinition, + #[expect( + dead_code, + reason = "profile metadata is consumed by external orchestration" + )] + per_partition_failover: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RegionDefinition { + name: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ReplicationDefinition { + min_delay_ms: u64, + max_delay_ms: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RuntimeDefinition { + pub id: String, + pub gateway_v2: String, + pub ppcb: String, + pub default_read_consistency_strategy: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ClientDefinition { + pub id: String, + pub binary_encoding: String, + pub routing: String, + pub default_read_consistency_strategy: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ImplementationMap { + spec_version: String, + sdk: String, + test_target: String, + scenarios: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Implementation { + id: String, + test: String, + status: ImplementationStatus, +} + +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +enum ImplementationStatus { + Active, + Planned, + Unsupported, +} + +impl Profile { + pub fn account(&self, id: &str) -> &AccountDefinition { + self.accounts + .iter() + .find(|definition| definition.id == id) + .unwrap_or_else(|| panic!("profile '{}' has no account '{id}'", self.id)) + } + + pub fn runtime(&self, id: &str) -> &RuntimeDefinition { + self.runtimes + .iter() + .find(|definition| definition.id == id) + .unwrap_or_else(|| panic!("profile '{}' has no runtime '{id}'", self.id)) + } + + pub fn client(&self, id: &str) -> &ClientDefinition { + self.clients + .iter() + .find(|definition| definition.id == id) + .unwrap_or_else(|| panic!("profile '{}' has no client '{id}'", self.id)) + } + + pub fn selected_account(&self) -> Result<&AccountDefinition, String> { + let ids: Vec<_> = self + .accounts + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + Ok(self.account(selected_axis("AZURE_COSMOS_E2E_ACCOUNT", &ids)?)) + } + + pub fn selected_runtime(&self) -> Result<&RuntimeDefinition, String> { + let ids: Vec<_> = self + .runtimes + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + Ok(self.runtime(selected_axis("AZURE_COSMOS_E2E_RUNTIME", &ids)?)) + } + + pub fn selected_client(&self) -> Result<&ClientDefinition, String> { + let ids: Vec<_> = self + .clients + .iter() + .map(|definition| definition.id.as_str()) + .collect(); + Ok(self.client(selected_axis("AZURE_COSMOS_E2E_CLIENT", &ids)?)) + } +} + +fn selected_axis<'a>(environment_variable: &str, available: &'a [&str]) -> Result<&'a str, String> { + match std::env::var(environment_variable) { + Ok(selected) => available + .iter() + .copied() + .find(|candidate| *candidate == selected) + .ok_or_else(|| { + format!("{environment_variable}='{selected}' is not one of {available:?}") + }), + Err(_) if available.len() == 1 => Ok(available[0]), + Err(_) => Err(format!( + "{environment_variable} is required because this profile defines {available:?}" + )), + } +} + +fn load_scenarios() -> Result, String> { + SCENARIOS + .iter() + .map(|json| serde_json::from_str(json).map_err(|error| error.to_string())) + .collect() +} + +fn scenario_ids_on_disk() -> Result, String> { + let mut directories = vec![PathBuf::from(SCENARIO_DIRECTORY)]; + let mut documents = Vec::new(); + while let Some(directory) = directories.pop() { + let entries = fs::read_dir(&directory) + .map_err(|error| format!("failed to read '{}': {error}", directory.display()))?; + for entry in entries { + let path = entry.map_err(|error| error.to_string())?.path(); + if path.is_dir() { + directories.push(path); + } else if path + .extension() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) + { + documents.push(path); + } + } + } + documents.sort(); + + let mut ids = BTreeSet::new(); + for path in documents { + let json = fs::read_to_string(&path) + .map_err(|error| format!("failed to read '{}': {error}", path.display()))?; + let document: Value = serde_json::from_str(&json) + .map_err(|error| format!("invalid scenario '{}': {error}", path.display()))?; + let id = document + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| format!("scenario '{}' has no string id", path.display()))?; + if !ids.insert(id.to_owned()) { + return Err(format!("duplicate scenario id '{id}' on disk")); + } + } + Ok(ids) +} + +fn load_profiles() -> Result, String> { + PROFILES + .iter() + .map(|json| serde_json::from_str(json).map_err(|error| error.to_string())) + .collect() +} + +pub fn selected_profile_for(scenario_id: &str) -> Result, String> { + let selected = + std::env::var("AZURE_COSMOS_E2E_PROFILE").unwrap_or_else(|_| DEFAULT_PROFILE.to_owned()); + let scenarios = load_scenarios()?; + let scenario = scenarios + .iter() + .find(|scenario| scenario.id == scenario_id) + .ok_or_else(|| format!("E2E scenario '{scenario_id}' does not exist"))?; + let profiles = load_profiles()?; + if !profiles.iter().any(|profile| profile.id == selected) { + return Err(format!("E2E profile '{selected}' does not exist")); + } + if !scenario.profiles.contains(&selected) { + return Ok(None); + } + Ok(profiles.into_iter().find(|profile| profile.id == selected)) +} + +pub(super) fn required_capabilities_for( + scenario_id: &str, + backend: &str, +) -> Result, String> { + let scenarios = load_scenarios()?; + let scenario = scenarios + .iter() + .find(|scenario| scenario.id == scenario_id) + .ok_or_else(|| format!("E2E scenario '{scenario_id}' does not exist"))?; + Ok(scenario + .backends + .get(backend) + .ok_or_else(|| format!("E2E scenario '{scenario_id}' has no backend '{backend}'"))? + .requires + .clone()) +} + +pub fn validate_catalog(implemented_tests: &[&str]) -> Result<(), String> { + // Full JSON Schema evaluation is owned by Test-CosmosE2eScenarioDocuments in + // Invoke-CosmosTestSetup.ps1. Keep these repository-native semantic checks aligned with + // schema constraints that they intentionally duplicate. + for (name, schema) in [("scenario", SCENARIO_SCHEMA), ("profile", PROFILE_SCHEMA)] { + let schema: Value = serde_json::from_str(schema) + .map_err(|error| format!("{name} schema is not JSON: {error}"))?; + if schema.get("$schema").and_then(Value::as_str) + != Some("https://json-schema.org/draft/2020-12/schema") + { + return Err(format!("{name} schema must use JSON Schema draft 2020-12")); + } + } + + let profiles = load_profiles()?; + let mut profile_ids = BTreeSet::new(); + for profile in &profiles { + if profile.schema != PROFILE_SCHEMA_REFERENCE + || profile.spec_version != "1.0" + || !valid_camel_id(&profile.id) + || !profile_ids.insert(profile.id.as_str()) + { + return Err(format!("invalid or duplicate profile '{}'", profile.id)); + } + for (axis, ids) in [ + ( + "account", + profile + .accounts + .iter() + .map(|definition| definition.id.as_str()) + .collect::>(), + ), + ( + "runtime", + profile + .runtimes + .iter() + .map(|definition| definition.id.as_str()) + .collect::>(), + ), + ( + "client", + profile + .clients + .iter() + .map(|definition| definition.id.as_str()) + .collect::>(), + ), + ] { + let unique: BTreeSet<_> = ids.iter().copied().collect(); + if ids.is_empty() || unique.len() != ids.len() { + return Err(format!( + "profile '{}' has an empty or duplicate {axis} axis", + profile.id + )); + } + } + for account in &profile.accounts { + if account.regions.is_empty() + || account.regions.iter().any(|region| region.name.is_empty()) + || account.replication.min_delay_ms > account.replication.max_delay_ms + || !matches!(account.write_mode.as_str(), "single" | "multi") + || !matches!( + account.consistency.as_str(), + "strong" | "boundedStaleness" | "session" | "consistentPrefix" | "eventual" + ) + { + return Err(format!( + "profile '{}' account '{}' has invalid regions or replication delay", + profile.id, account.id + )); + } + } + if profile.runtimes.iter().any(|runtime| { + !matches!( + runtime.gateway_v2.as_str(), + "enabled" | "disabled" | "backendDefault" + ) || !matches!(runtime.ppcb.as_str(), "enabled" | "disabled" | "sdkDefault") + || !valid_optional_read_strategy( + runtime.default_read_consistency_strategy.as_deref(), + ) + }) || profile.clients.iter().any(|client| { + !matches!( + client.binary_encoding.as_str(), + "enabled" | "disabled" | "sdkDefault" + ) || !matches!( + client.routing.as_str(), + "proximity" | "preferredRegions" | "accountOrder" + ) || !valid_optional_read_strategy(client.default_read_consistency_strategy.as_deref()) + }) { + return Err(format!( + "profile '{}' has invalid setup options", + profile.id + )); + } + } + validate_pipeline_matrix( + CONSISTENCY_MATRIX, + profiles + .iter() + .find(|profile| profile.id == "lifecycleConsistencyMatrix") + .expect("consistency profile must be registered"), + )?; + validate_pipeline_matrix( + OVERRIDE_MATRIX, + profiles + .iter() + .find(|profile| profile.id == "readConsistencyOverrideMatrix") + .expect("override profile must be registered"), + )?; + + let scenarios = load_scenarios()?; + let mut scenario_ids = BTreeSet::new(); + for scenario in &scenarios { + if scenario.schema != SCENARIO_SCHEMA_REFERENCE + || scenario.spec_version != "1.0" + || !valid_scenario_id(&scenario.id) + { + return Err(format!( + "scenario '{}' has an unsupported version", + scenario.id + )); + } + if !scenario_ids.insert(scenario.id.as_str()) { + return Err(format!("duplicate scenario id '{}'", scenario.id)); + } + if scenario.title.is_empty() + || scenario.requirement.is_empty() + || scenario.precedents.is_empty() + || scenario.profiles.is_empty() + || scenario.tags.is_empty() + || scenario + .precedents + .iter() + .any(|precedent| precedent.path.is_empty() || precedent.test.is_empty()) + { + return Err(format!( + "scenario '{}' is missing required metadata", + scenario.id + )); + } + let selected_profiles: BTreeSet<_> = scenario.profiles.iter().map(String::as_str).collect(); + let tags: BTreeSet<_> = scenario.tags.iter().map(String::as_str).collect(); + if selected_profiles.len() != scenario.profiles.len() + || !selected_profiles.is_subset(&profile_ids) + || tags.len() != scenario.tags.len() + { + return Err(format!( + "scenario '{}' references an unknown or duplicate profile", + scenario.id + )); + } + let backend_names: BTreeSet<_> = scenario.backends.keys().map(String::as_str).collect(); + if backend_names != BACKENDS.into_iter().collect() { + return Err(format!( + "scenario '{}' has incomplete backend applicability", + scenario.id + )); + } + for (backend_name, backend) in &scenario.backends { + if backend.applicability == Applicability::NotApplicable && backend.reason.is_none() { + return Err(format!( + "scenario '{}' must explain why '{backend_name}' is not applicable", + scenario.id + )); + } + let requirements: BTreeSet<_> = backend.requires.iter().collect(); + if requirements.len() != backend.requires.len() { + return Err(format!( + "scenario '{}' has duplicate requirements for '{backend_name}'", + scenario.id + )); + } + } + } + let registered_scenario_ids: BTreeSet<_> = + scenario_ids.iter().map(|id| (*id).to_owned()).collect(); + let discovered_scenario_ids = scenario_ids_on_disk()?; + if registered_scenario_ids != discovered_scenario_ids { + let unregistered: Vec<_> = discovered_scenario_ids + .difference(®istered_scenario_ids) + .cloned() + .collect(); + let missing: Vec<_> = registered_scenario_ids + .difference(&discovered_scenario_ids) + .cloned() + .collect(); + return Err(format!( + "scenario inventory differs from e2e_tests/scenarios; unregistered: {unregistered:?}, missing: {missing:?}" + )); + } + let referenced_profiles: BTreeSet<_> = scenarios + .iter() + .flat_map(|scenario| scenario.profiles.iter().map(String::as_str)) + .collect(); + if let Ok(selected_profile) = std::env::var("AZURE_COSMOS_E2E_PROFILE") { + if !profile_ids.contains(selected_profile.as_str()) { + return Err(format!( + "selected E2E profile '{selected_profile}' does not exist" + )); + } + if !referenced_profiles.contains(selected_profile.as_str()) { + return Err(format!( + "selected E2E profile '{selected_profile}' has no active scenarios" + )); + } + let selected_scenarios: Vec<_> = scenarios + .iter() + .filter(|scenario| scenario.profiles.contains(&selected_profile)) + .map(|scenario| scenario.id.as_str()) + .collect(); + eprintln!("E2E profile '{selected_profile}' selects scenarios: {selected_scenarios:?}"); + } + + let implementations: ImplementationMap = + serde_json::from_str(RUST_IMPLEMENTATIONS).map_err(|error| error.to_string())?; + if implementations.spec_version != "1.0" + || implementations.sdk != "rust" + || implementations.test_target != "e2e_tests" + { + return Err("invalid Rust implementation map header".to_owned()); + } + let known_tests: BTreeSet<_> = implemented_tests.iter().copied().collect(); + let mut mapped_ids = BTreeSet::new(); + for implementation in &implementations.scenarios { + if !scenario_ids.contains(implementation.id.as_str()) { + return Err(format!( + "implementation references unknown scenario '{}'", + implementation.id + )); + } + if !mapped_ids.insert(implementation.id.as_str()) { + return Err(format!( + "scenario '{}' is mapped more than once", + implementation.id + )); + } + if implementation.status == ImplementationStatus::Active + && !known_tests.contains(implementation.test.as_str()) + { + return Err(format!( + "scenario '{}' references missing test '{}'", + implementation.id, implementation.test + )); + } + } + if mapped_ids != scenario_ids { + return Err("every scenario must have exactly one Rust implementation mapping".to_owned()); + } + Ok(()) +} + +fn valid_optional_read_strategy(strategy: Option<&str>) -> bool { + strategy.is_none_or(|strategy| { + matches!( + strategy, + "Default" | "Eventual" | "Session" | "LatestCommitted" | "GlobalStrong" + ) + }) +} + +fn validate_pipeline_matrix(json: &str, profile: &Profile) -> Result<(), String> { + let document: Value = serde_json::from_str(json).map_err(|error| error.to_string())?; + let matrix = document + .get("matrix") + .and_then(Value::as_object) + .ok_or("pipeline matrix must contain an object named 'matrix'")?; + let actual_profiles = matrix_axis(matrix, "AZURE_COSMOS_E2E_PROFILE")?; + if actual_profiles != BTreeSet::from([profile.id.as_str()]) { + return Err(format!( + "pipeline matrix must select only profile '{}'", + profile.id + )); + } + for (axis, actual, expected) in [ + ( + "account", + matrix_axis(matrix, "AZURE_COSMOS_E2E_ACCOUNT")?, + profile + .accounts + .iter() + .map(|definition| definition.id.as_str()) + .collect(), + ), + ( + "runtime", + matrix_axis(matrix, "AZURE_COSMOS_E2E_RUNTIME")?, + profile + .runtimes + .iter() + .map(|definition| definition.id.as_str()) + .collect(), + ), + ( + "client", + matrix_axis(matrix, "AZURE_COSMOS_E2E_CLIENT")?, + profile + .clients + .iter() + .map(|definition| definition.id.as_str()) + .collect(), + ), + ] { + if actual != expected { + return Err(format!( + "pipeline matrix for '{}' does not cover its {axis} axis: expected {expected:?}, got {actual:?}", + profile.id + )); + } + } + let flavors = matrix_axis(matrix, "AZURE_COSMOS_EMULATOR_FLAVOR")?; + if flavors != BTreeSet::from(["inmemory-v1", "inmemory-v2"]) { + return Err(format!( + "pipeline matrix for '{}' must cover Gateway V1 and Gateway V2", + profile.id + )); + } + Ok(()) +} + +fn matrix_axis<'a>( + matrix: &'a serde_json::Map, + name: &str, +) -> Result, String> { + matrix + .get(name) + .and_then(Value::as_array) + .ok_or_else(|| format!("pipeline matrix is missing '{name}'"))? + .iter() + .map(|value| { + value + .as_str() + .ok_or_else(|| format!("pipeline matrix axis '{name}' must contain strings")) + }) + .collect() +} + +fn valid_camel_id(value: &str) -> bool { + value + .chars() + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && value + .chars() + .all(|character| character.is_ascii_alphanumeric()) +} + +fn valid_scenario_id(value: &str) -> bool { + let mut segments = value.split('.'); + let first = segments.next(); + let rest: Vec<_> = segments.collect(); + first.is_some_and(valid_first_scenario_segment) + && !rest.is_empty() + && rest.into_iter().all(valid_slug) +} + +fn valid_first_scenario_segment(value: &str) -> bool { + value + .chars() + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && value + .chars() + .all(|character| character.is_ascii_lowercase() || character.is_ascii_digit()) +} + +fn valid_slug(value: &str) -> bool { + value + .chars() + .next() + .is_some_and(|first| first.is_ascii_lowercase()) + && value.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) +} + +#[cfg(test)] +mod tests { + use super::valid_scenario_id; + + #[test] + fn scenario_id_matches_schema_segment_rules() { + assert!(valid_scenario_id("changefeed.all-versions")); + assert!(!valid_scenario_id("change-feed.all-versions")); + assert!(!valid_scenario_id("changefeed.")); + } +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs new file mode 100644 index 0000000000..654ee22f4d --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/diagnostics_success_and_error.rs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::StatusCode; +use azure_data_cosmos::options::Region; + +use crate::e2e_test_cases::{ + fixture::{E2eTest, TestResult}, + support::{assert_critical_diagnostics, item, should_run}, +}; + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn diagnostics_cover_success_and_error() -> TestResult { + if !should_run("diagnostics.success-and-error").await? { + return Ok(()); + } + E2eTest::builder() + .run(async |fixture| { + // Arrange one readable item in East US. + fixture + .container + .create_item("A", "item-1", item("item-1", "A", 1), None) + .await?; + + // Success diagnostics identify the operation, 200 status, activity, request, and region. + let success = fixture.container.read_item("A", "item-1", None).await?; + assert_eq!(success.status().status_code(), StatusCode::Ok); + assert_critical_diagnostics(&success.diagnostics(), "read_item", StatusCode::Ok); + assert!(success + .diagnostics() + .regions_contacted() + .contains(&Region::EAST_US)); + + // Error diagnostics preserve the same fields while reporting the terminal plain 404. + let error = fixture + .container + .read_item("A", "missing", None) + .await + .expect_err("missing read must fail"); + assert_eq!(error.status().status_code(), StatusCode::NotFound); + assert_eq!( + error + .status() + .sub_status() + .map(|value| value.value()) + .unwrap_or(0), + 0 + ); + let diagnostics = error + .diagnostics() + .expect("service error must carry diagnostics"); + assert_critical_diagnostics(&diagnostics, "read_item", StatusCode::NotFound); + assert!(diagnostics.regions_contacted().contains(&Region::EAST_US)); + Ok(()) + }) + .await +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs new file mode 100644 index 0000000000..0fbf0a2eac --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/fixture.rs @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use std::panic::AssertUnwindSafe; + +use azure_core::Uuid; +use azure_data_cosmos::{ + clients::ContainerClient, + models::{ContainerProperties, PartitionKeyDefinition}, + options::{ + BinaryEncodingOptions, ConnectionPoolOptions, OperationOptions, PartitionFailoverOptions, + ReadConsistencyStrategy, Region, + }, + AccountEndpoint, AccountReference, CosmosClient, CosmosRuntime, RoutingStrategy, +}; +use futures::FutureExt; + +use crate::e2e_test_cases::catalog::{ClientDefinition, RuntimeDefinition}; + +pub type TestResult = Result>; + +pub struct E2eTestFixture { + cleanup: DatabaseCleanup, + pub container: ContainerClient, +} + +pub struct E2eTest; + +pub struct E2eTestBuilder { + client: Option, + partition_key: PartitionKeyDefinition, +} + +pub struct ClientSetup { + pub routing_strategy: RoutingStrategy, + pub runtime_read_consistency: Option, + pub client_read_consistency: Option, + pub gateway_v2_enabled: Option, + pub ppcb_enabled: Option, + pub binary_encoding_enabled: Option, +} + +impl ClientSetup { + pub fn from_profile( + runtime: &RuntimeDefinition, + client: &ClientDefinition, + routing_strategy: RoutingStrategy, + ) -> TestResult { + Ok(Self { + routing_strategy, + runtime_read_consistency: parse_optional_read_consistency( + runtime.default_read_consistency_strategy.as_deref(), + )?, + client_read_consistency: parse_optional_read_consistency( + client.default_read_consistency_strategy.as_deref(), + )?, + gateway_v2_enabled: parse_setup_switch(&runtime.gateway_v2, "backendDefault")?, + ppcb_enabled: parse_setup_switch(&runtime.ppcb, "sdkDefault")?, + binary_encoding_enabled: parse_setup_switch(&client.binary_encoding, "sdkDefault")?, + }) + } +} + +fn parse_optional_read_consistency( + value: Option<&str>, +) -> TestResult> { + value + .map(|value| value.parse::().map_err(Into::into)) + .transpose() +} + +fn parse_setup_switch(value: &str, default: &str) -> TestResult> { + match value { + "enabled" => Ok(Some(true)), + "disabled" => Ok(Some(false)), + value if value == default => Ok(None), + value => Err(format!("unsupported setup switch '{value}'").into()), + } +} + +struct DatabaseCleanup { + client: CosmosClient, + database_id: String, +} + +impl E2eTest { + pub fn builder() -> E2eTestBuilder { + E2eTestBuilder { + client: None, + partition_key: "/pk".into(), + } + } +} + +impl E2eTestBuilder { + pub fn with_client(mut self, client: CosmosClient) -> Self { + self.client = Some(client); + self + } + + pub fn with_partition_key_definition(mut self, partition_key: PartitionKeyDefinition) -> Self { + self.partition_key = partition_key; + self + } + + pub async fn run(self, test: F) -> TestResult + where + F: AsyncFnOnce(&E2eTestFixture) -> TestResult, + { + let client = match self.client { + Some(client) => client, + None => build_client().await?, + }; + E2eTestFixture::run(client, self.partition_key, test).await + } +} + +impl E2eTestFixture { + async fn run( + client: CosmosClient, + partition_key: PartitionKeyDefinition, + test: F, + ) -> TestResult + where + F: AsyncFnOnce(&E2eTestFixture) -> TestResult, + { + let fixture = Self::new(client, partition_key).await?; + let outcome = AssertUnwindSafe(test(&fixture)).catch_unwind().await; + let cleanup = fixture.cleanup().await; + match outcome { + Ok(Ok(())) => cleanup, + Ok(Err(test_error)) => match cleanup { + Ok(()) => Err(test_error), + Err(cleanup_error) => Err(format!( + "E2E test failed: {test_error}; database cleanup also failed: {cleanup_error}" + ) + .into()), + }, + Err(panic) => { + if let Err(error) = cleanup { + eprintln!("E2E database cleanup after panic failed: {error}"); + } + std::panic::resume_unwind(panic) + } + } + } + + async fn new(client: CosmosClient, partition_key: PartitionKeyDefinition) -> TestResult { + // Preserve creation time in leaked resource IDs so cleanup tooling can age them out. + let database_id = format!("e2e-{}", Uuid::now_v7()); + let container_id = format!("items-{}", Uuid::now_v7()); + client.create_database(&database_id, None).await?; + let database = client.database_client(&database_id); + let cleanup = DatabaseCleanup::new(client.clone(), database_id); + let setup = async { + database + .create_container( + ContainerProperties::new(container_id.clone(), partition_key), + None, + ) + .await?; + database.container_client(&container_id, None).await + } + .await; + match setup { + Ok(container) => Ok(Self { cleanup, container }), + Err(setup_error) => match cleanup.cleanup().await { + Ok(()) => Err(setup_error.into()), + Err(cleanup_error) => Err(format!( + "E2E fixture setup failed: {setup_error}; database cleanup also failed: {cleanup_error}" + ) + .into()), + }, + } + } + + async fn cleanup(self) -> TestResult { + self.cleanup.cleanup().await + } +} + +impl DatabaseCleanup { + fn new(client: CosmosClient, database_id: String) -> Self { + Self { + client, + database_id, + } + } + + async fn cleanup(self) -> TestResult { + self.client + .database_client(&self.database_id) + .delete(None) + .await?; + Ok(()) + } +} + +pub async fn build_client() -> TestResult { + build_client_with_routing(RoutingStrategy::ProximityTo(Region::EAST_US)).await +} + +pub async fn build_client_with_routing( + routing_strategy: RoutingStrategy, +) -> TestResult { + build_client_with_defaults(ClientSetup { + routing_strategy, + runtime_read_consistency: None, + client_read_consistency: None, + gateway_v2_enabled: None, + ppcb_enabled: None, + binary_encoding_enabled: None, + }) + .await +} + +pub async fn build_client_with_defaults(setup: ClientSetup) -> TestResult { + let connection_string = std::env::var("AZURE_COSMOS_CONNECTION_STRING")?; + let endpoint = connection_string_value(&connection_string, "AccountEndpoint")?; + let key = connection_string_value(&connection_string, "AccountKey")?; + let endpoint: AccountEndpoint = endpoint.parse()?; + let mut runtime_builder = CosmosRuntime::builder(); + if let Some(enabled) = setup.gateway_v2_enabled { + let options = ConnectionPoolOptions::builder() + .with_gateway_v2_disabled(!enabled) + .build()?; + runtime_builder = runtime_builder.with_connection_pool(options); + } + if let Some(strategy) = setup.runtime_read_consistency { + let mut options = OperationOptions::default(); + options.read_consistency_strategy = Some(strategy); + runtime_builder = runtime_builder.with_default_operation_options(options); + } + let runtime = runtime_builder.build().await?; + + let mut client_builder = CosmosClient::builder().with_runtime(runtime); + if let Some(strategy) = setup.client_read_consistency { + let mut options = OperationOptions::default(); + options.read_consistency_strategy = Some(strategy); + client_builder = client_builder.with_default_operation_options(options); + } + if let Some(enabled) = setup.ppcb_enabled { + let options = PartitionFailoverOptions::builder() + .with_circuit_breaker_enabled(enabled) + .build()?; + client_builder = client_builder.with_partition_failover_options(options); + } + if let Some(enabled) = setup.binary_encoding_enabled { + client_builder = client_builder + .with_binary_encoding_options(BinaryEncodingOptions::new().with_enabled(enabled)); + } + Ok(client_builder + .build( + AccountReference::with_authentication_key(endpoint, key), + setup.routing_strategy, + ) + .await?) +} + +fn connection_string_value(connection_string: &str, key: &str) -> TestResult { + connection_string + .split(';') + .filter_map(|part| part.split_once('=')) + .find_map(|(name, value)| name.eq_ignore_ascii_case(key).then_some(value.to_owned())) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("connection string is missing {key}").into()) +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs new file mode 100644 index 0000000000..62914d712b --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_create_conflict.rs @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::StatusCode; +use azure_data_cosmos::{ + models::{PartitionKeyDefinition, PartitionKeyKind, PartitionKeyValue, PartitionKeyVersion}, + PartitionKey, +}; +use serde_json::{json, Value}; + +use crate::e2e_test_cases::{ + fixture::{E2eTest, TestResult}, + support::{assert_critical_diagnostics, should_run}, +}; + +// Run the same conflict contract for Hash V1, Hash V2, and hierarchical V2 partition keys. +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn duplicate_create_preserves_original() -> TestResult { + if !should_run("item.create-conflict").await? { + return Ok(()); + } + for case in duplicate_create_cases() { + let document_id = case + .original + .get("id") + .and_then(Value::as_str) + .expect("original document must have an id"); + assert_eq!( + case.duplicate.get("id").and_then(Value::as_str), + Some(document_id) + ); + + E2eTest::builder() + .with_partition_key_definition(case.partition_key_definition) + .run(async |fixture| { + // Arrange the original value 1 document. + fixture + .container + .create_item( + case.partition_key.clone(), + document_id, + &case.original, + None, + ) + .await?; + + // Creating value 2 with the same ID and partition key returns conflict. + let error = fixture + .container + .create_item( + case.partition_key.clone(), + document_id, + &case.duplicate, + None, + ) + .await + .expect_err("duplicate create must fail"); + assert_eq!(error.status().status_code(), StatusCode::Conflict); + assert_eq!( + error + .status() + .sub_status() + .map(|value| value.value()) + .unwrap_or(0), + 0 + ); + assert_critical_diagnostics( + &error + .diagnostics() + .expect("service error must carry diagnostics"), + "create_item", + StatusCode::Conflict, + ); + + // The failed duplicate create leaves every original field unchanged. + let stored: Value = fixture + .container + .read_item(case.partition_key.clone(), document_id, None) + .await? + .into_model()?; + for (name, expected) in case + .original + .as_object() + .expect("original document must be an object") + { + assert_eq!( + stored.get(name), + Some(expected), + "fixture '{}' field '{name}' changed after duplicate create", + case.id + ); + } + Ok(()) + }) + .await?; + } + Ok(()) +} + +// Partition-key cases --------------------------------------------------------- + +struct DuplicateCreateCase { + id: &'static str, + partition_key_definition: PartitionKeyDefinition, + partition_key: PartitionKey, + original: Value, + duplicate: Value, +} + +fn duplicate_create_cases() -> Vec { + let simple_hash = |id, version| DuplicateCreateCase { + id, + partition_key_definition: PartitionKeyDefinition::new(vec!["/pk".into()]) + .with_kind(PartitionKeyKind::Hash) + .with_version(version), + partition_key: PartitionKey::from("A"), + original: json!({ "id": "duplicate-1", "pk": "A", "value": 1 }), + duplicate: json!({ "id": "duplicate-1", "pk": "A", "value": 2 }), + }; + + vec![ + simple_hash("hashV1", PartitionKeyVersion::V1), + simple_hash("hashV2", PartitionKeyVersion::V2), + DuplicateCreateCase { + id: "hierarchicalV2", + partition_key_definition: PartitionKeyDefinition::new(vec![ + "/tenant".into(), + "/user".into(), + ]) + .with_kind(PartitionKeyKind::MultiHash) + .with_version(PartitionKeyVersion::V2), + partition_key: PartitionKey::from(vec![ + PartitionKeyValue::from("tenant-a"), + PartitionKeyValue::from("user-1"), + ]), + original: json!({ + "id": "duplicate-1", + "tenant": "tenant-a", + "user": "user-1", + "value": 1 + }), + duplicate: json!({ + "id": "duplicate-1", + "tenant": "tenant-a", + "user": "user-1", + "value": 2 + }), + }, + ] +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs new file mode 100644 index 0000000000..284a259d65 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_lifecycle.rs @@ -0,0 +1,941 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use std::time::Duration; + +use azure_core::http::StatusCode; +use azure_data_cosmos::{ + clients::ContainerClient, + options::{ + AvailabilityStrategy, ItemReadOptions, OperationOptions, ReadConsistencyStrategy, Region, + }, + RoutingStrategy, +}; + +use crate::e2e_test_cases::{ + catalog::{AccountDefinition, ClientDefinition, Profile, RuntimeDefinition}, + fixture::{build_client_with_defaults, ClientSetup, E2eTest, TestResult}, + support::{ + assert_critical_diagnostics, item, selected_scenario_profile, write_options_with_content, + Item, + }, +}; + +const REPLICATION_TIMEOUT: Duration = Duration::from_secs(5); +const RETRY_DELAY: Duration = Duration::from_millis(50); + +// The JSON profile selects the account, runtime, and client configuration. Rust then expands +// that setup into the operation-level cases below: +// +// * smokeTests: one account-default read; +// * lifecycleConsistencyMatrix: Default, Eventual, Session, LatestCommitted, and GlobalStrong; +// * readConsistencyOverrideMatrix: inherit defaults, restore account default, and Eventual. +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn crud_lifecycle() -> TestResult { + let Some(profile) = selected_scenario_profile("item.lifecycle").await? else { + return Ok(()); + }; + let setup = SelectedLifecycleSetup::from_profile(&profile)?; + run_selected_lifecycle_cases(&setup).await +} + +// This is the lifecycle contract. Keep the operation sequence and its assertions visible here; +// helpers below translate profiles, construct the varying read, and handle polling mechanics. +async fn run_lifecycle_case( + setup: &SelectedLifecycleSetup<'_>, + read_case: &PostCreateReadCase, +) -> TestResult { + let execution = setup.execution_name(read_case.name); + let client = setup.build_client().await?; + + E2eTest::builder() + .with_client(client) + .run(async |fixture| { + let item_id = format!("lifecycle-{}", execution.replace('/', "-")); + + // Create an item and capture the session token used by explicit Session reads. + let created = fixture + .container + .create_item("A", &item_id, item(&item_id, "A", 1), None) + .await?; + assert_eq!(created.status().status_code(), StatusCode::Created); + assert_critical_diagnostics(&created.diagnostics(), "create_item", StatusCode::Created); + let create_session_token = created + .headers() + .session_token() + .map(|token| token.as_str().to_owned()); + + // Read the created item using this case's operation-level consistency behavior. + let read_outcome = read_created_item( + &fixture.container, + &item_id, + create_session_token, + read_case, + &execution, + &setup.read_region, + ) + .await?; + match read_outcome { + PostCreateReadOutcome::Item(actual) => assert_eq!( + actual, + item(&item_id, "A", 1), + "read returned the wrong item for '{execution}'" + ), + PostCreateReadOutcome::RejectedBeforeTransport => assert_eq!( + read_case.expectation, + ReadExpectation::RejectedBeforeTransport, + "read was rejected unexpectedly for '{execution}'" + ), + } + + // Replace the item and verify the returned model. + let replaced = fixture + .container + .replace_item( + "A", + &item_id, + item(&item_id, "A", 2), + Some(write_options_with_content()), + ) + .await?; + assert_eq!(replaced.status().status_code(), StatusCode::Ok); + assert_critical_diagnostics(&replaced.diagnostics(), "replace_item", StatusCode::Ok); + assert_eq!(replaced.into_model::()?, item(&item_id, "A", 2)); + + // Delete the item, then use the delete session token to verify a plain 404/0. + let deleted = fixture.container.delete_item("A", &item_id, None).await?; + assert_eq!(deleted.status().status_code(), StatusCode::NoContent); + assert_critical_diagnostics( + &deleted.diagnostics(), + "delete_item", + StatusCode::NoContent, + ); + let delete_session_token = deleted + .headers() + .session_token() + .map(|token| token.as_str().to_owned()) + .ok_or("delete response must carry a session token")?; + assert_item_deleted( + &fixture.container, + &item_id, + delete_session_token, + &execution, + ) + .await?; + + Ok(()) + }) + .await +} + +async fn run_selected_lifecycle_cases(setup: &SelectedLifecycleSetup<'_>) -> TestResult { + match setup.profile.id.as_str() { + "smokeTests" => default_smoke_read(setup).await, + "lifecycleConsistencyMatrix" => { + default_strategy_uses_account_consistency(setup).await?; + eventual_strategy_allows_replication_lag(setup).await?; + session_strategy_uses_create_token(setup).await?; + latest_committed_is_region_local(setup).await?; + global_strong_requires_strong_account(setup).await + } + "readConsistencyOverrideMatrix" => { + inherited_defaults_follow_precedence(setup).await?; + default_override_restores_account_consistency(setup).await?; + eventual_override_wins_over_defaults(setup).await + } + profile => Err(format!("item.lifecycle does not implement profile '{profile}'").into()), + } +} + +async fn default_smoke_read(setup: &SelectedLifecycleSetup<'_>) -> TestResult { + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "default_strategy_reads_created_item", + Some(ReadConsistencyStrategy::Default), + SessionTokenBehavior::SdkManaged, + ReadExpectation::SucceedsImmediately, + ), + ) + .await +} + +async fn default_strategy_uses_account_consistency( + setup: &SelectedLifecycleSetup<'_>, +) -> TestResult { + let (session_token, expectation) = match setup.account.consistency.as_str() { + "strong" => ( + SessionTokenBehavior::Omitted, + ReadExpectation::SucceedsImmediately, + ), + "session" => ( + SessionTokenBehavior::SdkManaged, + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable), + ), + _ => ( + SessionTokenBehavior::Omitted, + eventually_succeeds_after(TransientReadStatus::PlainNotFound), + ), + }; + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "default_strategy_uses_account_consistency", + Some(ReadConsistencyStrategy::Default), + session_token, + expectation, + ), + ) + .await +} + +async fn eventual_strategy_allows_replication_lag( + setup: &SelectedLifecycleSetup<'_>, +) -> TestResult { + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "eventual_strategy_allows_replication_lag", + Some(ReadConsistencyStrategy::Eventual), + SessionTokenBehavior::Omitted, + regional_read_expectation(setup.account), + ), + ) + .await +} + +async fn session_strategy_uses_create_token(setup: &SelectedLifecycleSetup<'_>) -> TestResult { + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "session_strategy_uses_create_token", + Some(ReadConsistencyStrategy::Session), + SessionTokenBehavior::ExplicitCreateResponse, + session_read_expectation(setup.account), + ), + ) + .await +} + +async fn latest_committed_is_region_local(setup: &SelectedLifecycleSetup<'_>) -> TestResult { + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "latest_committed_is_region_local", + Some(ReadConsistencyStrategy::LatestCommitted), + SessionTokenBehavior::Omitted, + regional_read_expectation(setup.account), + ), + ) + .await +} + +async fn global_strong_requires_strong_account(setup: &SelectedLifecycleSetup<'_>) -> TestResult { + let expectation = if setup.account.consistency == "strong" { + ReadExpectation::SucceedsImmediately + } else { + ReadExpectation::RejectedBeforeTransport + }; + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "global_strong_requires_strong_account", + Some(ReadConsistencyStrategy::GlobalStrong), + SessionTokenBehavior::Omitted, + expectation, + ), + ) + .await +} + +async fn inherited_defaults_follow_precedence(setup: &SelectedLifecycleSetup<'_>) -> TestResult { + require_session_account(setup.account)?; + let inherited_strategy = setup + .client + .default_read_consistency_strategy + .as_deref() + .or(setup.runtime.default_read_consistency_strategy.as_deref()); + let inherited_uses_session = inherited_strategy + .map(parse_read_consistency) + .transpose()? + .is_none_or(|strategy| strategy == ReadConsistencyStrategy::Session); + let (session_token, expectation) = if inherited_uses_session { + ( + SessionTokenBehavior::SdkManaged, + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable), + ) + } else { + ( + SessionTokenBehavior::Omitted, + eventually_succeeds_after(TransientReadStatus::PlainNotFound), + ) + }; + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "inherits_client_then_runtime_then_account_default", + None, + session_token, + expectation, + ), + ) + .await +} + +async fn default_override_restores_account_consistency( + setup: &SelectedLifecycleSetup<'_>, +) -> TestResult { + require_session_account(setup.account)?; + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "default_override_restores_account_consistency", + Some(ReadConsistencyStrategy::Default), + SessionTokenBehavior::ExplicitCreateResponse, + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable), + ), + ) + .await +} + +async fn eventual_override_wins_over_defaults(setup: &SelectedLifecycleSetup<'_>) -> TestResult { + require_session_account(setup.account)?; + run_lifecycle_case( + setup, + &PostCreateReadCase::new( + "eventual_override_wins_over_all_defaults", + Some(ReadConsistencyStrategy::Eventual), + SessionTokenBehavior::Omitted, + eventually_succeeds_after(TransientReadStatus::PlainNotFound), + ), + ) + .await +} + +fn regional_read_expectation(account: &AccountDefinition) -> ReadExpectation { + if account.consistency == "strong" { + ReadExpectation::SucceedsImmediately + } else { + eventually_succeeds_after(TransientReadStatus::PlainNotFound) + } +} + +fn session_read_expectation(account: &AccountDefinition) -> ReadExpectation { + if account.consistency == "strong" { + ReadExpectation::SucceedsImmediately + } else { + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable) + } +} + +fn require_session_account(account: &AccountDefinition) -> TestResult { + if account.consistency == "session" { + Ok(()) + } else { + Err(format!( + "readConsistencyOverrideMatrix requires a Session account, got '{}'", + account.consistency + ) + .into()) + } +} + +// Operation cases ------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SessionTokenBehavior { + SdkManaged, + ExplicitCreateResponse, + Omitted, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ReadExpectation { + SucceedsImmediately, + EventuallySucceeds { + allowed_transient_statuses: &'static [TransientReadStatus], + }, + RejectedBeforeTransport, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TransientReadStatus { + PlainNotFound, + SessionNotAvailable, +} + +impl TransientReadStatus { + const fn http_status(self) -> ExpectedHttpStatus { + match self { + Self::PlainNotFound => PLAIN_NOT_FOUND, + Self::SessionNotAvailable => SESSION_NOT_AVAILABLE, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +enum PostCreateReadOutcome { + Item(Item), + RejectedBeforeTransport, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct PostCreateReadCase { + name: &'static str, + consistency_override: Option, + session_token: SessionTokenBehavior, + expectation: ReadExpectation, +} + +impl PostCreateReadCase { + const fn new( + name: &'static str, + consistency_override: Option, + session_token: SessionTokenBehavior, + expectation: ReadExpectation, + ) -> Self { + Self { + name, + consistency_override, + session_token, + expectation, + } + } +} + +fn eventually_succeeds_after(status: TransientReadStatus) -> ReadExpectation { + ReadExpectation::EventuallySucceeds { + allowed_transient_statuses: match status { + TransientReadStatus::PlainNotFound => &[TransientReadStatus::PlainNotFound], + TransientReadStatus::SessionNotAvailable => &[TransientReadStatus::SessionNotAvailable], + }, + } +} + +// Selected JSON setup --------------------------------------------------------- + +struct SelectedLifecycleSetup<'a> { + profile: &'a Profile, + account: &'a AccountDefinition, + runtime: &'a RuntimeDefinition, + client: &'a ClientDefinition, + read_region: Region, + routing: RoutingStrategy, +} + +impl<'a> SelectedLifecycleSetup<'a> { + fn from_profile(profile: &'a Profile) -> TestResult { + let account = profile.selected_account()?; + let runtime = profile.selected_runtime()?; + let client = profile.selected_client()?; + let read_region = lifecycle_read_region(profile)?; + let routing = lifecycle_routing(client, &read_region)?; + + Ok(Self { + profile, + account, + runtime, + client, + read_region, + routing, + }) + } + + async fn build_client(&self) -> TestResult { + build_client_with_defaults(ClientSetup::from_profile( + self.runtime, + self.client, + self.routing.clone(), + )?) + .await + } + + fn execution_name(&self, case_name: &str) -> String { + format!( + "{}/{}/{}/{}/{}", + self.profile.id, self.account.id, self.runtime.id, self.client.id, case_name + ) + } +} + +// Retry mechanics ------------------------------------------------------------- + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ExpectedSubstatus { + Any, + ZeroOrMissing, + Exact(u16), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ExpectedHttpStatus { + status_code: StatusCode, + substatus: ExpectedSubstatus, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ActualHttpStatus { + status_code: StatusCode, + substatus: Option, +} + +impl ExpectedHttpStatus { + fn matches(self, status_code: StatusCode, substatus: Option) -> bool { + self.status_code == status_code + && match self.substatus { + ExpectedSubstatus::Any => true, + ExpectedSubstatus::ZeroOrMissing => { + substatus.is_none() || matches!(substatus, Some(0)) + } + ExpectedSubstatus::Exact(expected) => { + matches!(substatus, Some(actual) if actual == expected) + } + } + } +} + +const PLAIN_NOT_FOUND: ExpectedHttpStatus = ExpectedHttpStatus { + status_code: StatusCode::NotFound, + substatus: ExpectedSubstatus::ZeroOrMissing, +}; +const SESSION_NOT_AVAILABLE: ExpectedHttpStatus = ExpectedHttpStatus { + status_code: StatusCode::NotFound, + substatus: ExpectedSubstatus::Exact(1002), +}; +const READ_SUCCEEDED: ExpectedHttpStatus = ExpectedHttpStatus { + status_code: StatusCode::Ok, + substatus: ExpectedSubstatus::Any, +}; +const CLIENT_REJECTED: ExpectedHttpStatus = ExpectedHttpStatus { + status_code: StatusCode::BadRequest, + substatus: ExpectedSubstatus::Any, +}; + +async fn read_created_item( + container: &ContainerClient, + item_id: &str, + create_session_token: Option, + read_case: &PostCreateReadCase, + execution: &str, + expected_region: &Region, +) -> TestResult { + let mut operation = OperationOptions::default(); + operation.read_consistency_strategy = read_case.consistency_override; + operation.availability_strategy = Some(AvailabilityStrategy::Disabled); + let mut options = ItemReadOptions::default().with_operation_options(operation); + if read_case.session_token == SessionTokenBehavior::ExplicitCreateResponse { + options = options.with_session_token( + create_session_token.ok_or("create response must carry a session token")?, + ); + } + + let deadline = tokio::time::Instant::now() + read_case.expectation.timeout(); + let mut observed_statuses = Vec::new(); + loop { + match container + .read_item("A", item_id, Some(options.clone())) + .await + { + Ok(response) => { + let status = ActualHttpStatus { + status_code: response.status().status_code(), + substatus: response.status().sub_status().map(|value| value.value()), + }; + record_request_statuses(&response.diagnostics(), &mut observed_statuses); + observed_statuses.push(status); + verify_observed_statuses(read_case, execution, &observed_statuses)?; + assert_initial_read_region(&response.diagnostics(), expected_region, execution); + if read_case + .expectation + .terminal_status() + .matches(status.status_code, status.substatus) + { + verify_required_transient_observed(read_case, execution, &observed_statuses)?; + assert_critical_diagnostics( + &response.diagnostics(), + "read_item", + StatusCode::Ok, + ); + return Ok(PostCreateReadOutcome::Item(response.into_model::()?)); + } + verify_transient_status( + read_case, + status, + deadline, + execution, + &observed_statuses, + )?; + } + Err(error) => { + let status = ActualHttpStatus { + status_code: error.status().status_code(), + substatus: error.status().sub_status().map(|value| value.value()), + }; + if let Some(diagnostics) = error.diagnostics() { + record_request_statuses(&diagnostics, &mut observed_statuses); + if diagnostics.request_count() > 0 { + assert_initial_read_region(&diagnostics, expected_region, execution); + } + } + observed_statuses.push(status); + verify_observed_statuses(read_case, execution, &observed_statuses)?; + if read_case + .expectation + .terminal_status() + .matches(status.status_code, status.substatus) + { + verify_required_transient_observed(read_case, execution, &observed_statuses)?; + if read_case.expectation == ReadExpectation::RejectedBeforeTransport { + assert!( + error.response().is_none(), + "client validation for '{execution}' unexpectedly received a response" + ); + let diagnostics = error + .diagnostics() + .expect("client rejection must carry diagnostics"); + assert_eq!( + diagnostics.request_count(), + 0, + "client validation must reject '{execution}' before transport" + ); + } + return Ok(PostCreateReadOutcome::RejectedBeforeTransport); + } + verify_transient_status( + read_case, + status, + deadline, + execution, + &observed_statuses, + )?; + } + } + tokio::time::sleep(RETRY_DELAY).await; + } +} + +async fn assert_item_deleted( + container: &ContainerClient, + item_id: &str, + delete_session_token: String, + execution: &str, +) -> TestResult { + let mut operation = OperationOptions::default(); + operation.read_consistency_strategy = Some(ReadConsistencyStrategy::Session); + operation.availability_strategy = Some(AvailabilityStrategy::Disabled); + let options = ItemReadOptions::default() + .with_operation_options(operation) + .with_session_token(delete_session_token); + let deadline = tokio::time::Instant::now() + REPLICATION_TIMEOUT; + + loop { + let result = container + .read_item("A", item_id, Some(options.clone())) + .await; + let status = match &result { + Ok(response) => ActualHttpStatus { + status_code: response.status().status_code(), + substatus: response.status().sub_status().map(|value| value.value()), + }, + Err(error) => ActualHttpStatus { + status_code: error.status().status_code(), + substatus: error.status().sub_status().map(|value| value.value()), + }, + }; + match deleted_read_action(status, tokio::time::Instant::now() < deadline) { + DeletedReadAction::Deleted => return Ok(()), + DeletedReadAction::Retry => {} + DeletedReadAction::SessionViolation => { + return Err(format!( + "deleted item for '{execution}' was served despite its explicit Session token" + ) + .into()) + } + DeletedReadAction::TimedOut => { + return Err(format!( + "read for '{execution}' remained at 404/1002 after {REPLICATION_TIMEOUT:?}" + ) + .into()) + } + DeletedReadAction::Unexpected => match result { + Err(error) => return Err(error.into()), + Ok(_) => unreachable!("successful reads are session violations"), + }, + } + tokio::time::sleep(RETRY_DELAY).await; + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DeletedReadAction { + Deleted, + Retry, + SessionViolation, + TimedOut, + Unexpected, +} + +fn deleted_read_action(status: ActualHttpStatus, before_deadline: bool) -> DeletedReadAction { + if PLAIN_NOT_FOUND.matches(status.status_code, status.substatus) { + DeletedReadAction::Deleted + } else if SESSION_NOT_AVAILABLE.matches(status.status_code, status.substatus) { + if before_deadline { + DeletedReadAction::Retry + } else { + DeletedReadAction::TimedOut + } + } else if READ_SUCCEEDED.matches(status.status_code, status.substatus) { + DeletedReadAction::SessionViolation + } else { + DeletedReadAction::Unexpected + } +} + +impl ReadExpectation { + const fn terminal_status(self) -> ExpectedHttpStatus { + match self { + Self::SucceedsImmediately | Self::EventuallySucceeds { .. } => READ_SUCCEEDED, + Self::RejectedBeforeTransport => CLIENT_REJECTED, + } + } + + const fn allowed_transient_statuses(self) -> &'static [TransientReadStatus] { + match self { + Self::EventuallySucceeds { + allowed_transient_statuses, + } => allowed_transient_statuses, + Self::SucceedsImmediately | Self::RejectedBeforeTransport => &[], + } + } + + const fn timeout(self) -> Duration { + match self { + Self::EventuallySucceeds { .. } => REPLICATION_TIMEOUT, + Self::SucceedsImmediately | Self::RejectedBeforeTransport => Duration::ZERO, + } + } +} + +fn verify_transient_status( + read_case: &PostCreateReadCase, + actual: ActualHttpStatus, + deadline: tokio::time::Instant, + execution: &str, + observed_statuses: &[ActualHttpStatus], +) -> TestResult { + let allowed = read_case.expectation.allowed_transient_statuses(); + if !allowed.iter().any(|expected| { + expected + .http_status() + .matches(actual.status_code, actual.substatus) + }) { + return Err(format!( + "'{execution}' observed unexpected read status {actual:?}; expected transient {allowed:?} or terminal {:?}; observed {observed_statuses:?}", + read_case.expectation.terminal_status() + ) + .into()); + } + if tokio::time::Instant::now() >= deadline { + return Err(format!( + "'{execution}' did not reach terminal status {:?} within {:?}; observed {observed_statuses:?}", + read_case.expectation.terminal_status(), + read_case.expectation.timeout() + ) + .into()); + } + Ok(()) +} + +fn verify_observed_statuses( + read_case: &PostCreateReadCase, + execution: &str, + observed_statuses: &[ActualHttpStatus], +) -> TestResult { + let terminal = read_case.expectation.terminal_status(); + let allowed = read_case.expectation.allowed_transient_statuses(); + if let Some(unexpected) = observed_statuses.iter().find(|actual| { + !terminal.matches(actual.status_code, actual.substatus) + && !allowed.iter().any(|expected| { + expected + .http_status() + .matches(actual.status_code, actual.substatus) + }) + }) { + return Err(format!( + "'{execution}' observed unexpected internal read status {unexpected:?}; expected transient {allowed:?} or terminal {terminal:?}; observed {observed_statuses:?}" + ) + .into()); + } + Ok(()) +} + +fn verify_required_transient_observed( + read_case: &PostCreateReadCase, + execution: &str, + observed_statuses: &[ActualHttpStatus], +) -> TestResult { + let allowed = read_case.expectation.allowed_transient_statuses(); + if !allowed.is_empty() + && !observed_statuses.iter().any(|actual| { + allowed.iter().any(|expected| { + expected + .http_status() + .matches(actual.status_code, actual.substatus) + }) + }) + { + return Err(format!( + "'{execution}' reached terminal status without observing required transient {allowed:?}; observed {observed_statuses:?}" + ) + .into()); + } + Ok(()) +} + +fn assert_initial_read_region( + diagnostics: &azure_data_cosmos::diagnostics::DiagnosticsContext, + expected_region: &Region, + execution: &str, +) { + assert_eq!( + diagnostics + .requests() + .first() + .and_then(|request| request.region()), + Some(expected_region), + "'{execution}' must begin its read in the selected region" + ); +} + +fn record_request_statuses( + diagnostics: &azure_data_cosmos::diagnostics::DiagnosticsContext, + observed: &mut Vec, +) { + observed.extend( + diagnostics + .requests() + .iter() + .map(|request| ActualHttpStatus { + status_code: request.status().status_code(), + substatus: request.status().sub_status().map(|value| value.value()), + }), + ); +} + +// Profile value translation --------------------------------------------------- + +fn lifecycle_read_region(profile: &Profile) -> TestResult { + match profile.id.as_str() { + "smokeTests" => Ok(Region::EAST_US), + "lifecycleConsistencyMatrix" | "readConsistencyOverrideMatrix" => Ok(Region::WEST_US), + profile => { + Err(format!("item.lifecycle does not define a read region for '{profile}'").into()) + } + } +} + +fn lifecycle_routing( + client: &ClientDefinition, + read_region: &Region, +) -> TestResult { + match client.routing.as_str() { + "proximity" => Ok(RoutingStrategy::ProximityTo(read_region.clone())), + "preferredRegions" => { + let mut regions = vec![read_region.clone()]; + if read_region != &Region::EAST_US { + regions.push(Region::EAST_US); + } + Ok(RoutingStrategy::PreferredRegions(regions)) + } + "accountOrder" => Ok(RoutingStrategy::PreferredRegions(Vec::new())), + routing => Err(format!("unsupported lifecycle routing strategy '{routing}'").into()), + } +} + +fn parse_read_consistency(value: &str) -> TestResult { + value.parse::().map_err(Into::into) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_matching_distinguishes_wildcard_zero_and_exact_substatus() { + assert!(READ_SUCCEEDED.matches(StatusCode::Ok, Some(42))); + assert!(PLAIN_NOT_FOUND.matches(StatusCode::NotFound, None)); + assert!(PLAIN_NOT_FOUND.matches(StatusCode::NotFound, Some(0))); + assert!(!PLAIN_NOT_FOUND.matches(StatusCode::NotFound, Some(1002))); + assert!(SESSION_NOT_AVAILABLE.matches(StatusCode::NotFound, Some(1002))); + } + + #[test] + fn successful_read_after_delete_is_a_session_violation() { + let success = ActualHttpStatus { + status_code: StatusCode::Ok, + substatus: None, + }; + assert_eq!( + deleted_read_action(success, true), + DeletedReadAction::SessionViolation + ); + } + + #[test] + fn internal_attempts_must_match_the_case_allowlist() { + let case = PostCreateReadCase::new( + "plain-not-found-only", + Some(ReadConsistencyStrategy::Eventual), + SessionTokenBehavior::Omitted, + eventually_succeeds_after(TransientReadStatus::PlainNotFound), + ); + let allowed = [ + ActualHttpStatus { + status_code: StatusCode::NotFound, + substatus: Some(0), + }, + ActualHttpStatus { + status_code: StatusCode::Ok, + substatus: None, + }, + ]; + assert!(verify_observed_statuses(&case, "allowed", &allowed).is_ok()); + + let disallowed = [ + ActualHttpStatus { + status_code: StatusCode::NotFound, + substatus: Some(1002), + }, + allowed[1], + ]; + assert!(verify_observed_statuses(&case, "disallowed", &disallowed).is_err()); + } + + #[test] + fn eventual_success_requires_an_expected_transient() { + let case = PostCreateReadCase::new( + "session-not-available-first", + Some(ReadConsistencyStrategy::Session), + SessionTokenBehavior::ExplicitCreateResponse, + eventually_succeeds_after(TransientReadStatus::SessionNotAvailable), + ); + let success = ActualHttpStatus { + status_code: StatusCode::Ok, + substatus: None, + }; + assert!(verify_required_transient_observed(&case, "missing", &[success]).is_err()); + + let observed = [ + ActualHttpStatus { + status_code: StatusCode::NotFound, + substatus: Some(1002), + }, + success, + ]; + assert!(verify_required_transient_observed(&case, "observed", &observed).is_ok()); + } +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs new file mode 100644 index 0000000000..a3ff59d21b --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_not_found.rs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::StatusCode; + +use crate::e2e_test_cases::{ + fixture::{E2eTest, TestResult}, + support::{assert_critical_diagnostics, item, should_run, Item}, +}; + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn not_found_does_not_cross_partition_keys() -> TestResult { + if !should_run("item.not-found-wrong-partition-key").await? { + return Ok(()); + } + E2eTest::builder() + .run(async |fixture| { + // Arrange one item in logical partition A. + fixture + .container + .create_item("A", "item-1", item("item-1", "A", 1), None) + .await?; + + // Case 1: a missing ID in the correct partition returns a plain 404. + let missing = fixture + .container + .read_item("A", "missing", None) + .await + .expect_err("missing item read must fail"); + assert_plain_not_found(&missing); + + // Case 2: the existing ID cannot be read through a different partition key. + let wrong_partition = fixture + .container + .read_item("B", "item-1", None) + .await + .expect_err("wrong-partition-key read must fail"); + assert_plain_not_found(&wrong_partition); + + // Neither failed read mutated or hid the correctly addressed item. + let read = fixture.container.read_item("A", "item-1", None).await?; + assert_eq!(read.into_model::()?, item("item-1", "A", 1)); + Ok(()) + }) + .await +} + +fn assert_plain_not_found(error: &azure_data_cosmos::CosmosError) { + assert_eq!(error.status().status_code(), StatusCode::NotFound); + assert_eq!( + error + .status() + .sub_status() + .map(|value| value.value()) + .unwrap_or(0), + 0 + ); + assert_critical_diagnostics( + &error + .diagnostics() + .expect("service error must carry diagnostics"), + "read_item", + StatusCode::NotFound, + ); +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs new file mode 100644 index 0000000000..a019c21953 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_optimistic_concurrency.rs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::{Etag, StatusCode}; +use azure_data_cosmos::options::{ItemWriteOptions, Precondition}; + +use crate::e2e_test_cases::{ + fixture::{E2eTest, TestResult}, + support::{assert_critical_diagnostics, item, should_run, Item}, +}; + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn stale_etag_preserves_successful_update() -> TestResult { + if !should_run("item.optimistic-concurrency").await? { + return Ok(()); + } + E2eTest::builder() + .run(async |fixture| { + // Arrange an item and retain its initial ETag. + let created = fixture + .container + .create_item("A", "etag-1", item("etag-1", "A", 1), None) + .await?; + let initial_etag = created + .headers() + .etag() + .expect("create must return an ETag") + .clone(); + + // Case 1: the current ETag permits the update from value 1 to value 2. + let current_options = ItemWriteOptions::default() + .with_precondition(Precondition::IfMatch(initial_etag.clone())); + let replaced = fixture + .container + .replace_item("A", "etag-1", item("etag-1", "A", 2), Some(current_options)) + .await?; + assert_eq!(replaced.status().status_code(), StatusCode::Ok); + + // Case 2: reusing the now-stale initial ETag cannot overwrite value 2. + let stale_options = ItemWriteOptions::default() + .with_precondition(Precondition::IfMatch(Etag::from(initial_etag.to_string()))); + let error = fixture + .container + .replace_item("A", "etag-1", item("etag-1", "A", 3), Some(stale_options)) + .await + .expect_err("stale ETag must fail"); + assert_eq!(error.status().status_code(), StatusCode::PreconditionFailed); + assert_eq!( + error + .status() + .sub_status() + .map(|value| value.value()) + .unwrap_or(0), + 0 + ); + assert_critical_diagnostics( + &error + .diagnostics() + .expect("service error must carry diagnostics"), + "replace_item", + StatusCode::PreconditionFailed, + ); + + // The rejected write leaves the successful value 2 update intact. + assert_eq!( + fixture + .container + .read_item("A", "etag-1", None) + .await? + .into_model::()?, + item("etag-1", "A", 2) + ); + Ok(()) + }) + .await +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs new file mode 100644 index 0000000000..5ef7088997 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/item_upsert.rs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::StatusCode; +use azure_data_cosmos::{feed::FeedScope, Query}; +use futures::TryStreamExt; + +use crate::e2e_test_cases::{ + fixture::{E2eTest, TestResult}, + support::{assert_critical_diagnostics, item, should_run, write_options_with_content, Item}, +}; + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn upsert_creates_then_updates() -> TestResult { + if !should_run("item.upsert-create-update").await? { + return Ok(()); + } + E2eTest::builder() + .run(async |fixture| { + // Case 1: upserting a missing identity creates value 1 and returns 201. + let created = fixture + .container + .upsert_item( + "A", + "upsert-1", + item("upsert-1", "A", 1), + Some(write_options_with_content()), + ) + .await?; + assert_eq!(created.status().status_code(), StatusCode::Created); + assert_critical_diagnostics(&created.diagnostics(), "upsert_item", StatusCode::Created); + + // Case 2: upserting the same identity replaces it with value 2 and returns 200. + let updated = fixture + .container + .upsert_item( + "A", + "upsert-1", + item("upsert-1", "A", 2), + Some(write_options_with_content()), + ) + .await?; + assert_eq!(updated.status().status_code(), StatusCode::Ok); + assert_critical_diagnostics(&updated.diagnostics(), "upsert_item", StatusCode::Ok); + assert_eq!(updated.into_model::()?, item("upsert-1", "A", 2)); + + // Both operations addressed one identity; no duplicate document was created. + let items: Vec = fixture + .container + .query_items( + Query::from("SELECT * FROM c WHERE c.id = @id") + .with_parameter("@id", "upsert-1")?, + FeedScope::partition("A"), + None, + ) + .await? + .try_collect() + .await?; + assert_eq!(items, [item("upsert-1", "A", 2)]); + Ok(()) + }) + .await +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/mod.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/mod.rs new file mode 100644 index 0000000000..148a7a5cf2 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/mod.rs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +mod bootstrap_primary; +mod capabilities; +mod catalog; +mod diagnostics_success_and_error; +mod fixture; +mod item_create_conflict; +mod item_lifecycle; +mod item_not_found; +mod item_optimistic_concurrency; +mod item_upsert; +mod query_invalid_syntax; +mod query_parameterized_filter; +mod support; + +const IMPLEMENTED_TESTS: &[&str] = &[ + "capabilities::capability_document_is_versioned", + "bootstrap_primary::bootstrap_primary_endpoint", + "item_lifecycle::crud_lifecycle", + "item_upsert::upsert_creates_then_updates", + "item_create_conflict::duplicate_create_preserves_original", + "item_not_found::not_found_does_not_cross_partition_keys", + "item_optimistic_concurrency::stale_etag_preserves_successful_update", + "query_parameterized_filter::parameterized_query_filters_and_orders", + "query_invalid_syntax::invalid_query_is_not_an_empty_feed", + "diagnostics_success_and_error::diagnostics_cover_success_and_error", +]; + +#[test] +fn e2e_scenario_catalog_is_valid() { + catalog::validate_catalog(IMPLEMENTED_TESTS).expect("E2E scenario catalog must be valid"); +} + +#[test] +fn implementation_registry_names_compiled_tests() { + let executable = std::env::current_exe().expect("current E2E test executable must be known"); + let output = std::process::Command::new(executable) + .arg("--list") + .output() + .expect("E2E test executable must support libtest --list"); + assert!( + output.status.success(), + "listing compiled E2E tests failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let listed = String::from_utf8(output.stdout).expect("libtest list must be UTF-8"); + for test in IMPLEMENTED_TESTS { + assert!( + listed.lines().any(|line| { + line.strip_suffix(": test") == Some(format!("e2e_test_cases::{test}").as_str()) + }), + "implementation registry entry '{test}' does not name a compiled E2E test" + ); + } +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs new file mode 100644 index 0000000000..33490771b5 --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_invalid_syntax.rs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::StatusCode; +use azure_data_cosmos::feed::FeedScope; +use futures::StreamExt; + +use crate::e2e_test_cases::{ + fixture::{E2eTest, TestResult}, + support::{should_run, Item}, +}; + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn invalid_query_is_not_an_empty_feed() -> TestResult { + if !should_run("query.invalid-syntax").await? { + return Ok(()); + } + E2eTest::builder() + .run(async |fixture| { + // Query failures may surface while creating the iterator or while reading its first page. + let result = fixture + .container + .query_items::("SELECT FROM", FeedScope::partition("A"), None) + .await; + let error = match result { + Err(error) => error, + Ok(mut stream) => stream + .next() + .await + .expect("invalid query must produce an error") + .expect_err("invalid query must not produce a page"), + }; + + // Invalid syntax is a typed bad request, never an empty successful feed. + assert_eq!(error.status().status_code(), StatusCode::BadRequest); + Ok(()) + }) + .await +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs new file mode 100644 index 0000000000..0cdc84d98e --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/query_parameterized_filter.rs @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_data_cosmos::{feed::FeedScope, Query}; +use futures::{StreamExt, TryStreamExt}; + +use crate::e2e_test_cases::{ + fixture::{E2eTest, TestResult}, + support::{item, should_run, Item}, +}; + +#[tokio::test] +#[cfg_attr( + not(any(test_category = "emulator_inmemory", test_category = "e2e")), + ignore = "requires the externally hosted in-memory emulator" +)] +async fn parameterized_query_filters_and_orders() -> TestResult { + if !should_run("query.parameterized-filter").await? { + return Ok(()); + } + E2eTest::builder() + .run(async |fixture| { + // Arrange IDs and scores in different orders so ORDER BY is observable. + for (id, score) in [("a", 3), ("b", 1), ("c", 2)] { + let mut value = item(id, "A", score); + value.score = Some(score); + fixture.container.create_item("A", id, value, None).await?; + } + + // Bind values as parameters and restrict execution to partition A. + let query = Query::from( + "SELECT * FROM c WHERE c.pk = @pk AND c.score >= @min ORDER BY c.score ASC", + ) + .with_parameter("@pk", "A")? + .with_parameter("@min", 2)?; + let mut results = fixture + .container + .query_items::(query, FeedScope::partition("A"), None) + .await?; + let items: Vec = results.by_ref().try_collect().await?; + + // The filter excludes score 1 and ordering differs from ID/insertion order. + assert_eq!( + items + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + ["c", "a"] + ); + + let descending = Query::from( + "SELECT * FROM c WHERE c.pk = @pk AND c.score >= @min ORDER BY c.score DESC", + ) + .with_parameter("@pk", "A")? + .with_parameter("@min", 2)?; + let descending_items: Vec = fixture + .container + .query_items::(descending, FeedScope::partition("A"), None) + .await? + .try_collect() + .await?; + assert_eq!( + descending_items + .iter() + .map(|item| item.id.as_str()) + .collect::>(), + ["a", "c"] + ); + Ok(()) + }) + .await +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs new file mode 100644 index 0000000000..621c88fefd --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases/support.rs @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use azure_core::http::StatusCode; +use azure_data_cosmos::{ + diagnostics::{DiagnosticsContext, TransportKind}, + options::{ContentResponseOnWrite, ItemWriteOptions, OperationOptions}, +}; +use serde::{Deserialize, Serialize}; + +use crate::e2e_test_cases::{ + catalog::{required_capabilities_for, selected_profile_for, Capability, Profile}, + fixture::TestResult, +}; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +pub(super) struct Item { + pub(super) id: String, + pub(super) pk: String, + pub(super) value: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) score: Option, +} + +pub(super) fn item(id: &str, pk: &str, value: i64) -> Item { + Item { + id: id.to_owned(), + pk: pk.to_owned(), + value, + score: None, + } +} + +pub(super) fn write_options_with_content() -> ItemWriteOptions { + let mut operation = OperationOptions::default(); + operation.content_response_on_write = Some(ContentResponseOnWrite::Enabled); + ItemWriteOptions::default().with_operation_options(operation) +} + +pub(super) async fn should_run(scenario_id: &str) -> TestResult { + let Some(profile) = selected_scenario_profile(scenario_id).await? else { + return Ok(false); + }; + if profile.accounts.len() != 1 || profile.runtimes.len() != 1 || profile.clients.len() != 1 { + return Err(format!( + "scenario '{scenario_id}' requires a single-cell setup profile, got '{}'", + profile.id + ) + .into()); + } + let runtime = &profile.runtimes[0]; + let client = &profile.clients[0]; + if runtime.gateway_v2 != "backendDefault" + || runtime.ppcb != "sdkDefault" + || runtime.default_read_consistency_strategy.is_some() + || client.binary_encoding != "sdkDefault" + || client.routing != "proximity" + || client.default_read_consistency_strategy.is_some() + { + return Err(format!( + "scenario '{scenario_id}' does not implement the runtime/client settings in profile '{}'", + profile.id + ) + .into()); + } + Ok(true) +} + +pub(super) async fn selected_scenario_profile(scenario_id: &str) -> TestResult> { + init_test_tracing(); + let Some(profile) = selected_profile_for(scenario_id)? else { + let selected = + std::env::var("AZURE_COSMOS_E2E_PROFILE").unwrap_or_else(|_| "smokeTests".to_owned()); + eprintln!("SKIP {scenario_id}: profile '{selected}' does not select it"); + return Ok(None); + }; + enforce_required_capabilities(scenario_id).await?; + Ok(Some(profile)) +} + +fn init_test_tracing() { + let filter = std::env::var("RUST_LOG") + .map(tracing_subscriber::EnvFilter::new) + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::from_default_env()); + let _ = tracing_subscriber::fmt::fmt() + .with_env_filter(filter) + .try_init(); +} + +async fn enforce_required_capabilities(scenario_id: &str) -> TestResult { + let management_endpoint = std::env::var("AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT")?; + let response = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build()? + .get(url::Url::parse(&management_endpoint)?.join("capabilities")?) + .send() + .await? + .error_for_status()?; + let capabilities: CapabilityDocument = serde_json::from_slice(&response.bytes().await?)?; + if capabilities.api_version != 1 { + return Err(format!( + "scenario '{scenario_id}' requires capabilities API version 1, got {}", + capabilities.api_version + ) + .into()); + } + let backend = match std::env::var("AZURE_COSMOS_EMULATOR_FLAVOR") + .ok() + .as_deref() + { + Some("inmemory-v1") => "hostedEmulatorGatewayV1", + Some("inmemory-v2") => "hostedEmulatorGatewayV2", + Some(flavor) => { + return Err(format!( + "E2E scenario '{scenario_id}' does not support emulator flavor '{flavor}'" + ) + .into()) + } + None if capabilities.protocols.gateway_v2 => "hostedEmulatorGatewayV2", + None => "hostedEmulatorGatewayV1", + }; + let requirements = required_capabilities_for(scenario_id, backend)?; + if requirements.is_empty() { + return Ok(()); + } + for requirement in requirements { + let available = match requirement { + Capability::Capabilities => true, + Capability::GatewayV2 => capabilities.protocols.gateway_v2, + }; + if !available { + return Err(format!( + "required capability '{requirement:?}' is unavailable for scenario '{scenario_id}'" + ) + .into()); + } + } + Ok(()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CapabilityDocument { + api_version: u32, + protocols: ProtocolCapabilities, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ProtocolCapabilities { + gateway_v2: bool, +} + +pub(super) fn assert_critical_diagnostics( + diagnostics: &DiagnosticsContext, + operation_name: &str, + status_code: StatusCode, +) { + assert_eq!(diagnostics.operation_name(), Some(operation_name)); + assert!(!diagnostics.activity_id().to_string().is_empty()); + assert_eq!( + diagnostics + .effective_status() + .map(|status| status.status_code()), + Some(status_code) + ); + assert!(diagnostics.request_count() >= 1); + let expected_transport = match std::env::var("AZURE_COSMOS_EMULATOR_FLAVOR").as_deref() { + Ok("inmemory-v1") => Some(TransportKind::Gateway), + Ok("inmemory-v2") => Some(TransportKind::GatewayV2), + _ => None, + }; + if let Some(expected_transport) = expected_transport { + assert!( + diagnostics + .requests() + .iter() + .all(|request| request.transport_kind() == expected_transport), + "completed requests must use {expected_transport:?}" + ); + } +} diff --git a/sdk/cosmos/azure_data_cosmos/tests/e2e_tests.rs b/sdk/cosmos/azure_data_cosmos/tests/e2e_tests.rs new file mode 100644 index 0000000000..0e76dc81cc --- /dev/null +++ b/sdk/cosmos/azure_data_cosmos/tests/e2e_tests.rs @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +#![allow(clippy::large_futures)] + +mod e2e_test_cases; diff --git a/sdk/cosmos/azure_data_cosmos/tests/in_memory_emulator_tests/session_token.rs b/sdk/cosmos/azure_data_cosmos/tests/in_memory_emulator_tests/session_token.rs index 954a0755e0..84471e5bf7 100644 --- a/sdk/cosmos/azure_data_cosmos/tests/in_memory_emulator_tests/session_token.rs +++ b/sdk/cosmos/azure_data_cosmos/tests/in_memory_emulator_tests/session_token.rs @@ -182,10 +182,18 @@ struct Harness { impl Harness { async fn setup() -> Self { - Self::setup_with_partition_key_range_cache(true).await + Self::setup_with_options(true, ConsistencyLevel::Session, None).await } async fn setup_with_partition_key_range_cache(enabled: bool) -> Self { + Self::setup_with_options(enabled, ConsistencyLevel::Session, None).await + } + + async fn setup_with_options( + partition_key_range_cache_enabled: bool, + account_consistency: ConsistencyLevel, + read_consistency_strategy: Option, + ) -> Self { let observer = RecordingObserver::new(); let config = VirtualAccountConfig::new(vec![VirtualRegion::new( @@ -193,7 +201,7 @@ impl Harness { Url::parse(EMULATOR_GATEWAY_URL).unwrap(), )]) .unwrap() - .with_consistency(ConsistencyLevel::Session); + .with_consistency(account_consistency); let emulator = Arc::new( InMemoryEmulatorHttpClient::new(config).with_request_observer(observer.clone()), @@ -219,14 +227,16 @@ impl Harness { Url::parse(EMULATOR_GATEWAY_URL).unwrap(), EMULATOR_KEY, ); - let driver = runtime - .create_driver( - DriverOptions::builder(account) - .with_partition_key_range_cache_enabled(enabled) + let mut driver_options = DriverOptions::builder(account) + .with_partition_key_range_cache_enabled(partition_key_range_cache_enabled); + if let Some(strategy) = read_consistency_strategy { + driver_options = driver_options.with_operation_options( + OperationOptionsBuilder::new() + .with_read_consistency_strategy(strategy) .build(), - ) - .await - .unwrap(); + ); + } + let driver = runtime.create_driver(driver_options.build()).await.unwrap(); let container = driver .resolve_container( @@ -297,6 +307,101 @@ impl Harness { } } +#[tokio::test] +async fn session_strategy_on_eventual_account_captures_write_token_for_read() { + let h = Harness::setup_with_options(true, ConsistencyLevel::Eventual, None).await; + + h.observer.clear(); + let create_token = h + .create("pk1", "item-1", 1) + .await + .expect("create should return a session token"); + let create_writes: Vec = h + .observer + .snapshots() + .into_iter() + .filter(|snapshot| snapshot.is_item_request() && snapshot.method == Method::Post) + .collect(); + assert_eq!(create_writes.len(), 1); + assert_eq!( + create_writes[0].session_token, None, + "read consistency must not automatically attach cached tokens to writes" + ); + + h.observer.clear(); + h.driver + .execute_singleton_operation( + CosmosOperation::read_item(h.item_ref("pk1", "item-1")), + OperationOptionsBuilder::new() + .with_read_consistency_strategy(ReadConsistencyStrategy::Session) + .build(), + ) + .await + .expect("Session read should succeed"); + assert_eq!( + h.observer.single_item_read().session_token.as_deref(), + Some(create_token.as_str()), + "read must carry the token captured from the preceding write" + ); +} + +#[tokio::test] +async fn single_write_account_does_not_attach_cached_token_to_writes() { + let h = Harness::setup().await; + h.create("pk1", "item-1", 1) + .await + .expect("create should return a session token"); + + h.observer.clear(); + h.replace("pk1", "item-1", 2) + .await + .expect("replace should return a session token"); + let writes: Vec<_> = h + .observer + .snapshots() + .into_iter() + .filter(|snapshot| snapshot.is_item_request() && snapshot.method == Method::Put) + .collect(); + assert_eq!(writes.len(), 1, "expected exactly one replace request"); + assert_eq!( + writes[0].session_token, None, + "ordinary single-write operations must not attach cached session tokens" + ); +} + +#[tokio::test] +async fn session_capturing_disabled_prevents_write_response_capture() { + let h = Harness::setup().await; + let body = serde_json::to_vec(&TestItem { + id: "item-1".to_owned(), + pk: "pk1".to_owned(), + value: 1, + }) + .unwrap(); + h.driver + .execute_singleton_operation( + CosmosOperation::create_item(h.item_ref("pk1", "item-1")).with_body(body), + OperationOptionsBuilder::new() + .with_session_capturing_disabled(true) + .build(), + ) + .await + .expect("create_item should succeed"); + + h.observer.clear(); + h.driver + .execute_singleton_operation( + CosmosOperation::read_item(h.item_ref("pk1", "item-1")), + OperationOptionsBuilder::new().build(), + ) + .await + .expect("read_item should succeed"); + assert_eq!( + h.observer.single_item_read().session_token, None, + "a response captured with automatic session management disabled must not populate the cache" + ); +} + #[tokio::test] async fn cache_disabled_preserves_explicit_tokens_without_automatic_session_management() { let h = Harness::setup_with_partition_key_range_cache(false).await; diff --git a/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs b/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs index 9153e04a6b..cd468716b9 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/src/driver/pipeline/operation_pipeline.rs @@ -436,11 +436,21 @@ pub(crate) async fn execute_operation_pipeline( .read_consistency_strategy() .copied() .unwrap_or(ReadConsistencyStrategy::Default); - let effective_consistency = - resolve_effective_consistency(read_consistency_strategy, account_default_consistency); - let session_consistency_active = partition_key_range_cache_enabled + let operation_read_consistency_strategy = + read_consistency_strategy_for_operation(operation, read_consistency_strategy); + let effective_consistency = resolve_effective_consistency( + operation_read_consistency_strategy, + account_default_consistency, + ); + let session_token_resolution_active = partition_key_range_cache_enabled && !session_capturing_disabled - && read_consistency_strategy.is_session_effective(account_default_consistency); + && operation_allows_automatic_session_token_resolution( + operation, + location_snapshot.account.multiple_write_locations_enabled, + ) + && operation_read_consistency_strategy.is_session_effective(account_default_consistency); + let session_token_capture_active = + partition_key_range_cache_enabled && !session_capturing_disabled; // Rule 4 (RCS validation): GlobalStrong is // valid only on reads against accounts whose default consistency is Strong. @@ -453,13 +463,7 @@ pub(crate) async fn execute_operation_pipeline( ) && operation.is_read_only() && account_default_consistency != DefaultConsistencyLevel::Strong { - return Err(crate::error::CosmosError::builder() - .with_status(crate::error::CosmosStatus::CLIENT_BAD_REQUEST) - .with_message( - "ReadConsistencyStrategy::GlobalStrong is only valid against accounts whose \ - default consistency level is Strong", - ) - .build()); + return Err(global_strong_account_validation_error(diagnostics)); } let max_session_retries = options .max_session_retry_count() @@ -572,15 +576,20 @@ pub(crate) async fn execute_operation_pipeline( if operation.prefers_write_endpoints_for_read() && routing.routing_fallback.is_some() { ReadConsistencyStrategy::Default } else { - read_consistency_strategy + operation_read_consistency_strategy }; let attempt_effective_consistency = resolve_effective_consistency( attempt_read_consistency_strategy, account_default_consistency, ); - let attempt_session_consistency_active = partition_key_range_cache_enabled + let attempt_session_token_resolution_active = partition_key_range_cache_enabled && !session_capturing_disabled + && operation_allows_automatic_session_token_resolution( + operation, + location.account.multiple_write_locations_enabled, + ) && attempt_read_consistency_strategy.is_session_effective(account_default_consistency); + let attempt_session_token_capture_active = session_token_capture_active; // Emit one structured debug record per attempt with the chosen // routing decision. Tests and SREs filter on this to verify which @@ -670,7 +679,8 @@ pub(crate) async fn execute_operation_pipeline( effective_consistency, read_consistency_strategy, session_manager, - session_consistency_active, + session_token_resolution_active, + session_token_capture_active, options, throughput_control, deadline, @@ -781,7 +791,7 @@ pub(crate) async fn execute_operation_pipeline( } else { ReadConsistencyStrategy::Default }, - resolved_session_token: attempt_session_consistency_active + resolved_session_token: attempt_session_token_resolution_active .then(|| { // Scope the session token to the target partition-key-range // only for thin-client (Gateway 2.0) requests: the RNTBD @@ -884,7 +894,7 @@ pub(crate) async fn execute_operation_pipeline( // Abort, or a retry action. 409/412 map to Abort, and the Abort // variant does not carry headers — capturing after evaluation // would silently drop tokens from those responses. - if attempt_session_consistency_active { + if attempt_session_token_capture_active { if let Some(cosmos_headers) = result.cosmos_headers() { if should_capture_session_token_from_status( cosmos_headers.substatus.as_ref(), @@ -944,7 +954,7 @@ pub(crate) async fn execute_operation_pipeline( Box::pin(driver.pre_resolve_partition_key_range_id( operation, &overrides, - session_consistency_active, + session_token_resolution_active, operation_options, )) .await; @@ -1279,7 +1289,8 @@ pub(crate) async fn execute_operation_pipeline( effective_consistency, read_consistency_strategy, session_manager, - session_consistency_active, + session_token_resolution_active, + session_token_capture_active, options, throughput_control, deadline, @@ -2909,9 +2920,10 @@ struct AttemptContext<'a> { /// rationale as `effective_consistency`. read_consistency_strategy: ReadConsistencyStrategy, session_manager: &'a SessionManager, - /// Whether session consistency is in effect for this operation - /// (drives session-token resolve/capture inside the attempt). - session_consistency_active: bool, + /// Whether cached session-token resolution is active for this operation. + session_token_resolution_active: bool, + /// Whether the winning response's session token should be captured. + session_token_capture_active: bool, options: &'a OperationOptionsView<'a>, throughput_control: Option, /// End-to-end deadline (operation timeout) — passed through to each @@ -3287,7 +3299,7 @@ async fn perform_single_attempt( // Scope to the target range only for thin-client (Gateway 2.0); classic // gateway keeps the composite token (see main-loop rationale). let resolved_session_token = ctx - .session_consistency_active + .session_token_resolution_active .then(|| { let scoped_pk_range_id = if matches!(routing.transport_mode, TransportMode::GatewayV2) { ctx.partition_key_range_id.as_ref().map(|id| id.as_str()) @@ -3392,7 +3404,7 @@ async fn perform_single_attempt( /// whose response the caller never observes would leak stale state and /// violate read-your-writes against the winning region. fn capture_session_token_for_winner(ctx: &AttemptContext<'_>, result: &TransportResult) { - if !ctx.session_consistency_active { + if !ctx.session_token_capture_active { return; } if let Some(cosmos_headers) = result.cosmos_headers() { @@ -4349,6 +4361,41 @@ async fn execute_hedged( } } +fn read_consistency_strategy_for_operation( + operation: &CosmosOperation, + read_consistency_strategy: ReadConsistencyStrategy, +) -> ReadConsistencyStrategy { + if operation.is_read_only() { + read_consistency_strategy + } else { + ReadConsistencyStrategy::Default + } +} + +fn operation_allows_automatic_session_token_resolution( + operation: &CosmosOperation, + multiple_write_locations_enabled: bool, +) -> bool { + operation.is_read_only() + || operation.operation_type() == OperationType::Batch + || multiple_write_locations_enabled +} + +fn global_strong_account_validation_error( + mut diagnostics: DiagnosticsContextBuilder, +) -> crate::error::CosmosError { + let status = crate::error::CosmosStatus::CLIENT_BAD_REQUEST; + diagnostics.set_operation_status(status.status_code(), status.sub_status()); + crate::error::CosmosError::builder() + .with_status(status) + .with_message( + "ReadConsistencyStrategy::GlobalStrong is only valid against accounts whose \ + default consistency level is Strong", + ) + .with_diagnostics(Arc::new(diagnostics.complete())) + .build() +} + /// Generic "both sides transient" error carried inside /// [`HedgedRaceResult::BothTransient`] when neither leg produced a /// final response and the deadline has not elapsed. The surrounding @@ -4716,6 +4763,41 @@ mod tests { )); } + #[test] + fn automatic_session_token_resolution_respects_operation_and_topology() { + let item = ItemReference::from_name(&test_container(), PartitionKey::from("pk1"), "doc1"); + let write = CosmosOperation::create_item(item.clone()).with_body(b"{}".to_vec()); + let read = CosmosOperation::read_item(item); + let batch = CosmosOperation::batch(test_container(), PartitionKey::from("pk1")); + + assert_eq!( + super::read_consistency_strategy_for_operation( + &write, + crate::options::ReadConsistencyStrategy::Session, + ), + crate::options::ReadConsistencyStrategy::Default + ); + assert_eq!( + super::read_consistency_strategy_for_operation( + &read, + crate::options::ReadConsistencyStrategy::Eventual, + ), + crate::options::ReadConsistencyStrategy::Eventual + ); + assert!(super::operation_allows_automatic_session_token_resolution( + &read, false + )); + assert!(!super::operation_allows_automatic_session_token_resolution( + &write, false + )); + assert!(super::operation_allows_automatic_session_token_resolution( + &write, true + )); + assert!(super::operation_allows_automatic_session_token_resolution( + &batch, false + )); + } + #[test] fn patch_read_routing_hint_is_restored_after_generic_failover_reset() { use crate::driver::pipeline::components::SessionRetryRouting; @@ -10261,6 +10343,27 @@ mod tests { ) } + #[test] + fn global_strong_account_validation_preserves_zero_request_diagnostics() { + let error = super::global_strong_account_validation_error(test_diagnostics()); + + assert_eq!( + error.status(), + crate::error::CosmosStatus::CLIENT_BAD_REQUEST + ); + assert!(error.response().is_none()); + let diagnostics = error + .diagnostics() + .expect("client-side validation must preserve diagnostics"); + assert_eq!(diagnostics.request_count(), 0); + assert_eq!( + diagnostics + .effective_status() + .map(|status| status.status_code()), + Some(azure_core::http::StatusCode::BadRequest) + ); + } + #[test] fn enforce_deadline_none_is_ok() { let options = empty_options_view(); diff --git a/sdk/cosmos/azure_data_cosmos_driver/src/options/read_consistency.rs b/sdk/cosmos/azure_data_cosmos_driver/src/options/read_consistency.rs index a6b7ac5a54..69cfd1c088 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/src/options/read_consistency.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/src/options/read_consistency.rs @@ -28,8 +28,9 @@ use crate::models::DefaultConsistencyLevel; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum ReadConsistencyStrategy { - /// Use the default read behavior for the consistency level applied to the operation, - /// the client, or the account. No RCS header / token is emitted on the wire. + /// Reset to the default consistency-level or account behavior at the layer where this + /// value is explicitly configured. Unlike an absent value, an explicit `Default` masks + /// lower-precedence read consistency strategies. No RCS header / token is emitted. Default, /// Eventual consistency guarantees that reads will return a subset of writes. @@ -40,12 +41,15 @@ pub enum ReadConsistencyStrategy { /// read-your-writes within any single session. Session, - /// Returns the latest committed version of the requested item across replicas. + /// Returns the latest committed version available across replicas in the + /// selected read region. /// /// On accounts whose default consistency is Session, ConsistentPrefix, or Eventual, /// this strategy upgrades the read to a quorum read (1:2 client-to-backend /// amplification) without weakening any other operation. On Strong / BoundedStaleness - /// accounts it behaves like the account default. + /// accounts it behaves like the account default. This is a region-local + /// quorum read, not a cross-region replication barrier: a newly written + /// item can be temporarily absent from a lagging secondary region. LatestCommitted, /// Reads the latest version across all regions. diff --git a/sdk/cosmos/azure_data_cosmos_driver/tests/emulator_tests/driver_fault_injection.rs b/sdk/cosmos/azure_data_cosmos_driver/tests/emulator_tests/driver_fault_injection.rs index b9a133b3a6..e5d8fda2b4 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/tests/emulator_tests/driver_fault_injection.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/tests/emulator_tests/driver_fault_injection.rs @@ -1119,11 +1119,13 @@ pub async fn fault_injection_429_honors_configurable_throttle_retry_count( .build(), ); - // Pin the throttle-retry budget at the runtime layer of the option - // view. A generous cumulative-wait budget keeps the attempt count the - // sole limiter for these small retry counts. No end-to-end latency - // policy is set, so the transport request carries no deadline and the - // forced-final retry is immediate. + // Pin the throttle-retry budget on the ReadItem operation under test. + // Setup and account-metadata operations retain their normal retry + // budgets so transient service-side metadata throttling cannot mask the + // fault-injection assertion. A generous cumulative-wait budget keeps + // the read's attempt count as the sole limiter for these small retry + // counts. No end-to-end latency policy is set, so the transport request + // carries no deadline and the forced-final retry is immediate. let operation_options = OperationOptionsBuilder::new() .with_throttling_retry_options( ThrottlingRetryOptionsBuilder::new() @@ -1134,45 +1136,44 @@ pub async fn fault_injection_429_honors_configurable_throttle_retry_count( .build(); let rule_for_assert = Arc::clone(&rule); - Box::pin( - DriverTestClient::run_with_unique_db_and_fault_injection_options( - vec![rule], - operation_options, - async move |context, database| { - let container_name = context.unique_container_name(); - let container = context - .create_container(&database, &container_name, "/pk") - .await?; - - // Seed the item with a write. The fault rule targets only - // ReadItem, so the seeding write is unaffected. - let item_json = br#"{"id": "item1", "pk": "pk1", "value": "test"}"#; - context - .create_item(&container, "item1", "pk1", item_json) - .await?; - - // The read always observes 429 and ultimately fails once - // the throttle budget is exhausted. - let read_result = context.read_item(&container, "item1", "pk1").await; - assert!( - read_result.is_err(), - "read must fail once the throttle budget is exhausted \ + Box::pin(DriverTestClient::run_with_unique_db_and_fault_injection( + vec![rule], + async move |context, database| { + let container_name = context.unique_container_name(); + let container = context + .create_container(&database, &container_name, "/pk") + .await?; + + // Seed the item with a write. The fault rule targets only + // ReadItem, so the seeding write is unaffected. + let item_json = br#"{"id": "item1", "pk": "pk1", "value": "test"}"#; + context + .create_item(&container, "item1", "pk1", item_json) + .await?; + + // The read always observes 429 and ultimately fails once + // the throttle budget is exhausted. + let read_result = context + .read_item_with_options(&container, "item1", "pk1", operation_options) + .await; + assert!( + read_result.is_err(), + "read must fail once the throttle budget is exhausted \ (max_throttle_retry_count={max_throttle_retry_count})", - ); + ); - assert_eq!( - rule_for_assert.hit_count(), - expected_hits, - "max_throttle_retry_count={max_throttle_retry_count} must yield \ + assert_eq!( + rule_for_assert.hit_count(), + expected_hits, + "max_throttle_retry_count={max_throttle_retry_count} must yield \ {expected_hits} ReadItem attempts on the wire, but the 429 fault \ rule fired {} time(s)", - rule_for_assert.hit_count(), - ); + rule_for_assert.hit_count(), + ); - Ok(()) - }, - ), - ) + Ok(()) + }, + )) .await?; } diff --git a/sdk/cosmos/azure_data_cosmos_driver/tests/framework/test_client.rs b/sdk/cosmos/azure_data_cosmos_driver/tests/framework/test_client.rs index 491734cc71..cdd06b3d05 100644 --- a/sdk/cosmos/azure_data_cosmos_driver/tests/framework/test_client.rs +++ b/sdk/cosmos/azure_data_cosmos_driver/tests/framework/test_client.rs @@ -1005,6 +1005,23 @@ impl DriverTestRunContext { container: &ContainerReference, item_id: &str, partition_key: impl Into, + ) -> Result> { + self.read_item_with_options( + container, + item_id, + partition_key, + OperationOptions::default(), + ) + .await + } + + /// Reads an item using the driver with operation-specific options. + pub async fn read_item_with_options( + &self, + container: &ContainerReference, + item_id: &str, + partition_key: impl Into, + options: OperationOptions, ) -> Result> { let driver = self .client @@ -1017,7 +1034,7 @@ impl DriverTestRunContext { let operation = CosmosOperation::read_item(item_ref); let result = driver - .execute_singleton_operation(operation, OperationOptions::default()) + .execute_singleton_operation(operation, options) .await?; Ok(result) diff --git a/sdk/cosmos/azure_data_cosmos_emulator/README.md b/sdk/cosmos/azure_data_cosmos_emulator/README.md index 5a27ff98ae..049ab5d08b 100644 --- a/sdk/cosmos/azure_data_cosmos_emulator/README.md +++ b/sdk/cosmos/azure_data_cosmos_emulator/README.md @@ -29,6 +29,9 @@ covers the host security boundary. - A **management REST API** for emulator-only control-plane actions with no Cosmos gateway equivalent: partition split/merge (as long-running operations), per-partition-failover toggling, and replication pause/resume. +- A versioned **capability document** at `GET /capabilities` so external SDK + runners can fail closed when a required protocol or management action is not + available. ## Quick start diff --git a/sdk/cosmos/azure_data_cosmos_emulator/src/management.rs b/sdk/cosmos/azure_data_cosmos_emulator/src/management.rs index 2b2d467c5f..1730513359 100644 --- a/sdk/cosmos/azure_data_cosmos_emulator/src/management.rs +++ b/sdk/cosmos/azure_data_cosmos_emulator/src/management.rs @@ -79,6 +79,7 @@ fn router( }; Router::new() .route("/health", get(health)) + .route("/capabilities", get(capabilities)) .route("/account", get(account)) .route( "/databases/{database}/containers/{container}/partitions/{partition_id}/split", @@ -120,6 +121,64 @@ async fn health(State(state): State) -> Json })) } +const CAPABILITIES_API_VERSION: u32 = 1; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CapabilitiesResponse { + api_version: u32, + emulator_version: &'static str, + protocols: ProtocolCapabilities, + data_plane: &'static [&'static str], + management_actions: &'static [&'static str], + limitations: &'static [&'static str], +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ProtocolCapabilities { + gateway_v1: bool, + gateway_v2: bool, +} + +/// Returns the versioned capabilities document consumed by external SDK E2E +/// test runners. Capabilities describe what the current host can exercise; +/// they do not claim production-service fidelity beyond the listed limits. +async fn capabilities(State(state): State) -> Json { + Json(CapabilitiesResponse { + api_version: CAPABILITIES_API_VERSION, + emulator_version: env!("CARGO_PKG_VERSION"), + protocols: ProtocolCapabilities { + gateway_v1: true, + gateway_v2: state + .bindings + .iter() + .any(|binding| binding.gateway20_url.is_some()), + }, + data_plane: &[ + "database", + "container", + "offer", + "item", + "query", + "changeFeed", + "transactionalBatch", + "patch", + ], + management_actions: &[ + "partitionSplit", + "partitionMerge", + "perPartitionFailover", + "replicationPauseResume", + ], + limitations: &[ + "authenticationNotEnforced", + "loopbackOnly", + "volatileStorage", + ], + }) +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct AccountResponse { @@ -1350,6 +1409,36 @@ mod tests { server.abort(); } + #[tokio::test] + async fn capabilities_report_configured_protocols() { + let gateway_url = Url::parse("http://127.0.0.1:18081/").unwrap(); + let gateway20_url = Url::parse("http://127.0.0.1:18082/").unwrap(); + let config = + VirtualAccountConfig::new(vec![VirtualRegion::new("East US", gateway_url.clone()) + .with_gateway_v2_url(gateway20_url.clone())]) + .unwrap(); + let emulator = Arc::new(InMemoryEmulatorHttpClient::new(config)); + let state = ManagementState { + emulator, + account_id: "test-account".into(), + bindings: vec![GatewayBinding { + region_name: "East US".to_owned(), + gateway_url, + gateway20_url: Some(gateway20_url), + }] + .into(), + metrics: Arc::new(HostMetrics::default()), + operations: Arc::new(OperationRegistry::default()), + }; + + let response = capabilities(State(state)).await.0; + assert_eq!(response.api_version, CAPABILITIES_API_VERSION); + assert!(response.protocols.gateway_v1); + assert!(response.protocols.gateway_v2); + assert!(response.data_plane.contains(&"item")); + assert!(response.management_actions.contains(&"partitionSplit")); + } + #[tokio::test] async fn replication_pause_and_resume_round_trip_through_management_api() { let east_url = Url::parse("http://127.0.0.1:18081/").unwrap(); diff --git a/sdk/cosmos/ci.yml b/sdk/cosmos/ci.yml index 5e20866623..00930b0c30 100644 --- a/sdk/cosmos/ci.yml +++ b/sdk/cosmos/ci.yml @@ -120,6 +120,20 @@ extends: Path: sdk/cosmos/inmemory-emulator-matrix.json Selection: all GenerateVMJobs: true + # The full consistency × read-consistency-strategy lifecycle matrix is + # intentionally scheduled rather than blocking every PR. Jobs select + # account/runtime/client cells from the two focused profiles. Non-Strong + # account definitions inject a deterministic replication delay so + # regional 404/session-recovery behavior is actually exercised. + - ${{ if or(eq(variables['Build.Reason'], 'Schedule'), endsWith(variables['Build.DefinitionName'], '- weekly')) }}: + - Name: Cosmos_e2e_consistency + Path: sdk/cosmos/e2e-consistency-matrix.json + Selection: all + GenerateVMJobs: true + - Name: Cosmos_e2e_read_consistency_overrides + Path: sdk/cosmos/e2e-read-consistency-override-matrix.json + Selection: all + GenerateVMJobs: true # Cosmos_live_test runs against fixed, self-owned accounts (see # sdk/cosmos/eng/pipelines/README.md) instead of resources deployed fresh each run, # so it lives in FixedAccountMatrixConfigs rather than LiveTestMatrixConfigs. diff --git a/sdk/cosmos/docs/README.md b/sdk/cosmos/docs/README.md index 6b50eef8b9..4a8bb19fe1 100644 --- a/sdk/cosmos/docs/README.md +++ b/sdk/cosmos/docs/README.md @@ -60,6 +60,7 @@ directly by Rust source remain with their crates. | 0025 | Throughput control (`specs/0025-throughput-control.md`) | | 0026 | Session consistency (`specs/0026-session-consistency.md`) | | 0027 | Hosted emulator (`specs/0027-hosted-emulator.md`) | +| 0028 | Functional E2E testing (`specs/0028-functional-e2e-testing.md`) | ## Architecture decision records diff --git a/sdk/cosmos/docs/specs/0011-gateway-v2.md b/sdk/cosmos/docs/specs/0011-gateway-v2.md index d54fa91623..99cf95c5ce 100644 --- a/sdk/cosmos/docs/specs/0011-gateway-v2.md +++ b/sdk/cosmos/docs/specs/0011-gateway-v2.md @@ -376,7 +376,11 @@ Sources, highest precedence first: 4. Client-level `ConsistencyLevel` 5. Account default consistency (no header / no token emitted; backend applies its default) -`ReadConsistencyStrategy::Default` at any level is a pass-through — falls through to the next source. Write operations skip steps 1 and 3 entirely (RCS is read-only); writes resolve from steps 2/4/5. +An absent `ReadConsistencyStrategy` value falls through to the next source. An explicitly +configured `ReadConsistencyStrategy::Default` terminates strategy resolution at that layer and +resets the read to consistency-level or account behavior; it masks lower-precedence RCS values +without emitting an RCS header or token. Write operations skip steps 1 and 3 entirely (RCS is +read-only); writes resolve from steps 2/4/5. ##### Dual-header rejection rule diff --git a/sdk/cosmos/docs/specs/0026-session-consistency.md b/sdk/cosmos/docs/specs/0026-session-consistency.md index 10deb233ee..4b4331a0ae 100644 --- a/sdk/cosmos/docs/specs/0026-session-consistency.md +++ b/sdk/cosmos/docs/specs/0026-session-consistency.md @@ -201,17 +201,32 @@ knobs. The pipeline computes, per attempt: ```text -automatic_session_management_effective = - partition_key_range_cache_enabled - && !session_capturing_disabled - && read_consistency_strategy.is_session_effective(account_default) +session_token_resolution_strategy = + read_consistency_strategy if operation is a read + Default otherwise + +automatic_session_token_resolution_effective = + partition_key_range_cache_enabled + && !session_capturing_disabled + && (operation is a read + || operation is a batch + || account has multiple write locations) + && session_token_resolution_strategy.is_session_effective(account_default) + +automatic_session_token_capture_effective = + partition_key_range_cache_enabled + && !session_capturing_disabled ``` `is_session_effective` is true when the strategy is `Session`, or when the strategy is `Default` and the account default consistency level is `Session`. `Eventual`, `LatestCommitted`, and `GlobalStrong` deliberately leave the session -lane, so the pipeline neither resolves cached tokens nor captures response -tokens. +lane for reads. Capture is independent of consistency so a later Session read +can use tokens returned by earlier operations on Strong, Bounded Staleness, +Consistent Prefix, or Eventual accounts. Ordinary single-write operations do not +automatically attach cached tokens. Batches and writes on multi-write accounts +attach them when the account default consistency is Session. Explicit +per-operation tokens remain authoritative on every topology. `session_capturing_disabled` is a single switch that turns off *both* automatic halves — no cache-based attach and no capture. Explicit per-operation tokens @@ -508,9 +523,10 @@ formatting of driver state. `driver/pipeline/operation_pipeline.rs` and `driver/pipeline/retry_evaluation.rs`. - **End-to-end (in-memory emulator)** — `azure_data_cosmos/tests/in_memory_emulator_tests/session_token.rs` observes the - outgoing `x-ms-session-token` header to prove capture-then-resolve, cache - advance across writes, caller-token precedence, and the negative controls - (Eventual consistency, capturing disabled, empty cache). + outgoing `x-ms-session-token` header to prove consistency-independent capture, + Session-read resolution, single-write omission, cache advance across writes, + caller-token precedence, and the negative controls (Eventual reads, capturing + disabled, empty cache). - **Cross-backend** — dual-backend tests compare response session tokens between the in-memory emulator and a real account, which is what keeps the emulator's modeled contract (§1.1) honest. diff --git a/sdk/cosmos/docs/specs/0028-functional-e2e-testing.md b/sdk/cosmos/docs/specs/0028-functional-e2e-testing.md new file mode 100644 index 0000000000..967ed5d0a2 --- /dev/null +++ b/sdk/cosmos/docs/specs/0028-functional-e2e-testing.md @@ -0,0 +1,391 @@ +# Cosmos SDK Functional E2E Testing + +**Status:** Draft +**Date:** 2026-09-11 +**Crates:** `azure_data_cosmos`, `azure_data_cosmos_driver`, +`azure_data_cosmos_emulator` + +## Purpose + +This plan introduces a dedicated functional end-to-end test suite for the Rust +Cosmos DB SDK. The suite defines and validates behavior observable through the +public `azure_data_cosmos` API while exercising the real driver, routing, +retry, session, diagnostics, and transport paths underneath it. + +The suite runs against three backends: + +- the hosted in-memory emulator through Gateway V1; +- the hosted in-memory emulator through Gateway V2; and +- Azure Cosmos DB live accounts where service fidelity is required. + +The hosted emulator provides deterministic setup, replication delay, fault, and +topology control. Live accounts remain the fidelity reference. Neither backend +replaces the other. + +The work is delivered in five sequentially reviewed and merged pull requests. +Development of later pull requests may proceed on dependent branches while an +earlier pull request is under review, but each pull request is rebased onto the +updated `main` and reviewed independently after its predecessor merges. + +## Goals + +- Validate Rust SDK functional behavior through public SDK APIs only. +- Exercise driver behavior implicitly through the supported Rust SDK surface. +- Cover positive and negative operation behavior, configuration precedence, + consistency, retries, fault injection, availability, topology transitions, + split/merge, partition migration, and diagnostics. +- Run deterministic behavior against the hosted emulator through Gateway V1 + and Gateway V2. +- Validate service fidelity through targeted and eventually comprehensive live + account execution. +- Keep scenario identities, requirements, setup profiles, backend + applicability, and implementation references machine-readable and + language-neutral. +- Keep executable behavior and assertions source-native, type checked, and easy + for humans to review. +- Allow Java, .NET, Python, and other SDKs to implement the same semantic + scenario IDs without prescribing language-specific APIs. +- Keep individual CI shards within a target of 15–20 minutes. + +## Non-goals + +- The JSON catalog is not a general-purpose test language or interpreter. +- Tests do not call the Rust driver directly to validate product behavior. +- The hosted emulator is not treated as proof of complete service fidelity. +- The suite does not duplicate every unit, component, or protocol conformance + test already present in the SDK or driver. +- The suite does not standardize incidental timing, opaque IDs, exact request + charge, error text, or serialized diagnostics. +- Cross-language reuse does not require identical source implementations. + +## Test architecture + +### Ownership boundary + +The suite deliberately separates reusable configuration data from executable +behavior. + +| Owner | Contents | +| --- | --- | +| Scenario JSON | Stable ID, title, requirement, maturity, tags, precedents, applicable setup profiles, backend applicability, fidelity, and required backend capabilities. | +| Profile JSON | Account topology and consistency, replication behavior, runtime configuration, and client configuration. | +| SDK implementation map | Scenario ID to source-native test implementation and implementation status. | +| Rust source | Fixtures, operation-level options, generated cases, sequencing, retries, concurrency, state validation, diagnostics, and assertions. | +| Pipeline matrices | Backend and setup-profile selection, scheduling, and sharding. | +| Hosted emulator management API | Deterministic external controls for emulator-only orchestration. | + +This boundary prevents the catalog from becoming a second programming language. +Operation-specific dimensions such as `ReadConsistencyStrategy`, patch +strategy, availability strategy, continuation behavior, fault rules, and batch +options remain beside the test that uses them. + +### Scenario catalog + +The language-neutral catalog lives under `sdk/cosmos/e2e_tests`: + +```text +e2e_tests/ +├── schema/ # Scenario and profile JSON Schemas +├── profiles/ # Reusable account/runtime/client setup +├── scenarios/ # Concise semantic scenario metadata +├── implementations/ # SDK-specific implementation maps +└── README.md +``` + +Scenario IDs are permanent. A semantic change creates a new ID; established IDs +are deprecated rather than repurposed. + +Each scenario declares backend applicability: + +- `required`: the scenario must execute and pass on the backend; +- `supported`: expected to work, but not yet enforced in every pipeline; +- `simulated`: deterministic emulator coverage without a full service-fidelity + claim; and +- `notApplicable`: intentionally unavailable, with a reason. + +An unavailable required capability is a configuration failure, not a silent +skip. + +### Setup profiles + +A profile describes account, runtime, and client axes. Pipelines select a +profile and, when an axis has multiple definitions, one definition from each +axis through: + +- `AZURE_COSMOS_E2E_PROFILE`; +- `AZURE_COSMOS_E2E_ACCOUNT`; +- `AZURE_COSMOS_E2E_RUNTIME`; and +- `AZURE_COSMOS_E2E_CLIENT`. + +For hosted-emulator jobs, setup translates the selected account definition into +an emulator configuration. Rust fixtures apply the selected runtime and client +configuration through public builders. + +For live jobs, a profile is matched to a fixed or provisioned account whose +actual consistency, regions, write mode, and feature configuration satisfy the +profile. Live setup must not silently pretend to apply an account property that +the selected account does not have. + +### Source-native implementations + +Rust implementations live under +`sdk/cosmos/azure_data_cosmos/tests/e2e_test_cases`. Each scenario has its own +module. A module should be optimized for human review in this order: + +1. test entry point and selected profile expansion; +2. visible arrange, act, and assert lifecycle; +3. operation case definitions with descriptive behavioral names; +4. selected-profile translation; +5. retry or orchestration mechanics; and +6. focused unit tests for generated case matrices. + +Helpers should remove mechanical noise without hiding what behavior is tested +or what is asserted. + +### Public SDK boundary + +Product operations use only `azure_data_cosmos` public APIs. The driver is +exercised transitively and must not become the assertion surface for Rust SDK +functional contracts. + +Emulator-only orchestration may call the hosted emulator's external management +REST API. This keeps topology and fault control language-neutral without +exposing Rust internals to test implementations. + +## Assertion policy + +Tests assert stable, customer-observable behavior: + +- HTTP status and Cosmos substatus; +- typed error classification; +- returned models and item identity; +- resource state and operation side effects; +- ordering, uniqueness, continuation, and no-loss/no-duplication invariants; +- transaction atomicity; +- operation-level configuration precedence; +- terminal retry and failover behavior; and +- selected structured diagnostics such as operation, effective status, + attempts, and contacted regions. + +Transient service states are permitted rather than required when live timing is +nondeterministic. Tests accept only explicitly listed transient status/substatus +combinations and retry until a terminal state or a bounded deadline. Immediate +terminal success is always valid when replication has already converged. + +Exact first-attempt timing should be asserted only when a deterministic +emulator control makes it part of the test contract. + +## Execution tiers + +### Pull-request smoke + +- Small, stable functional subset. +- Hosted emulator Gateway V1 and Gateway V2. +- Fast enough to block normal pull requests. +- Includes catalog/schema validation and implementation-map consistency. + +### Scheduled hosted-emulator matrices + +- Expanded account/runtime/client profiles. +- Consistency and configuration-precedence matrices. +- Fault and topology scenarios requiring deterministic control. +- Split into shards with a target maximum duration of 15–20 minutes. + +### Live account validation + +- Targeted differential baselines initially. +- Comprehensive eligible-scenario coverage before final promotion. +- Fixed accounts where stable topology is required; provisioned resources where + isolation or specialized account configuration is required. +- Separate shards by account configuration and scenario cost. + +## Delivery plan + +### PR1 — Foundation and executable smoke + +**Status:** Implemented; under review. + +Scope: + +- scenario and profile JSON Schemas; +- concise language-neutral scenario metadata; +- reusable account/runtime/client profiles; +- Rust implementation map and catalog validation; +- source-native Rust E2E test target using public SDK APIs; +- one source module per scenario; +- hosted emulator capability discovery; +- hosted Gateway V1 and Gateway V2 CI execution; +- initial consistency and layered runtime/client/operation default matrices; +- scheduled expanded emulator profiles; and +- initial scenarios: + - client bootstrap; + - item create/read/replace/delete lifecycle; + - upsert create/update behavior; + - duplicate create conflict; + - missing ID and wrong partition key; + - optimistic concurrency with ETags; + - parameterized filtering and ordering; + - invalid query syntax; and + - critical success/error diagnostics. + +PR1 establishes architecture and representative execution. An `azureLive` +applicability value of `supported` records scenarios intended for live +validation but does not yet mean that all such scenarios are enforced by live +CI. + +### PR2 — Core operations and emulator fidelity + +Scope: + +- database and container control-plane lifecycle; +- item CRUD breadth across partition-key variants; +- Hash V1, Hash V2, and hierarchical partition keys; +- string, numeric, Boolean, null, and undefined partition-key behavior where + supported; +- item ID, partition-key, payload-size, and unique-key validation; +- query and read-feed pagination; +- feed ranges and continuation handling; +- change-feed modes and resume behavior; +- transactional batch success, failure, and atomicity; +- patch operation and strategy behavior; +- exact post-operation state assertions; +- expanded negative status/substatus coverage; +- emulator support needed for those scenarios; and +- selected live differential baselines used to confirm emulator fidelity. + +PR2 should add emulator behavior only when required by a concrete SDK scenario. +Differences discovered against live accounts must be fixed, explicitly modeled +as simulated, or documented as not applicable. + +### PR3 — Configuration, consistency, and resilience + +Scope: + +- primary and backup bootstrap paths; +- supported authentication modes; +- runtime, client, account, and operation configuration precedence; +- Gateway V1/Gateway V2 selection; +- binary encoding and text-response combinations; +- connection-pool and preferred-region behavior; +- all account consistency levels across applicable read operations; +- `ReadConsistencyStrategy` beyond the initial lifecycle matrix; +- session capture, explicit tokens, disabled management, and cross-page token + behavior; +- throttling, transport, service, and timeout retries; +- end-to-end operation deadlines; +- cross-region hedging; +- public fault-injection behavior; +- stable diagnostics for representative retry and failure paths; and +- representative OpenTelemetry metrics, spans, and logs. + +PR3 keeps fault predicates and operation-specific configuration in source code. +Only reusable environment setup belongs in profile JSON. + +### PR4 — Dynamic topology and availability + +Scope: + +- runtime region add, remove, offline, online, and recovery; +- write-region failover and failback; +- deterministic partition migration simulation; +- physical partition split and merge; +- manually controlled transition phases; +- operations issued before, during, and after each transition; +- stale routing and address-cache refresh; +- replication pause, resume, and convergence; +- per-partition automatic failover; +- per-partition circuit breaker behavior; +- hedging interaction with topology changes; +- query and change-feed continuation across topology changes; and +- no-loss, no-duplication, ordering, and bounded-recovery assertions. + +Dynamic controls are invoked through the hosted emulator management API. +Equivalent live scenarios are added only where Azure can expose the transition +safely and repeatably. + +### PR5 — Full matrix, live enforcement, and promotion + +Scope: + +- complete live-account setup profiles; +- fixed-account and provisioned-account E2E matrices; +- Azure Live execution for every eligible scenario; +- promotion of validated `azureLive` applicability from `supported` to + `required`; +- complete diagnostics and OpenTelemetry audit; +- generated pull-request and scheduled shard manifests; +- JUnit and scenario/profile/backend coverage reports; +- runtime measurement and shard calibration; +- 15–20 minute maximum shard target; +- retry and quarantine policy for environmental failures; +- documentation for local, hosted-emulator, and live execution; and +- cross-SDK portability review with Java, .NET, and Python implementations. + +PR5 is the release-readiness gate for the suite rather than a place to add +large amounts of previously untested functionality. + +## Pull-request workflow + +The five pull requests are reviewed and merged sequentially: + +1. merge the current pull request; +2. update local `main` from `origin/main`; +3. replay the next pull request's commits onto updated `main`; +4. verify its diff contains only that pull request's scope; +5. rerun its validation; and +6. open it for review. + +Development may proceed in parallel on dependent local branches. The repository +does not depend on GitHub stacked-PR automation, and reviewers see one +independent pull request at a time. + +## Quality gates + +Every pull request must: + +- validate all scenario and profile documents against their schemas; +- validate every scenario ID has exactly one Rust implementation-map entry; +- validate every active implementation maps to a discovered Rust test; +- validate pipeline matrices cover their declared profile axes; +- run `cargo fmt`; +- build affected crates; +- run Clippy with all features and targets; +- build Rust documentation without warnings; +- run relevant offline tests; +- run applicable hosted-emulator Gateway V1 and Gateway V2 tests; +- run required live tests for that phase; +- pass Markdown lint and spelling checks; and +- avoid silent skipping of required scenarios or capabilities. + +## Completion criteria + +The roadmap is complete when: + +- all planned functional domains have stable scenario IDs and active Rust + implementations; +- the pull-request smoke set is reliable and bounded; +- scheduled hosted-emulator matrices cover deterministic fault and topology + behavior through Gateway V1 and Gateway V2; +- every eligible scenario runs against appropriate live account profiles; +- required backend coverage is enforced rather than informational; +- CI reports scenario/profile/backend coverage; +- no shard exceeds the 15–20 minute target under normal conditions; and +- the catalog is usable by peer SDK teams without requiring them to adopt Rust + APIs or a shared behavioral interpreter. + +## Related documents + +- Hosted emulator: `specs/0027-hosted-emulator.md` +- In-memory emulator: `specs/0021-in-memory-emulator.md` +- Operation and transport pipelines: + `specs/0005-operation-and-transport-pipelines.md` +- Error codes and retries: `specs/0006-error-codes-and-retries.md` +- Partition-level failover: `specs/0008-partition-level-failover.md` +- Cross-region hedging: `specs/0009-cross-region-hedging.md` +- Gateway V2: `specs/0011-gateway-v2.md` +- Feed operations and dataflow: + `specs/0012-feed-operations-and-dataflow.md` +- Patch handler: `specs/0017-patch-handler.md` +- Diagnostics contract: `specs/0018-diagnostics-contract.md` +- Fault injection: `specs/0024-fault-injection.md` +- Session consistency: `specs/0026-session-consistency.md` diff --git a/sdk/cosmos/e2e-consistency-matrix.json b/sdk/cosmos/e2e-consistency-matrix.json new file mode 100644 index 0000000000..a8f2164328 --- /dev/null +++ b/sdk/cosmos/e2e-consistency-matrix.json @@ -0,0 +1,44 @@ +{ + "displayNames": { + "inmemory-v1": "gateway_v1", + "inmemory-v2": "gateway_v2", + "lifecycleConsistencyMatrix": "consistency", + "unset": "default", + "strong": "strong_2_regions", + "boundedStaleness": "bounded_staleness_2_regions_delayed", + "session": "session_2_regions_delayed", + "consistentPrefix": "consistent_prefix_2_regions_delayed", + "eventual": "eventual_2_regions_delayed" + }, + "matrix": { + "Agent": { + "ubuntu": { + "OSVmImage": "env:LINUXVMIMAGE", + "Pool": "env:LINUXPOOL" + } + }, + "RustToolchainName": [ + "stable" + ], + "AZURE_COSMOS_EMULATOR_FLAVOR": [ + "inmemory-v1", + "inmemory-v2" + ], + "AZURE_COSMOS_E2E_PROFILE": [ + "lifecycleConsistencyMatrix" + ], + "AZURE_COSMOS_E2E_ACCOUNT": [ + "strong", + "boundedStaleness", + "session", + "consistentPrefix", + "eventual" + ], + "AZURE_COSMOS_E2E_RUNTIME": [ + "unset" + ], + "AZURE_COSMOS_E2E_CLIENT": [ + "unset" + ] + } +} diff --git a/sdk/cosmos/e2e-read-consistency-override-matrix.json b/sdk/cosmos/e2e-read-consistency-override-matrix.json new file mode 100644 index 0000000000..2769b381bd --- /dev/null +++ b/sdk/cosmos/e2e-read-consistency-override-matrix.json @@ -0,0 +1,41 @@ +{ + "displayNames": { + "inmemory-v1": "gateway_v1", + "inmemory-v2": "gateway_v2", + "readConsistencyOverrideMatrix": "rcs_override", + "unset": "default", + "eventual": "eventual", + "session": "session", + "latestCommitted": "latest_committed" + }, + "matrix": { + "Agent": { + "ubuntu": { + "OSVmImage": "env:LINUXVMIMAGE", + "Pool": "env:LINUXPOOL" + } + }, + "RustToolchainName": [ + "stable" + ], + "AZURE_COSMOS_EMULATOR_FLAVOR": [ + "inmemory-v1", + "inmemory-v2" + ], + "AZURE_COSMOS_E2E_PROFILE": [ + "readConsistencyOverrideMatrix" + ], + "AZURE_COSMOS_E2E_ACCOUNT": [ + "session" + ], + "AZURE_COSMOS_E2E_RUNTIME": [ + "unset", + "eventual", + "session" + ], + "AZURE_COSMOS_E2E_CLIENT": [ + "unset", + "latestCommitted" + ] + } +} diff --git a/sdk/cosmos/e2e_tests/README.md b/sdk/cosmos/e2e_tests/README.md new file mode 100644 index 0000000000..f80a33a190 --- /dev/null +++ b/sdk/cosmos/e2e_tests/README.md @@ -0,0 +1,94 @@ +# Cosmos SDK E2E test catalog + +This directory contains language-neutral metadata and reusable setup profiles +for Cosmos DB SDK E2E tests. Test behavior remains in each SDK's source code so +it is type checked, directly reviewable, and idiomatic for that language. + +## Layout + +- `schema/` contains JSON Schemas for scenario metadata and setup profiles. +- `profiles/` contains reusable account, runtime, and client configurations. +- `scenarios/` describes the behavior covered by each stable scenario ID. +- `implementations/` maps scenario IDs to SDK-specific test implementations. + +Scenario IDs are permanent. If an established scenario changes meaning, add a +new scenario and deprecate the old one rather than reusing its ID. + +## Scenario metadata + +A scenario document intentionally stays concise. It records: + +- a stable ID, title, and normative requirement; +- maturity, tags, and prior service or SDK test precedents; +- the setup profiles under which the implementation should run; +- backend applicability, fidelity, and required capabilities. + +Scenario JSON does not describe executable steps, operation-level options, +fixtures, retries, diagnostics, or assertions. Those concerns vary by SDK and +scenario and belong in the source-native implementation. This prevents the +catalog from becoming a second programming language and lets compiler and IDE +tooling validate the test logic. + +## Setup profiles + +A profile defines three setup axes: `accounts`, `runtimes`, and `clients`. +Pipeline matrices select one profile and one value from each axis through: + +- `AZURE_COSMOS_E2E_PROFILE`; +- `AZURE_COSMOS_E2E_ACCOUNT`; +- `AZURE_COSMOS_E2E_RUNTIME`; +- `AZURE_COSMOS_E2E_CLIENT`. + +The hosted emulator setup converts the selected account definition into an +emulator configuration. SDK fixtures apply the selected runtime and client +configuration. A test runs only when its scenario metadata includes the +selected profile. + +The item lifecycle implementation uses three profiles: + +- `smokeTests` for the default PR smoke case on any supported backend; +- `lifecycleConsistencyMatrix` for five account consistency configurations; +- `readConsistencyOverrideMatrix` for runtime and client default precedence. + +Operation-level `ReadConsistencyStrategy` cases, session-token choices, +acceptable transient statuses, retry deadlines, and assertions are defined in +the Rust test source. Future operation-specific dimensions follow the same +pattern instead of expanding the profile schema. + +The scheduled matrices in `e2e-consistency-matrix.json` and +`e2e-read-consistency-override-matrix.json` select every setup cell through +Gateway V1 and Gateway V2. Profile-driven jobs use the isolated `e2e` test +category. + +## Precedents and implementations + +The `precedents` array records prior evidence for expected behavior. A +precedent can point to a service specification or an existing Rust, Java, +.NET, or Python test. It is not an implementation registry. + +SDK implementations are tracked under `implementations/`. The Rust mapping +links every scenario ID to an executable test in +`azure_data_cosmos/tests/e2e_test_cases/`. Catalog validation fails if an +active mapping names a missing test, a scenario references an unknown setup +profile, or a scenario has no Rust mapping. Future SDKs can add their own map +without changing scenario meaning. + +## Source-native assertions + +The Rust implementations use only the public `azure_data_cosmos` surface for +product operations. Emulator orchestration uses the external management +endpoint. Concrete tests own resource fixtures, operation sequencing, data +invariants, HTTP status and substatus checks, diagnostics, retries, and cleanup. + +Error text, serialized diagnostics, opaque identifiers, exact request charge, +and incidental timing should not become stable E2E assertions. + +Backend applicability values are: + +- `required`: the scenario must execute and pass; +- `supported`: expected to work but not required in every pipeline; +- `simulated`: deterministic emulator behavior without a service-fidelity claim; +- `notApplicable`: intentionally unavailable, with a reason. + +An unavailable required capability is a test configuration failure, never a +silent skip. diff --git a/sdk/cosmos/e2e_tests/implementations/rust.json b/sdk/cosmos/e2e_tests/implementations/rust.json new file mode 100644 index 0000000000..60350fcd72 --- /dev/null +++ b/sdk/cosmos/e2e_tests/implementations/rust.json @@ -0,0 +1,57 @@ +{ + "specVersion": "1.0", + "sdk": "rust", + "testTarget": "e2e_tests", + "scenarios": [ + { + "id": "management.capabilities", + "test": "capabilities::capability_document_is_versioned", + "status": "active" + }, + { + "id": "bootstrap.primary-success", + "test": "bootstrap_primary::bootstrap_primary_endpoint", + "status": "active" + }, + { + "id": "item.lifecycle", + "test": "item_lifecycle::crud_lifecycle", + "status": "active" + }, + { + "id": "item.upsert-create-update", + "test": "item_upsert::upsert_creates_then_updates", + "status": "active" + }, + { + "id": "item.create-conflict", + "test": "item_create_conflict::duplicate_create_preserves_original", + "status": "active" + }, + { + "id": "item.not-found-wrong-partition-key", + "test": "item_not_found::not_found_does_not_cross_partition_keys", + "status": "active" + }, + { + "id": "item.optimistic-concurrency", + "test": "item_optimistic_concurrency::stale_etag_preserves_successful_update", + "status": "active" + }, + { + "id": "query.parameterized-filter", + "test": "query_parameterized_filter::parameterized_query_filters_and_orders", + "status": "active" + }, + { + "id": "query.invalid-syntax", + "test": "query_invalid_syntax::invalid_query_is_not_an_empty_feed", + "status": "active" + }, + { + "id": "diagnostics.success-and-error", + "test": "diagnostics_success_and_error::diagnostics_cover_success_and_error", + "status": "active" + } + ] +} diff --git a/sdk/cosmos/e2e_tests/profiles/lifecycleConsistencyMatrix.json b/sdk/cosmos/e2e_tests/profiles/lifecycleConsistencyMatrix.json new file mode 100644 index 0000000000..d876167de1 --- /dev/null +++ b/sdk/cosmos/e2e_tests/profiles/lifecycleConsistencyMatrix.json @@ -0,0 +1,49 @@ +{ + "$schema": "../schema/profile.v1.json", + "specVersion": "1.0", + "id": "lifecycleConsistencyMatrix", + "accounts": [ + { + "id": "strong", + "writeMode": "single", + "consistency": "strong", + "regions": [{ "name": "East US" }, { "name": "West US" }], + "replication": { "minDelayMs": 0, "maxDelayMs": 0 }, + "perPartitionFailover": false + }, + { + "id": "boundedStaleness", + "writeMode": "single", + "consistency": "boundedStaleness", + "regions": [{ "name": "East US" }, { "name": "West US" }], + "replication": { "minDelayMs": 500, "maxDelayMs": 500 }, + "perPartitionFailover": false + }, + { + "id": "session", + "writeMode": "single", + "consistency": "session", + "regions": [{ "name": "East US" }, { "name": "West US" }], + "replication": { "minDelayMs": 500, "maxDelayMs": 500 }, + "perPartitionFailover": false + }, + { + "id": "consistentPrefix", + "writeMode": "single", + "consistency": "consistentPrefix", + "regions": [{ "name": "East US" }, { "name": "West US" }], + "replication": { "minDelayMs": 500, "maxDelayMs": 500 }, + "perPartitionFailover": false + }, + { + "id": "eventual", + "writeMode": "single", + "consistency": "eventual", + "regions": [{ "name": "East US" }, { "name": "West US" }], + "replication": { "minDelayMs": 500, "maxDelayMs": 500 }, + "perPartitionFailover": false + } + ], + "runtimes": [{ "id": "unset", "gatewayV2": "backendDefault", "ppcb": "disabled" }], + "clients": [{ "id": "unset", "binaryEncoding": "sdkDefault", "routing": "preferredRegions" }] +} diff --git a/sdk/cosmos/e2e_tests/profiles/readConsistencyOverrideMatrix.json b/sdk/cosmos/e2e_tests/profiles/readConsistencyOverrideMatrix.json new file mode 100644 index 0000000000..0237a6c6e0 --- /dev/null +++ b/sdk/cosmos/e2e_tests/profiles/readConsistencyOverrideMatrix.json @@ -0,0 +1,22 @@ +{ + "$schema": "../schema/profile.v1.json", + "specVersion": "1.0", + "id": "readConsistencyOverrideMatrix", + "accounts": [{ + "id": "session", + "writeMode": "single", + "consistency": "session", + "regions": [{ "name": "East US" }, { "name": "West US" }], + "replication": { "minDelayMs": 500, "maxDelayMs": 500 }, + "perPartitionFailover": false + }], + "runtimes": [ + { "id": "unset", "gatewayV2": "backendDefault", "ppcb": "disabled" }, + { "id": "eventual", "gatewayV2": "backendDefault", "ppcb": "disabled", "defaultReadConsistencyStrategy": "Eventual" }, + { "id": "session", "gatewayV2": "backendDefault", "ppcb": "disabled", "defaultReadConsistencyStrategy": "Session" } + ], + "clients": [ + { "id": "unset", "binaryEncoding": "sdkDefault", "routing": "preferredRegions" }, + { "id": "latestCommitted", "binaryEncoding": "sdkDefault", "routing": "preferredRegions", "defaultReadConsistencyStrategy": "LatestCommitted" } + ] +} diff --git a/sdk/cosmos/e2e_tests/profiles/smokeTests.json b/sdk/cosmos/e2e_tests/profiles/smokeTests.json new file mode 100644 index 0000000000..75185408d0 --- /dev/null +++ b/sdk/cosmos/e2e_tests/profiles/smokeTests.json @@ -0,0 +1,36 @@ +{ + "$schema": "../schema/profile.v1.json", + "specVersion": "1.0", + "id": "smokeTests", + "accounts": [ + { + "id": "sessionSingleRegion", + "writeMode": "single", + "consistency": "session", + "regions": [ + { + "name": "East US" + } + ], + "replication": { + "minDelayMs": 0, + "maxDelayMs": 0 + }, + "perPartitionFailover": false + } + ], + "runtimes": [ + { + "id": "sdkDefault", + "gatewayV2": "backendDefault", + "ppcb": "sdkDefault" + } + ], + "clients": [ + { + "id": "sdkDefault", + "binaryEncoding": "sdkDefault", + "routing": "proximity" + } + ] +} \ No newline at end of file diff --git a/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json b/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json new file mode 100644 index 0000000000..759bfc07bc --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/bootstrap/primary-success.json @@ -0,0 +1,39 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "bootstrap.primary-success", + "title": "A reachable primary endpoint initializes the client", + "requirement": "Building a client with a reachable account endpoint succeeds and permits a first data operation.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "rust", + "path": "sdk/cosmos/azure_data_cosmos/tests/emulator_tests/cosmos_items.rs", + "test": "item_crud" + } + ], + "profiles": [ + "smokeTests" + ], + "tags": [ + "prSmoke", + "bootstrap" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json b/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json new file mode 100644 index 0000000000..75dacb9670 --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/diagnostics/success-and-error.json @@ -0,0 +1,39 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "diagnostics.success-and-error", + "title": "Success and error paths expose critical diagnostics", + "requirement": "Public diagnostics identify the logical operation, effective status, activity, attempts, and contacted region on successful and failed operations.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "java", + "path": "sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosDiagnosticsE2ETest.java", + "test": "onlyCustomDiagnosticsHandler" + } + ], + "profiles": [ + "smokeTests" + ], + "tags": [ + "prSmoke", + "diagnostics" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json b/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json new file mode 100644 index 0000000000..dd7ec0a286 --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/items/create-conflict.json @@ -0,0 +1,40 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "item.create-conflict", + "title": "Creating a duplicate identity returns conflict", + "requirement": "A duplicate create returns 409 and leaves the original document unchanged.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "python", + "path": "sdk/cosmos/azure-cosmos/tests/test_crud.py", + "test": "duplicate document create" + } + ], + "profiles": [ + "smokeTests" + ], + "tags": [ + "prSmoke", + "item", + "negative" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json b/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json new file mode 100644 index 0000000000..c8ae753528 --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/items/lifecycle.json @@ -0,0 +1,41 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "item.lifecycle", + "title": "An item completes its CRUD lifecycle", + "requirement": "Create, read, replace, and delete preserve the addressed item identity and expose stable statuses.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "java", + "path": "sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosItemTest.java", + "test": "createItem/readItem/replaceItem/deleteItem" + } + ], + "profiles": [ + "smokeTests", + "lifecycleConsistencyMatrix", + "readConsistencyOverrideMatrix" + ], + "tags": [ + "prSmoke", + "item" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json b/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json new file mode 100644 index 0000000000..fa927903cd --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/items/not-found-wrong-partition-key.json @@ -0,0 +1,40 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "item.not-found-wrong-partition-key", + "title": "Missing identity and wrong partition key return not found", + "requirement": "Point reads do not leak an item across partition-key identities and return 404 without mutation.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "rust", + "path": "sdk/cosmos/azure_data_cosmos_driver/tests/in_memory_emulator_tests/error_cases.rs", + "test": "read_nonexistent_404" + } + ], + "profiles": [ + "smokeTests" + ], + "tags": [ + "prSmoke", + "item", + "negative" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json b/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json new file mode 100644 index 0000000000..e07bfc3a5b --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/items/optimistic-concurrency.json @@ -0,0 +1,40 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "item.optimistic-concurrency", + "title": "A stale ETag cannot overwrite a newer item", + "requirement": "If-Match accepts the current ETag, rejects a stale ETag with 412, and preserves the successful update.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "dotnet", + "path": "Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/CosmosItemTests.cs", + "test": "ItemRequestOptionAccessConditionTest" + } + ], + "profiles": [ + "smokeTests" + ], + "tags": [ + "prSmoke", + "item", + "negative" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json b/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json new file mode 100644 index 0000000000..04c2ea557e --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/items/upsert-create-update.json @@ -0,0 +1,39 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "item.upsert-create-update", + "title": "Upsert creates then updates one identity", + "requirement": "Upsert returns 201 for a missing identity and 200 when replacing it, without creating a duplicate.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "dotnet", + "path": "Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/CosmosItemTests.cs", + "test": "UpsertItemTest" + } + ], + "profiles": [ + "smokeTests" + ], + "tags": [ + "prSmoke", + "item" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json b/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json new file mode 100644 index 0000000000..db52a58d13 --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/management/capabilities.json @@ -0,0 +1,44 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "management.capabilities", + "title": "The emulator advertises a versioned capability document", + "requirement": "An external SDK runner can discover protocol and management capabilities without using Rust internals.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "rust", + "path": "sdk/cosmos/azure_data_cosmos_emulator/src/management.rs", + "test": "capabilities_report_configured_protocols" + } + ], + "profiles": [ + "smokeTests" + ], + "tags": [ + "prSmoke", + "management" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "capabilities" + ] + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "capabilities", + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "notApplicable", + "fidelity": "none", + "reason": "The capability document is specific to emulator E2E test orchestration." + } + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json b/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json new file mode 100644 index 0000000000..a35375c96a --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/queries/invalid-syntax.json @@ -0,0 +1,40 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "query.invalid-syntax", + "title": "Invalid query syntax returns bad request", + "requirement": "A deferred query failure is surfaced as a typed 400 error rather than an empty feed.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "dotnet", + "path": "Microsoft.Azure.Cosmos/tests/Microsoft.Azure.Cosmos.EmulatorTests/CosmosItemTests.cs", + "test": "NegativeQueryTest" + } + ], + "profiles": [ + "smokeTests" + ], + "tags": [ + "prSmoke", + "query", + "negative" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + } +} diff --git a/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json b/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json new file mode 100644 index 0000000000..977b4b2bcd --- /dev/null +++ b/sdk/cosmos/e2e_tests/scenarios/queries/parameterized-filter.json @@ -0,0 +1,39 @@ +{ + "$schema": "../../schema/scenario.v1.json", + "specVersion": "1.0", + "id": "query.parameterized-filter", + "title": "A parameterized partition query filters and orders results", + "requirement": "Query parameters are bound without string substitution and deterministic ORDER BY results preserve order.", + "maturity": "candidate", + "precedents": [ + { + "sdk": "java", + "path": "sdk/cosmos/azure-cosmos-tests/src/test/java/com/azure/cosmos/CosmosItemTest.java", + "test": "queryItems" + } + ], + "profiles": [ + "smokeTests" + ], + "tags": [ + "prSmoke", + "query" + ], + "backends": { + "hostedEmulatorGatewayV1": { + "applicability": "required", + "fidelity": "full" + }, + "hostedEmulatorGatewayV2": { + "applicability": "required", + "fidelity": "full", + "requires": [ + "gatewayV2" + ] + }, + "azureLive": { + "applicability": "supported", + "fidelity": "full" + } + } +} diff --git a/sdk/cosmos/e2e_tests/schema/profile.v1.json b/sdk/cosmos/e2e_tests/schema/profile.v1.json new file mode 100644 index 0000000000..d2bae51f12 --- /dev/null +++ b/sdk/cosmos/e2e_tests/schema/profile.v1.json @@ -0,0 +1,195 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://azure.github.io/cosmos-sdk-e2e-tests/profile.v1.json", + "title": "Cosmos SDK E2E test profile", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "specVersion", + "id", + "accounts", + "runtimes", + "clients" + ], + "properties": { + "$schema": { + "const": "../schema/profile.v1.json" + }, + "specVersion": { + "const": "1.0" + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9]*$" + }, + "accounts": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/account" + } + }, + "runtimes": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/runtime" + } + }, + "clients": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/client" + } + } + }, + "$defs": { + "readConsistencyStrategy": { + "enum": [ + "Default", + "Eventual", + "Session", + "LatestCommitted", + "GlobalStrong" + ] + }, + "account": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "writeMode", + "consistency", + "regions", + "replication", + "perPartitionFailover" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9]*$" + }, + "writeMode": { + "enum": [ + "single", + "multi" + ] + }, + "consistency": { + "enum": [ + "strong", + "boundedStaleness", + "session", + "consistentPrefix", + "eventual" + ] + }, + "regions": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + } + } + } + }, + "replication": { + "type": "object", + "additionalProperties": false, + "required": [ + "minDelayMs", + "maxDelayMs" + ], + "properties": { + "minDelayMs": { + "type": "integer", + "minimum": 0 + }, + "maxDelayMs": { + "type": "integer", + "minimum": 0 + } + } + }, + "perPartitionFailover": { + "type": "boolean" + } + } + }, + "runtime": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "gatewayV2", + "ppcb" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9]*$" + }, + "gatewayV2": { + "enum": [ + "enabled", + "disabled", + "backendDefault" + ] + }, + "ppcb": { + "enum": [ + "enabled", + "disabled", + "sdkDefault" + ] + }, + "defaultReadConsistencyStrategy": { + "$ref": "#/$defs/readConsistencyStrategy" + } + } + }, + "client": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "binaryEncoding", + "routing" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z][a-zA-Z0-9]*$" + }, + "binaryEncoding": { + "enum": [ + "enabled", + "disabled", + "sdkDefault" + ] + }, + "routing": { + "enum": [ + "proximity", + "preferredRegions", + "accountOrder" + ] + }, + "defaultReadConsistencyStrategy": { + "$ref": "#/$defs/readConsistencyStrategy" + } + } + } + } +} diff --git a/sdk/cosmos/e2e_tests/schema/scenario.v1.json b/sdk/cosmos/e2e_tests/schema/scenario.v1.json new file mode 100644 index 0000000000..0bdc69bff4 --- /dev/null +++ b/sdk/cosmos/e2e_tests/schema/scenario.v1.json @@ -0,0 +1,159 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://azure.github.io/cosmos-sdk-e2e-tests/scenario.v1.json", + "title": "Cosmos SDK E2E test scenario metadata", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "specVersion", + "id", + "title", + "requirement", + "maturity", + "precedents", + "profiles", + "tags", + "backends" + ], + "properties": { + "$schema": { + "const": "../../schema/scenario.v1.json" + }, + "specVersion": { + "const": "1.0" + }, + "id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(\\.[a-z][a-z0-9-]*)+$" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "requirement": { + "type": "string", + "minLength": 1 + }, + "maturity": { + "enum": [ + "candidate", + "stable", + "deprecated" + ] + }, + "precedents": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/reference" + } + }, + "profiles": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "tags": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "backends": { + "type": "object", + "additionalProperties": false, + "required": [ + "hostedEmulatorGatewayV1", + "hostedEmulatorGatewayV2", + "azureLive" + ], + "properties": { + "hostedEmulatorGatewayV1": { + "$ref": "#/$defs/backend" + }, + "hostedEmulatorGatewayV2": { + "$ref": "#/$defs/backend" + }, + "azureLive": { + "$ref": "#/$defs/backend" + } + } + } + }, + "$defs": { + "reference": { + "type": "object", + "additionalProperties": false, + "required": [ + "sdk", + "path", + "test" + ], + "properties": { + "sdk": { + "enum": [ + "service", + "rust", + "java", + "dotnet", + "python" + ] + }, + "path": { + "type": "string", + "minLength": 1 + }, + "test": { + "type": "string", + "minLength": 1 + } + } + }, + "backend": { + "type": "object", + "additionalProperties": false, + "required": [ + "applicability", + "fidelity" + ], + "properties": { + "applicability": { + "enum": [ + "required", + "supported", + "simulated", + "notApplicable" + ] + }, + "fidelity": { + "enum": [ + "full", + "partial", + "simulated", + "none" + ] + }, + "reason": { + "type": "string", + "minLength": 1 + }, + "requires": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + } +} diff --git a/sdk/cosmos/eng/scripts/Invoke-CosmosTestCleanup.ps1 b/sdk/cosmos/eng/scripts/Invoke-CosmosTestCleanup.ps1 index 912cebabf6..1254a92100 100644 --- a/sdk/cosmos/eng/scripts/Invoke-CosmosTestCleanup.ps1 +++ b/sdk/cosmos/eng/scripts/Invoke-CosmosTestCleanup.ps1 @@ -12,12 +12,31 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { $hostProcess = Get-Process -Id ([int]$env:AZURE_COSMOS_INMEMORY_EMULATOR_PID) -ErrorAction SilentlyContinue } else { - $hostProcess = Get-Process azure_data_cosmos_emulator -ErrorAction SilentlyContinue + $hostProcess = $null } if ($hostProcess) { $hostProcess | Stop-Process -Force -ErrorAction SilentlyContinue $hostProcess | Wait-Process -Timeout 10 -ErrorAction SilentlyContinue } + if ($env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY) { + $ownershipMarker = ([System.IO.Path]::Combine( + $env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY, + '.azure-data-cosmos-emulator-run')) + $ownedRunId = if (Test-Path -LiteralPath $ownershipMarker) { + Get-Content -LiteralPath $ownershipMarker -Raw + } + if ($env:AZURE_COSMOS_INMEMORY_RUN_ID -and + $ownedRunId -eq $env:AZURE_COSMOS_INMEMORY_RUN_ID) { + Remove-Item ` + -LiteralPath $env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY ` + -Recurse ` + -Force ` + -ErrorAction SilentlyContinue + } + else { + LogWarning "Refusing to delete unowned emulator run directory '$env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY'." + } + } } elseif ($IsWindows) { @@ -93,11 +112,17 @@ if ($env:AZURE_COSMOS_CONNECTION_STRING -eq "emulator" -or $env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { $env:AZURE_COSMOS_CONNECTION_STRING = $null } +if ($env:AZURE_COSMOS_E2E_PROFILE -and + $env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { + $env:AZURE_COSMOS_DEFAULT_CONSISTENCY = $null +} $env:AZURE_COSMOS_TEST_MODE = $null $env:AZURE_COSMOS_EMULATOR_HOST = $null $env:AZURE_COSMOS_INMEMORY_EMULATOR_PID = $null $env:AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT = $null $env:AZURE_COSMOS_INMEMORY_ACCOUNT_ENDPOINT = $null +$env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY = $null +$env:AZURE_COSMOS_INMEMORY_RUN_ID = $null # Remove any --cfg=test_category="..." flag added by Test-Setup.ps1 or COSMOS_RUSTFLAGS. # The next package's setup will re-add the correct flag from COSMOS_RUSTFLAGS # (or from AZURE_COSMOS_EMULATOR_FLAVOR=vnext when running the vnext stage). diff --git a/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 b/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 index 70d4ac1c40..b873964960 100644 --- a/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 +++ b/sdk/cosmos/eng/scripts/Invoke-CosmosTestSetup.ps1 @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. # cSpell:ignore noui noexplorer disableratelimiting enableaadauthentication partitioncount LASTEXITCODE TEAMPROJECTID +#Requires -Version 7.4 # Load common ES scripts . "$PSScriptRoot\..\..\..\..\eng\common\scripts\common.ps1" @@ -8,6 +9,126 @@ # Work around a temporary issue where Invoke-LoggedCommand, which calls us, needs LASTEXITCODE to be set $global:LASTEXITCODE = 0 +function Test-CosmosE2eScenarioDocuments { + $e2eTestRoot = ([System.IO.Path]::Combine($PSScriptRoot, '..', '..', 'e2e_tests')) + $scenarioSchema = ([System.IO.Path]::Combine($e2eTestRoot, 'schema', 'scenario.v1.json')) + $profileSchema = ([System.IO.Path]::Combine($e2eTestRoot, 'schema', 'profile.v1.json')) + + $scenarioDocuments = @(Get-ChildItem ([System.IO.Path]::Combine($e2eTestRoot, 'scenarios')) -Recurse -Filter '*.json' | ForEach-Object { + if (-not (Get-Content $_.FullName -Raw | Test-Json -SchemaFile $scenarioSchema)) { + throw "Cosmos E2E scenario failed schema validation: $($_.FullName)" + } + Get-Content $_.FullName -Raw | ConvertFrom-Json + }) + $profileDocuments = @(Get-ChildItem ([System.IO.Path]::Combine($e2eTestRoot, 'profiles')) -Filter '*.json' | ForEach-Object { + if (-not (Get-Content $_.FullName -Raw | Test-Json -SchemaFile $profileSchema)) { + throw "Cosmos E2E profile failed schema validation: $($_.FullName)" + } + Get-Content $_.FullName -Raw | ConvertFrom-Json + }) + + $implementationPath = ([System.IO.Path]::Combine($e2eTestRoot, 'implementations', 'rust.json')) + $implementation = Get-Content $implementationPath -Raw | ConvertFrom-Json + $scenarioIds = @($scenarioDocuments.id | Sort-Object -Unique) + $implementationIds = @($implementation.scenarios.id | Sort-Object -Unique) + if (Compare-Object $scenarioIds $implementationIds) { + throw 'Cosmos E2E scenario files and Rust implementation mappings must contain identical scenario IDs.' + } + + $profileIds = @($profileDocuments.id | Sort-Object -Unique) + $referencedProfileIds = @($scenarioDocuments.profiles | Sort-Object -Unique) + $unknownProfileIds = @($referencedProfileIds | Where-Object { $_ -notin $profileIds }) + if ($unknownProfileIds.Count -gt 0) { + throw "Cosmos E2E scenarios reference unknown profile IDs: $($unknownProfileIds -join ', ')." + } +} + +function New-CosmosE2eEmulatorConfig { + param( + [Parameter(Mandatory)] + [string] $ProfileId, + + [Parameter(Mandatory)] + [bool] $GatewayV2Enabled, + + [Parameter(Mandatory)] + [string] $OutputDirectory + ) + + if ($ProfileId -notmatch '^[a-zA-Z][a-zA-Z0-9]*$') { + throw "Invalid AZURE_COSMOS_E2E_PROFILE value '$ProfileId'." + } + $e2eTestRoot = ([System.IO.Path]::Combine($PSScriptRoot, '..', '..', 'e2e_tests')) + $profilePath = ([System.IO.Path]::Combine($e2eTestRoot, 'profiles', "$ProfileId.json")) + if (-not (Test-Path $profilePath)) { + throw "E2E profile '$ProfileId' does not exist at '$profilePath'." + } + $profileDocument = Get-Content $profilePath -Raw | ConvertFrom-Json + $accountDefinitions = @($profileDocument.accounts) + $accountDefinition = if ($env:AZURE_COSMOS_E2E_ACCOUNT) { + @($accountDefinitions | Where-Object { $_.id -eq $env:AZURE_COSMOS_E2E_ACCOUNT }) + } + elseif ($accountDefinitions.Count -eq 1) { + @($accountDefinitions[0]) + } + else { + throw "AZURE_COSMOS_E2E_ACCOUNT is required for profile '$ProfileId'." + } + if ($accountDefinition.Count -ne 1) { + throw "Profile '$ProfileId' does not contain exactly one account named '$env:AZURE_COSMOS_E2E_ACCOUNT'." + } + $accountDefinition = $accountDefinition[0] + $regions = @($accountDefinition.regions | ForEach-Object { + $region = [ordered]@{ + name = [string]$_.name + gatewayPort = 0 + } + if ($GatewayV2Enabled) { + $region.gateway20Port = 0 + } + [pscustomobject]$region + }) + $configuration = [ordered]@{ + account = [ordered]@{ + id = "e2e-$ProfileId-$($accountDefinition.id)" + writeMode = [string]$accountDefinition.writeMode + consistency = [string]$accountDefinition.consistency + perPartitionFailover = [bool]$accountDefinition.perPartitionFailover + throttling = $false + regions = $regions + replication = [ordered]@{ + minDelayMs = [uint64]$accountDefinition.replication.minDelayMs + maxDelayMs = [uint64]$accountDefinition.replication.maxDelayMs + maxBufferedReplications = 10000 + } + } + management = @{ port = 0 } + databases = @() + } + $mode = if ($GatewayV2Enabled) { 'v2' } else { 'v1' } + $path = ([System.IO.Path]::Combine($OutputDirectory, "azure-cosmos-e2e-$ProfileId-$($accountDefinition.id)-$mode.json")) + $configuration | ConvertTo-Json -Depth 10 | Set-Content $path + $defaultConsistency = switch ([string]$accountDefinition.consistency) { + 'strong' { 'Strong' } + 'boundedStaleness' { 'BoundedStaleness' } + 'session' { 'Session' } + 'consistentPrefix' { 'ConsistentPrefix' } + 'eventual' { 'Eventual' } + default { throw "Unsupported account consistency '$($accountDefinition.consistency)'." } + } + return [pscustomobject]@{ + Path = $path + AccountId = $configuration.account.id + DefaultConsistency = $defaultConsistency + } +} + +if (-not $env:AZURE_COSMOS_E2E_TESTS_VALIDATED) { + Test-CosmosE2eScenarioDocuments + $env:AZURE_COSMOS_E2E_TESTS_VALIDATED = '1' + Write-Host 'Validated Cosmos SDK E2E scenario documents.' +} + # Append COSMOS_RUSTFLAGS (from test-resources.bicep) to RUSTFLAGS if present if ($env:COSMOS_RUSTFLAGS) { $env:RUSTFLAGS = "$($env:RUSTFLAGS) $($env:COSMOS_RUSTFLAGS)" @@ -48,8 +169,30 @@ if ($env:AZURE_COSMOS_FUZZ -eq '1' -and -not $env:AZURE_COSMOS_FUZZ_RAN) { # Hosted in-memory emulator path. The additional CI matrix sets one of the two # flavors below so the existing emulator suites run against both Gateway V1 # and Gateway 2.0 over cleartext HTTP/2. +if ($env:AZURE_COSMOS_E2E_PROFILE -and + $env:AZURE_COSMOS_EMULATOR_FLAVOR -notin @('inmemory-v1', 'inmemory-v2')) { + throw 'AZURE_COSMOS_E2E_PROFILE requires AZURE_COSMOS_EMULATOR_FLAVOR to be inmemory-v1 or inmemory-v2.' +} + if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { $repoRoot = (Resolve-Path ([System.IO.Path]::Combine($PSScriptRoot, '..', '..', '..', '..'))).Path + $runDirectoryRoot = if ($env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY) { + $env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY + } + else { + [System.IO.Path]::GetTempPath() + } + $runId = [System.Guid]::NewGuid().ToString('N') + $runDirectory = ([System.IO.Path]::Combine( + $runDirectoryRoot, + "azure-data-cosmos-emulator-$runId")) + New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null + Set-Content ` + -LiteralPath ([System.IO.Path]::Combine($runDirectory, '.azure-data-cosmos-emulator-run')) ` + -Value $runId ` + -NoNewline + $env:AZURE_COSMOS_INMEMORY_RUN_DIRECTORY = $runDirectory + $env:AZURE_COSMOS_INMEMORY_RUN_ID = $runId $configuration = if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -eq 'inmemory-v2') { [System.IO.Path]::Combine($repoRoot, 'sdk', 'cosmos', 'azure_data_cosmos_emulator', 'config', 'ci-gateway-v2.json') } @@ -58,6 +201,16 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { } $ready = $false $expectedGateway20 = $env:AZURE_COSMOS_EMULATOR_FLAVOR -eq 'inmemory-v2' + $expectedAccountId = $null + if ($env:AZURE_COSMOS_E2E_PROFILE) { + $e2eConfiguration = New-CosmosE2eEmulatorConfig ` + -ProfileId $env:AZURE_COSMOS_E2E_PROFILE ` + -GatewayV2Enabled $expectedGateway20 ` + -OutputDirectory $runDirectory + $configuration = $e2eConfiguration.Path + $expectedAccountId = $e2eConfiguration.AccountId + $env:AZURE_COSMOS_DEFAULT_CONSISTENCY = $e2eConfiguration.DefaultConsistency + } $managementEndpoint = $env:AZURE_COSMOS_INMEMORY_MANAGEMENT_ENDPOINT $accountEndpoint = $env:AZURE_COSMOS_INMEMORY_ACCOUNT_ENDPOINT if ($managementEndpoint -and $accountEndpoint) { @@ -66,6 +219,12 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { $response = Invoke-WebRequest -Uri $healthUrl -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop $health = $response.Content | ConvertFrom-Json $ready = $response.StatusCode -eq 200 -and $health.gateway20Enabled -eq $expectedGateway20 + if ($ready -and $expectedAccountId) { + $accountUrl = ([System.Uri]::new([System.Uri]$managementEndpoint, 'account')).AbsoluteUri + $accountResponse = Invoke-WebRequest -Uri $accountUrl -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop + $account = $accountResponse.Content | ConvertFrom-Json + $ready = $accountResponse.StatusCode -eq 200 -and $account.id -eq $expectedAccountId + } } catch { $ready = $false @@ -73,7 +232,11 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { } if (-not $ready) { - Get-Process azure_data_cosmos_emulator -ErrorAction SilentlyContinue | Stop-Process -Force + if ($env:AZURE_COSMOS_INMEMORY_EMULATOR_PID) { + Get-Process -Id ([int]$env:AZURE_COSMOS_INMEMORY_EMULATOR_PID) -ErrorAction SilentlyContinue | + Stop-Process -Force -ErrorAction SilentlyContinue + $env:AZURE_COSMOS_INMEMORY_EMULATOR_PID = $null + } } if (-not $ready) { @@ -95,8 +258,8 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { 'azure_data_cosmos_emulator' } $executable = [System.IO.Path]::Combine($repoRoot, 'target', 'debug', $executableName) - $stdout = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'azure-data-cosmos-emulator.out.log') - $stderr = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'azure-data-cosmos-emulator.err.log') + $stdout = [System.IO.Path]::Combine($runDirectory, 'stdout.log') + $stderr = [System.IO.Path]::Combine($runDirectory, 'stderr.log') Remove-Item $stdout, $stderr -Force -ErrorAction SilentlyContinue LogGroupStart "Starting hosted Cosmos DB in-memory emulator" @@ -173,8 +336,13 @@ if ($env:AZURE_COSMOS_EMULATOR_FLAVOR -in @('inmemory-v1', 'inmemory-v2')) { $env:AZURE_COSMOS_CONNECTION_STRING = "AccountEndpoint=$accountEndpoint;AccountKey=$emulatorKey;" $env:AZURE_COSMOS_TEST_MODE = 'required' $env:RUSTFLAGS = $env:RUSTFLAGS -replace '\s*--cfg=test_category="[^"]*"', '' - $env:RUSTFLAGS = "$($env:RUSTFLAGS) --cfg=test_category=`"emulator_inmemory`"" - if ($expectedGateway20) { + if ($env:AZURE_COSMOS_E2E_PROFILE) { + $env:RUSTFLAGS = "$($env:RUSTFLAGS) --cfg=test_category=`"e2e`"" + } + else { + $env:RUSTFLAGS = "$($env:RUSTFLAGS) --cfg=test_category=`"emulator_inmemory`"" + } + if ($expectedGateway20 -and -not $env:AZURE_COSMOS_E2E_PROFILE) { $env:RUSTFLAGS = "$($env:RUSTFLAGS) --cfg=test_category=`"emulator_inmemory_gateway_v2`"" } $env:RUST_TEST_THREADS = '1'