use crate::diagnostics::DiagnosticsContext;
use crate::driver::pipeline::from_local_body::from_local_body_and_driver_headers;
use crate::driver::pipeline::patch_eval::apply_patch_ops;
use crate::driver::CosmosDriver;
use crate::models::{
CosmosOperation, CosmosResponse, PartitionKeyKind, PatchInstructions, PatchOperation,
Precondition, SessionToken,
};
use crate::options::OperationOptions;
use async_trait::async_trait;
use azure_core::http::{Etag, StatusCode};
use std::num::NonZeroU8;
use std::sync::Arc;
pub const DEFAULT_PATCH_MAX_ATTEMPTS: u8 = 5;
#[async_trait]
pub(crate) trait SubOperationDispatcher: Send + Sync {
async fn execute_operation(
&self,
operation: CosmosOperation,
options: OperationOptions,
) -> crate::error::Result<CosmosResponse>;
}
#[async_trait]
impl SubOperationDispatcher for CosmosDriver {
async fn execute_operation(
&self,
operation: CosmosOperation,
options: OperationOptions,
) -> crate::error::Result<CosmosResponse> {
CosmosDriver::execute_singleton_operation(self, operation, options).await
}
}
pub(crate) async fn execute(
driver: &CosmosDriver,
operation: CosmosOperation,
options: OperationOptions,
max_attempts: Option<NonZeroU8>,
) -> crate::error::Result<CosmosResponse> {
execute_with_dispatcher(driver, operation, options, max_attempts).await
}
pub(crate) async fn execute_with_dispatcher<D: SubOperationDispatcher + ?Sized>(
dispatcher: &D,
operation: CosmosOperation,
options: OperationOptions,
max_attempts: Option<NonZeroU8>,
) -> crate::error::Result<CosmosResponse> {
if operation.precondition().is_some() {
return Err(crate::error::CosmosError::builder()
.with_status(crate::error::CosmosStatus::new(
azure_core::http::StatusCode::BadRequest,
))
.with_message(
"PATCH does not support caller-set preconditions; \
the handler manages If-Match internally",
)
.build());
}
let body = operation
.body()
.ok_or_else(|| missing_body_error("PATCH operation requires a PatchInstructions body"))?;
let spec: PatchInstructions = serde_json::from_slice(body).map_err(|err| {
crate::error::CosmosError::builder()
.with_status(crate::error::CosmosStatus::SERIALIZATION_RESPONSE_BODY_INVALID)
.with_message("failed to parse PATCH body as PatchInstructions")
.with_source(err)
.build()
})?;
if spec.operations.is_empty() {
return Err(crate::error::CosmosError::builder()
.with_status(crate::error::CosmosStatus::new(
azure_core::http::StatusCode::BadRequest,
))
.with_message("PATCH operation must include at least one PatchOperation")
.build());
}
let item_ref = operation
.partition_key()
.cloned()
.and_then(|pk| operation.resource_reference().try_into_item_reference(pk))
.ok_or_else(|| {
crate::error::CosmosError::builder()
.with_status(crate::error::CosmosStatus::new(
azure_core::http::StatusCode::BadRequest,
))
.with_message(
"PATCH dispatch requires an item-level operation with a partition key",
)
.build()
})?;
validate_partition_key_paths(&spec.operations, &item_ref)?;
let attempts = max_attempts
.map(|n| n.get())
.unwrap_or(DEFAULT_PATCH_MAX_ATTEMPTS);
let mut effective_session_token = operation.request_headers().session_token.clone();
let mut last_412: Option<crate::error::CosmosError> = None;
let mut sub_op_diagnostics: Vec<Arc<DiagnosticsContext>> =
Vec::with_capacity(2 * attempts as usize);
for _ in 0..attempts {
let read_op = build_read_sub_op(item_ref.clone(), effective_session_token.clone());
let read_resp = dispatcher
.execute_operation(read_op, options.clone())
.await?;
sub_op_diagnostics.push(read_resp.diagnostics());
let etag = read_resp.headers().etag.clone().ok_or_else(|| {
crate::error::CosmosError::builder()
.with_status(crate::error::CosmosStatus::new(
azure_core::http::StatusCode::BadRequest,
))
.with_message("PATCH cannot proceed: the Read response did not include an ETag")
.build()
})?;
let read_session_token = read_resp.headers().session_token.clone();
if let Some(token) = read_session_token.clone() {
effective_session_token = Some(token);
}
let read_body_bytes = read_resp.into_body().single().map_err(|err| {
crate::error::CosmosError::builder()
.with_status(crate::error::CosmosStatus::SERIALIZATION_RESPONSE_BODY_INVALID)
.with_message("PATCH could not extract Read response body")
.with_source(err)
.build()
})?;
let mut value: serde_json::Value =
serde_json::from_slice(&read_body_bytes).map_err(|err| {
crate::error::CosmosError::builder()
.with_status(crate::error::CosmosStatus::SERIALIZATION_RESPONSE_BODY_INVALID)
.with_message(format!(
"PATCH could not deserialize current item body: {err}"
))
.with_source(err)
.build()
})?;
apply_patch_ops(&mut value, &spec.operations)?;
let merged_bytes = serde_json::to_vec(&value).map_err(|err| {
crate::error::CosmosError::builder()
.with_status(crate::error::CosmosStatus::SERIALIZATION_RESPONSE_BODY_INVALID)
.with_message("PATCH could not serialize merged item")
.with_source(err)
.build()
})?;
let replace_op = build_replace_sub_op(
item_ref.clone(),
merged_bytes.clone(),
etag,
read_session_token,
);
match dispatcher
.execute_operation(replace_op, options.clone())
.await
{
Ok(replace_resp) => {
let replace_headers = replace_resp.headers().clone();
let replace_status = replace_resp.status();
let replace_etag = replace_headers.etag.clone();
sub_op_diagnostics.push(replace_resp.diagnostics());
let replace_body = replace_resp.into_body();
let replace_body_bytes: Vec<u8> = match replace_body {
crate::models::ResponseBody::Bytes(b) => b.to_vec(),
crate::models::ResponseBody::NoPayload
| crate::models::ResponseBody::Items(_) => Vec::new(),
};
let diagnostics = DiagnosticsContext::aggregate_sub_operations(&sub_op_diagnostics)
.map(Arc::new)
.unwrap_or_else(|| {
sub_op_diagnostics
.last()
.cloned()
.expect("sub_op_diagnostics is non-empty after a successful Replace")
});
let synthesized_body = synthesize_post_image_body(
merged_bytes,
replace_body_bytes,
replace_etag.as_ref(),
);
return Ok(from_local_body_and_driver_headers(
synthesized_body,
replace_headers,
replace_status,
diagnostics,
));
}
Err(err) if is_precondition_failed(&err) => {
if let Some(token_412) = session_token_from_error(&err) {
effective_session_token = Some(
effective_session_token
.as_ref()
.and_then(|prev| prev.merge(&token_412).ok())
.unwrap_or(token_412),
);
}
if let Some(diag) = err.diagnostics() {
sub_op_diagnostics.push(diag);
}
last_412 = Some(err);
continue;
}
Err(err) => return Err(err),
}
}
Err(exhaustion_error(attempts, last_412, &sub_op_diagnostics))
}
fn missing_body_error(msg: &'static str) -> crate::error::CosmosError {
crate::error::CosmosError::builder()
.with_status(crate::error::CosmosStatus::new(
azure_core::http::StatusCode::BadRequest,
))
.with_message(msg)
.build()
}
fn is_precondition_failed(err: &crate::error::CosmosError) -> bool {
err.wire_payload().is_some() && err.status().is_precondition_failed()
}
fn session_token_from_error(err: &crate::error::CosmosError) -> Option<SessionToken> {
err.wire_payload()
.and_then(|p| p.headers().session_token.clone())
}
fn synthesize_post_image_body(
merged_bytes: Vec<u8>,
replace_body: Vec<u8>,
replace_etag: Option<&Etag>,
) -> Vec<u8> {
if !replace_body.is_empty() {
return replace_body;
}
let Some(etag) = replace_etag else {
return merged_bytes;
};
let Ok(mut value) = serde_json::from_slice::<serde_json::Value>(&merged_bytes) else {
return merged_bytes;
};
let serde_json::Value::Object(ref mut map) = value else {
return merged_bytes;
};
map.insert(
"_etag".to_string(),
serde_json::Value::String(etag.to_string()),
);
serde_json::to_vec(&value).unwrap_or(merged_bytes)
}
fn build_read_sub_op(
item_ref: crate::models::ItemReference,
caller_session_token: Option<crate::models::SessionToken>,
) -> CosmosOperation {
let mut op = CosmosOperation::read_item(item_ref);
if let Some(token) = caller_session_token {
op = op.with_session_token(token);
}
op
}
fn build_replace_sub_op(
item_ref: crate::models::ItemReference,
merged_bytes: Vec<u8>,
etag: Etag,
read_response_session_token: Option<crate::models::SessionToken>,
) -> CosmosOperation {
let mut op = CosmosOperation::replace_item(item_ref)
.with_body(merged_bytes)
.with_precondition(Precondition::if_match(etag));
if let Some(token) = read_response_session_token {
op = op.with_session_token(token);
}
op
}
fn exhaustion_error(
attempts: u8,
last_412: Option<crate::error::CosmosError>,
sub_op_diagnostics: &[Arc<DiagnosticsContext>],
) -> crate::error::CosmosError {
let message = format!("patch_item: ETag conflict after {attempts} attempts");
let aggregated = DiagnosticsContext::aggregate_sub_operations(sub_op_diagnostics).map(Arc::new);
match last_412 {
Some(source) => {
let mut b = crate::error::CosmosErrorBuilder::from_error(source).with_context(message);
if let Some(diag) = aggregated {
b = b.with_diagnostics(diag);
}
b.build()
}
None => {
let mut b = crate::error::CosmosError::builder()
.with_status(crate::models::CosmosStatus::new(
StatusCode::PreconditionFailed,
))
.with_message(message);
if let Some(diag) = aggregated {
b = b.with_diagnostics(diag);
}
b.build()
}
}
}
fn validate_partition_key_paths(
ops: &[PatchOperation],
item_ref: &crate::models::ItemReference,
) -> crate::error::Result<()> {
let pk_def = item_ref.container().partition_key_definition();
let pk_paths: Vec<&str> = pk_def.paths().iter().map(|p| p.as_ref()).collect();
let kind = pk_def.kind();
debug_assert!(matches!(
kind,
PartitionKeyKind::Hash | PartitionKeyKind::MultiHash | PartitionKeyKind::Range
));
let _ = kind;
for op in ops {
let dest = op.path();
let from = match op {
PatchOperation::Move { from, .. } => Some(from.as_str()),
_ => None,
};
for path in std::iter::once(dest).chain(from) {
for pk_path in &pk_paths {
if path_overlaps_partition_key(path, pk_path) {
return Err(crate::error::CosmosError::builder()
.with_status(crate::error::CosmosStatus::new(
azure_core::http::StatusCode::BadRequest,
))
.with_message(format!(
"PATCH op '{path}' overlaps partition key path '{pk_path}'; \
cannot mutate partition key with a client-side Read-Modify-Write"
))
.build());
}
}
}
}
Ok(())
}
fn path_overlaps_partition_key(op_path: &str, pk_path: &str) -> bool {
fn normalize(p: &str) -> String {
if p.is_empty() || p.starts_with('/') {
p.to_string()
} else {
format!("/{p}")
}
}
let op = normalize(op_path);
let pk = normalize(pk_path);
if op == pk {
return true;
}
let with_slash = |p: &str| {
if p.ends_with('/') {
p.to_string()
} else {
format!("{p}/")
}
};
let a = with_slash(&op);
let b = with_slash(&pk);
a.starts_with(&b) || b.starts_with(&a)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{
AccountReference, ContainerProperties, ContainerReference, ItemReference, OperationType,
PartitionKey, PartitionKeyDefinition, SessionToken, SystemProperties,
};
use azure_core::http::Url;
use std::borrow::Cow;
fn test_account() -> AccountReference {
AccountReference::with_master_key(
Url::parse("https://test.documents.azure.com:443/").unwrap(),
"test-key",
)
}
fn test_partition_key_definition(path: &str) -> PartitionKeyDefinition {
serde_json::from_str(&format!(r#"{{"paths":["{path}"]}}"#)).unwrap()
}
fn test_container() -> ContainerReference {
let props = ContainerProperties {
id: "testcontainer".into(),
partition_key: test_partition_key_definition("/pk"),
system_properties: SystemProperties::default(),
};
ContainerReference::new(
test_account(),
"testdb",
"testdb_rid",
"testcontainer",
"testcontainer_rid",
&props,
)
}
fn test_item_ref() -> ItemReference {
ItemReference::from_name(&test_container(), PartitionKey::from("pk1"), "doc1")
}
#[test]
fn path_overlap_detection() {
assert!(path_overlaps_partition_key("/pk", "/pk"));
assert!(path_overlaps_partition_key("/pk/inner", "/pk"));
assert!(path_overlaps_partition_key("/account", "/account/tenantId"));
assert!(!path_overlaps_partition_key("/pkOther", "/pk"));
assert!(!path_overlaps_partition_key("/other", "/pk"));
}
#[test]
fn path_overlap_normalizes_missing_leading_slash() {
assert!(path_overlaps_partition_key("pk", "/pk"));
assert!(path_overlaps_partition_key("pk/inner", "/pk"));
assert!(path_overlaps_partition_key("/pk", "pk"));
assert!(!path_overlaps_partition_key("other", "/pk"));
}
#[test]
fn read_sub_op_propagates_caller_session_token() {
let caller_token = SessionToken(Cow::Owned("0:1#42".into()));
let op = build_read_sub_op(test_item_ref(), Some(caller_token.clone()));
assert_eq!(op.operation_type(), OperationType::Read);
assert_eq!(
op.request_headers().session_token.as_ref(),
Some(&caller_token)
);
}
#[test]
fn read_sub_op_omits_token_when_caller_has_none() {
let op = build_read_sub_op(test_item_ref(), None);
assert_eq!(op.operation_type(), OperationType::Read);
assert!(op.request_headers().session_token.is_none());
}
#[test]
fn replace_sub_op_uses_read_response_session_token() {
let read_response_token = SessionToken(Cow::Owned("0:1#99".into()));
let etag = Etag::from("\"abc\"");
let body = b"{\"id\":\"doc1\"}".to_vec();
let op = build_replace_sub_op(
test_item_ref(),
body.clone(),
etag.clone(),
Some(read_response_token.clone()),
);
assert_eq!(op.operation_type(), OperationType::Replace);
assert_eq!(op.body(), Some(body.as_slice()));
assert_eq!(
op.request_headers().session_token.as_ref(),
Some(&read_response_token)
);
assert_eq!(op.precondition(), Some(&Precondition::if_match(etag)));
}
#[test]
fn replace_sub_op_omits_token_when_read_response_has_none() {
let etag = Etag::from("\"abc\"");
let op = build_replace_sub_op(test_item_ref(), Vec::new(), etag, None);
assert_eq!(op.operation_type(), OperationType::Replace);
assert!(op.request_headers().session_token.is_none());
}
#[test]
fn is_precondition_failed_matches_real_412() {
let err =
cosmos_service_error(StatusCode::PreconditionFailed, "412 from server", None, &[]);
assert!(is_precondition_failed(&err));
}
#[test]
fn is_precondition_failed_rejects_other_http_statuses() {
for status in [
StatusCode::NotFound,
StatusCode::Conflict,
StatusCode::TooManyRequests,
StatusCode::ServiceUnavailable,
] {
let err = cosmos_service_error(status, "non-412 service error", None, &[]);
assert!(
!is_precondition_failed(&err),
"should not match status {status:?}",
);
}
}
#[test]
fn is_precondition_failed_rejects_non_http_error_kinds() {
use crate::error::CosmosError;
let errs = [
CosmosError::builder()
.with_status(crate::error::CosmosStatus::new(
azure_core::http::StatusCode::BadRequest,
))
.with_message("synthetic")
.build(),
CosmosError::builder()
.with_status(crate::error::CosmosStatus::SERIALIZATION_RESPONSE_BODY_INVALID)
.with_message("bad json")
.with_source(std::io::Error::new(std::io::ErrorKind::InvalidData, "stub"))
.build(),
];
for err in &errs {
assert!(
!is_precondition_failed(err),
"should not match {:?}",
err.status()
);
}
}
#[test]
fn pk_guard_rejects_move_from_pk_path() {
let item_ref = test_item_ref();
let ops = vec![PatchOperation::move_value("/pk", "/somewhere_else")];
let err = validate_partition_key_paths(&ops, &item_ref)
.expect_err("MoveOp from /pk on a /pk PK must be rejected");
let msg = format!("{err}").to_ascii_lowercase();
assert!(
msg.contains("partition key"),
"error should mention partition key; got: {err}"
);
}
#[test]
fn pk_guard_rejects_move_from_pk_path_hierarchical() {
let pk_def: PartitionKeyDefinition = serde_json::from_str(
r#"{"paths":["/tenant","/region","/user"],"kind":"MultiHash","version":2}"#,
)
.unwrap();
let props = ContainerProperties {
id: "multi_hash_container".into(),
partition_key: pk_def,
system_properties: SystemProperties::default(),
};
let container = ContainerReference::new(
test_account(),
"testdb",
"testdb_rid",
"multi_hash_container",
"multi_hash_container_rid",
&props,
);
let item_ref =
ItemReference::from_name(&container, PartitionKey::from(("t1", "r1", "u1")), "doc1");
let ops = vec![PatchOperation::move_value("/tenant", "/somewhere_else")];
let err = validate_partition_key_paths(&ops, &item_ref)
.expect_err("MoveOp from /tenant on a hierarchical PK must be rejected");
let msg = format!("{err}").to_ascii_lowercase();
assert!(
msg.contains("partition key"),
"error should mention partition key; got: {err}"
);
}
#[test]
fn exhaustion_error_with_source_chains_underlying_412() {
let underlying = cosmos_service_error(
StatusCode::PreconditionFailed,
"ETag mismatch from server",
None,
b"server-body",
);
let err = exhaustion_error(7, Some(underlying), &[]);
assert_eq!(
err.status().status_code(),
StatusCode::PreconditionFailed,
"exhaustion error must surface as a 412; got {:?}",
err.status()
);
let msg = format!("{err}");
assert!(
msg.contains("7"),
"exhaustion message should mention the attempts count: {msg}"
);
assert!(
msg.to_ascii_lowercase().contains("etag")
|| msg.to_ascii_lowercase().contains("conflict"),
"exhaustion message should mention ETag conflict: {msg}"
);
assert!(
msg.contains("ETag mismatch from server"),
"exhaustion message should still surface the underlying detail: {msg}"
);
assert_eq!(
err.wire_payload().and_then(|p| match p.body() {
crate::models::ResponseBody::Bytes(b) => Some(b.as_ref()),
_ => None,
}),
Some(b"server-body".as_slice())
);
}
#[test]
fn exhaustion_error_without_source_is_still_412_shaped() {
let err = exhaustion_error(0, None, &[]);
assert_eq!(err.status().status_code(), StatusCode::PreconditionFailed);
assert!(
std::error::Error::source(&err).is_none(),
"exhaustion_error must NOT synthesize a source when none was passed"
);
let msg = format!("{err}");
assert!(
msg.contains("0"),
"exhaustion message should still mention the attempts count: {msg}"
);
}
#[test]
fn exhaustion_error_forwards_underlying_response_body_and_headers() {
let underlying = cosmos_service_error(
StatusCode::PreconditionFailed,
"ETag mismatch from server",
Some("0:1#42"),
b"{\"code\":\"PreconditionFailed\",\"message\":\"server: stale etag\"}",
);
let err = exhaustion_error(4, Some(underlying), &[]);
assert_eq!(err.status().status_code(), StatusCode::PreconditionFailed);
assert_eq!(
err.wire_payload().and_then(|p| match p.body() {
crate::models::ResponseBody::Bytes(b) => Some(b.as_ref()),
_ => None,
}),
Some(
b"{\"code\":\"PreconditionFailed\",\"message\":\"server: stale etag\"}".as_slice()
),
"exhaustion error must forward the wrapped 412's response body verbatim"
);
assert_eq!(
err.wire_payload()
.map(|p| p.headers())
.and_then(|h| h.session_token.as_ref())
.map(|t| t.0.as_ref()),
Some("0:1#42"),
"exhaustion error must forward the wrapped 412's session token"
);
}
#[test]
fn exhaustion_error_attaches_aggregated_sub_op_diagnostics() {
let underlying = cosmos_service_error(
StatusCode::PreconditionFailed,
"ETag mismatch from server",
None,
b"server-body",
);
let attempt_diags: Vec<Arc<DiagnosticsContext>> = (0..4)
.map(|_| {
let mut builder = DiagnosticsContextBuilder::new(
crate::models::ActivityId::new_uuid(),
Arc::new(crate::options::DiagnosticsOptions::default()),
);
let handle = builder.start_request(
crate::diagnostics::ExecutionContext::Initial,
crate::diagnostics::PipelineType::DataPlane,
crate::diagnostics::TransportSecurity::Secure,
crate::diagnostics::TransportKind::Gateway,
crate::diagnostics::TransportHttpVersion::Http11,
&crate::driver::routing::CosmosEndpoint::global(
url::Url::parse("https://test.documents.azure.com/").unwrap(),
),
);
builder.complete_request(handle, StatusCode::PreconditionFailed, None);
Arc::new(builder.complete())
})
.collect();
let err = exhaustion_error(2, Some(underlying), &attempt_diags);
let diag = err
.diagnostics()
.expect("exhaustion error must carry an aggregated DiagnosticsContext");
assert_eq!(
diag.request_count(),
4,
"aggregated diagnostics must concatenate every per-attempt RequestDiagnostics",
);
for input in &attempt_diags {
assert!(
!Arc::ptr_eq(&diag, input),
"exhaustion error must surface the aggregated context, not any input Arc",
);
}
}
use crate::diagnostics::DiagnosticsContextBuilder;
use crate::models::{ActivityId, CosmosResponseHeaders, CosmosStatus, RequestCharge};
use crate::options::DiagnosticsOptions;
use std::sync::{Arc, Mutex};
enum ScriptedReply {
Ok {
body: Vec<u8>,
etag: Option<&'static str>,
session_token: Option<&'static str>,
status: StatusCode,
},
Err(crate::error::CosmosError),
}
impl ScriptedReply {
fn ok(body: Vec<u8>, etag: Option<&'static str>, status: StatusCode) -> Self {
ScriptedReply::Ok {
body,
etag,
session_token: None,
status,
}
}
}
struct ScriptedDispatcher {
script: Mutex<Vec<ScriptedReply>>,
calls: Mutex<Vec<DispatchedCall>>,
}
#[derive(Debug, Clone)]
struct DispatchedCall {
op_type: OperationType,
if_match_etag: Option<String>,
session_token: Option<SessionToken>,
}
impl ScriptedDispatcher {
fn new(script: Vec<ScriptedReply>) -> Self {
Self {
script: Mutex::new(script),
calls: Mutex::new(Vec::new()),
}
}
fn calls(&self) -> Vec<DispatchedCall> {
self.calls.lock().unwrap().clone()
}
}
#[async_trait]
impl SubOperationDispatcher for ScriptedDispatcher {
async fn execute_operation(
&self,
operation: CosmosOperation,
_options: OperationOptions,
) -> crate::error::Result<CosmosResponse> {
let if_match = match operation.precondition() {
Some(Precondition::IfMatch(tag)) => Some(tag.as_ref().to_string()),
_ => None,
};
self.calls.lock().unwrap().push(DispatchedCall {
op_type: operation.operation_type(),
if_match_etag: if_match,
session_token: operation.request_headers().session_token.clone(),
});
let reply =
self.script.lock().unwrap().drain(..1).next().expect(
"ScriptedDispatcher exhausted: PATCH loop made more sub-ops than scripted",
);
match reply {
ScriptedReply::Err(e) => Err(e),
ScriptedReply::Ok {
body,
etag,
session_token,
status,
} => {
let mut headers = CosmosResponseHeaders::new();
if let Some(tag) = etag {
headers.etag = Some(Etag::from(tag));
}
if let Some(token) = session_token {
headers.session_token = Some(SessionToken(Cow::Owned(token.into())));
}
headers.request_charge = Some(RequestCharge::new(1.0));
let diagnostics = Arc::new(
DiagnosticsContextBuilder::new(
ActivityId::new_uuid(),
Arc::new(DiagnosticsOptions::default()),
)
.complete(),
);
Ok(from_local_body_and_driver_headers(
body,
headers,
CosmosStatus::from_parts(status, None),
diagnostics,
))
}
}
}
}
fn http_error(status: StatusCode, msg: &'static str) -> crate::error::CosmosError {
cosmos_service_error(status, msg, None, &[])
}
fn http_error_with_session_token(
status: StatusCode,
msg: &'static str,
session_token: &'static str,
) -> crate::error::CosmosError {
cosmos_service_error(status, msg, Some(session_token), &[])
}
fn cosmos_service_error(
status: StatusCode,
msg: &'static str,
session_token: Option<&'static str>,
body: &[u8],
) -> crate::error::CosmosError {
let mut headers = CosmosResponseHeaders::new();
if let Some(token) = session_token {
headers.session_token = Some(SessionToken(Cow::Owned(token.into())));
}
let diagnostics = Arc::new(
crate::diagnostics::DiagnosticsContextBuilder::new(
crate::models::ActivityId::new_uuid(),
Arc::new(crate::options::DiagnosticsOptions::default()),
)
.complete(),
);
crate::error::CosmosError::builder()
.with_status(CosmosStatus::new(status))
.with_message(msg)
.with_response_parts(crate::models::CosmosResponsePayload::new(
body.to_vec(),
headers,
))
.with_diagnostics(diagnostics)
.build()
}
fn patch_op_for(item_ref: ItemReference, ops: Vec<PatchOperation>) -> CosmosOperation {
let body = serde_json::to_vec(&PatchInstructions::from(ops)).unwrap();
CosmosOperation::patch_item(item_ref).with_body(body)
}
fn canonical_patch_op() -> CosmosOperation {
patch_op_for(
test_item_ref(),
vec![PatchOperation::increment("/visits", 1i64)],
)
}
#[tokio::test]
async fn rmw_recovers_from_412_on_first_replace() {
let dispatcher = ScriptedDispatcher::new(vec![
ScriptedReply::ok(
br#"{"id":"doc1","pk":"pk1","visits":0}"#.to_vec(),
Some("\"v1\""),
StatusCode::Ok,
),
ScriptedReply::Err(http_error(StatusCode::PreconditionFailed, "lost the race")),
ScriptedReply::ok(
br#"{"id":"doc1","pk":"pk1","visits":1}"#.to_vec(),
Some("\"v2\""),
StatusCode::Ok,
),
ScriptedReply::ok(
br#"{"id":"doc1","pk":"pk1","visits":2}"#.to_vec(),
Some("\"v3\""),
StatusCode::Ok,
),
]);
let resp = execute_with_dispatcher(
&dispatcher,
canonical_patch_op(),
OperationOptions::default(),
None,
)
.await
.expect("PATCH must succeed after a single 412 retry");
let body: serde_json::Value = resp.into_body().into_single().unwrap();
assert_eq!(body["visits"], serde_json::json!(2));
let calls = dispatcher.calls();
assert_eq!(
calls.len(),
4,
"expected exactly Read,Replace,Read,Replace; got: {calls:?}"
);
assert_eq!(calls[0].op_type, OperationType::Read);
assert_eq!(calls[1].op_type, OperationType::Replace);
assert_eq!(calls[1].if_match_etag.as_deref(), Some("\"v1\""));
assert_eq!(calls[2].op_type, OperationType::Read);
assert_eq!(calls[3].op_type, OperationType::Replace);
assert_eq!(calls[3].if_match_etag.as_deref(), Some("\"v2\""));
}
#[tokio::test]
async fn rmw_propagates_412_after_exhausting_max_attempts() {
let dispatcher = ScriptedDispatcher::new(vec![
ScriptedReply::ok(
br#"{"id":"doc1","pk":"pk1","visits":0}"#.to_vec(),
Some("\"v1\""),
StatusCode::Ok,
),
ScriptedReply::Err(http_error(StatusCode::PreconditionFailed, "412 #1")),
ScriptedReply::ok(
br#"{"id":"doc1","pk":"pk1","visits":0}"#.to_vec(),
Some("\"v2\""),
StatusCode::Ok,
),
ScriptedReply::Err(http_error(StatusCode::PreconditionFailed, "412 #2")),
ScriptedReply::ok(
br#"{"id":"doc1","pk":"pk1","visits":0}"#.to_vec(),
Some("\"v3\""),
StatusCode::Ok,
),
ScriptedReply::Err(http_error(StatusCode::PreconditionFailed, "412 #3")),
]);
let err = execute_with_dispatcher(
&dispatcher,
canonical_patch_op(),
OperationOptions::default(),
Some(NonZeroU8::new(3).unwrap()),
)
.await
.expect_err("PATCH must fail after exhausting attempts");
assert!(
is_precondition_failed(&err),
"final error must be 412-shaped; got {:?}",
err.status()
);
assert!(
format!("{err}").contains("3"),
"final error must mention attempt count; got {err}"
);
let calls = dispatcher.calls();
assert_eq!(calls.len(), 6, "expected 3 RMW attempts: {calls:?}");
}
#[tokio::test]
async fn rmw_propagates_non_412_replace_error_immediately() {
let dispatcher = ScriptedDispatcher::new(vec![
ScriptedReply::ok(
br#"{"id":"doc1","pk":"pk1","visits":0}"#.to_vec(),
Some("\"v1\""),
StatusCode::Ok,
),
ScriptedReply::Err(http_error(StatusCode::InternalServerError, "boom")),
]);
let err = execute_with_dispatcher(
&dispatcher,
canonical_patch_op(),
OperationOptions::default(),
None,
)
.await
.expect_err("non-412 Replace error must abort the loop");
assert!(
err.status().status_code() == StatusCode::InternalServerError,
"non-412 must propagate verbatim; got {:?}",
err.status()
);
assert_eq!(dispatcher.calls().len(), 2);
}
#[tokio::test]
async fn rmw_propagates_read_error_immediately() {
let dispatcher = ScriptedDispatcher::new(vec![ScriptedReply::Err(http_error(
StatusCode::NotFound,
"no such item",
))]);
let err = execute_with_dispatcher(
&dispatcher,
canonical_patch_op(),
OperationOptions::default(),
None,
)
.await
.expect_err("PATCH on a missing item must fail on the Read");
assert!(
err.status().status_code() == StatusCode::NotFound,
"PATCH on missing item must surface the Read's 404 verbatim; got {:?}",
err.status()
);
let calls = dispatcher.calls();
assert_eq!(calls.len(), 1, "no Replace must be issued on Read failure");
assert_eq!(calls[0].op_type, OperationType::Read);
}
#[tokio::test]
async fn rmw_fails_without_etag_before_replacing() {
let dispatcher = ScriptedDispatcher::new(vec![ScriptedReply::ok(
br#"{"id":"doc1","pk":"pk1"}"#.to_vec(),
None,
StatusCode::Ok,
)]);
let _err = execute_with_dispatcher(
&dispatcher,
canonical_patch_op(),
OperationOptions::default(),
None,
)
.await
.expect_err("missing ETag on Read must fail PATCH");
let calls = dispatcher.calls();
assert_eq!(calls.len(), 1, "no Replace must be issued without an ETag");
assert_eq!(calls[0].op_type, OperationType::Read);
}
#[tokio::test]
async fn pk_guard_rejection_issues_no_sub_operations() {
let dispatcher = ScriptedDispatcher::new(vec![]);
let op = patch_op_for(
test_item_ref(),
vec![PatchOperation::set("/pk", serde_json::json!("evicted"))],
);
let err = execute_with_dispatcher(&dispatcher, op, OperationOptions::default(), None)
.await
.expect_err("PK-mutating PATCH must be rejected by the guard");
assert!(
format!("{err}")
.to_ascii_lowercase()
.contains("partition key"),
"error must mention the partition key; got: {err}"
);
assert!(
dispatcher.calls().is_empty(),
"PK guard rejection must issue zero sub-operations; got: {:?}",
dispatcher.calls()
);
}
#[tokio::test]
async fn empty_patch_spec_issues_no_sub_operations() {
let dispatcher = ScriptedDispatcher::new(vec![]);
let op = patch_op_for(test_item_ref(), vec![]);
let err = execute_with_dispatcher(&dispatcher, op, OperationOptions::default(), None)
.await
.expect_err("PATCH with no ops must be rejected");
let msg = format!("{err}").to_ascii_lowercase();
assert!(
msg.contains("at least one"),
"error should mention the empty-ops constraint: {err}"
);
assert!(dispatcher.calls().is_empty());
}
#[tokio::test]
async fn caller_set_precondition_is_rejected_before_any_sub_op() {
let dispatcher = ScriptedDispatcher::new(vec![]);
let op = patch_op_for(
test_item_ref(),
vec![PatchOperation::set("/x", serde_json::json!(1))],
)
.with_precondition(Precondition::if_match(Etag::from("\"abc\"")));
let err = execute_with_dispatcher(&dispatcher, op, OperationOptions::default(), None)
.await
.expect_err("PATCH with caller-set precondition must be rejected");
let msg = format!("{err}").to_ascii_lowercase();
assert!(
msg.contains("precondition"),
"error should mention the precondition rejection: {err}"
);
assert!(
dispatcher.calls().is_empty(),
"precondition rejection must issue zero sub-operations; got: {:?}",
dispatcher.calls()
);
}
#[tokio::test]
async fn rmw_loop_dispatches_read_then_etag_guarded_replace() {
let dispatcher = ScriptedDispatcher::new(vec![
ScriptedReply::ok(
br#"{"id":"doc1","pk":"pk1","visits":0}"#.to_vec(),
Some("\"v1\""),
StatusCode::Ok,
),
ScriptedReply::ok(
br#"{"id":"doc1","pk":"pk1","visits":1}"#.to_vec(),
Some("\"v2\""),
StatusCode::Ok,
),
]);
let caller_token = SessionToken(Cow::Owned("0:1#7".into()));
let op = canonical_patch_op().with_session_token(caller_token.clone());
let _resp = execute_with_dispatcher(&dispatcher, op, OperationOptions::default(), None)
.await
.expect("PATCH should succeed");
let calls = dispatcher.calls();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].op_type, OperationType::Read);
assert_eq!(calls[0].if_match_etag, None);
assert_eq!(calls[1].op_type, OperationType::Replace);
assert_eq!(calls[1].if_match_etag.as_deref(), Some("\"v1\""));
}
#[tokio::test]
async fn rmw_carries_session_token_forward_across_412_retries() {
let dispatcher = ScriptedDispatcher::new(vec![
ScriptedReply::Ok {
body: br#"{"id":"doc1","pk":"pk1","visits":0}"#.to_vec(),
etag: Some("\"v1\""),
session_token: Some("0:1#100"),
status: StatusCode::Ok,
},
ScriptedReply::Err(http_error(StatusCode::PreconditionFailed, "lost the race")),
ScriptedReply::Ok {
body: br#"{"id":"doc1","pk":"pk1","visits":1}"#.to_vec(),
etag: Some("\"v2\""),
session_token: Some("0:1#200"),
status: StatusCode::Ok,
},
ScriptedReply::Ok {
body: br#"{"id":"doc1","pk":"pk1","visits":2}"#.to_vec(),
etag: Some("\"v3\""),
session_token: Some("0:1#201"),
status: StatusCode::Ok,
},
]);
let caller_token = SessionToken(Cow::Owned("0:1#1".into()));
let op = canonical_patch_op().with_session_token(caller_token.clone());
let _resp = execute_with_dispatcher(&dispatcher, op, OperationOptions::default(), None)
.await
.expect("PATCH should succeed after one 412 retry");
let calls = dispatcher.calls();
assert_eq!(calls.len(), 4);
assert_eq!(calls[0].op_type, OperationType::Read);
assert_eq!(calls[0].session_token.as_ref(), Some(&caller_token));
assert_eq!(calls[1].op_type, OperationType::Replace);
assert_eq!(
calls[1].session_token.as_ref().map(|t| t.0.as_ref()),
Some("0:1#100")
);
assert_eq!(calls[2].op_type, OperationType::Read);
assert_eq!(
calls[2].session_token.as_ref().map(|t| t.0.as_ref()),
Some("0:1#100"),
"attempt 2 Read must use the carried-forward session token \
from attempt 1's Read response, not the caller's stale token"
);
assert_eq!(calls[3].op_type, OperationType::Replace);
assert_eq!(
calls[3].session_token.as_ref().map(|t| t.0.as_ref()),
Some("0:1#200")
);
}
#[tokio::test]
async fn rmw_folds_412_response_session_token_into_carry_forward() {
let dispatcher = ScriptedDispatcher::new(vec![
ScriptedReply::Ok {
body: br#"{"id":"doc1","pk":"pk1","visits":0}"#.to_vec(),
etag: Some("\"v1\""),
session_token: Some("0:1#100"),
status: StatusCode::Ok,
},
ScriptedReply::Err(http_error_with_session_token(
StatusCode::PreconditionFailed,
"lost the race",
"0:1#300",
)),
ScriptedReply::Ok {
body: br#"{"id":"doc1","pk":"pk1","visits":1}"#.to_vec(),
etag: Some("\"v2\""),
session_token: Some("0:1#301"),
status: StatusCode::Ok,
},
ScriptedReply::Ok {
body: br#"{"id":"doc1","pk":"pk1","visits":2}"#.to_vec(),
etag: Some("\"v3\""),
session_token: Some("0:1#302"),
status: StatusCode::Ok,
},
]);
let op = canonical_patch_op();
let _resp = execute_with_dispatcher(&dispatcher, op, OperationOptions::default(), None)
.await
.expect("PATCH should succeed");
let calls = dispatcher.calls();
assert_eq!(calls.len(), 4);
assert_eq!(calls[2].op_type, OperationType::Read);
assert_eq!(
calls[2].session_token.as_ref().map(|t| t.0.as_ref()),
Some("0:1#300"),
"attempt 2 Read must use the 412's session token (freshest), \
not Read#1's older token"
);
}
#[tokio::test]
async fn synthesized_response_body_reflects_replace_etag_not_read_etag() {
let dispatcher = ScriptedDispatcher::new(vec![
ScriptedReply::ok(
br#"{"id":"doc1","pk":"pk1","visits":0,"_etag":"\"v1\""}"#.to_vec(),
Some("\"v1\""),
StatusCode::Ok,
),
ScriptedReply::ok(Vec::new(), Some("\"v2\""), StatusCode::Ok),
]);
let op = canonical_patch_op();
let resp = execute_with_dispatcher(&dispatcher, op, OperationOptions::default(), None)
.await
.expect("PATCH should succeed");
assert_eq!(
resp.headers().etag.as_ref().map(|t| -> &str { t.as_ref() }),
Some("\"v2\""),
"response header etag must be the Replace's etag"
);
let body: serde_json::Value = resp
.into_body()
.into_single()
.expect("body must be valid JSON");
assert_eq!(
body.get("_etag").and_then(|v| v.as_str()),
Some("\"v2\""),
"synthesized body's _etag must be the Replace's, not the Read's"
);
assert_eq!(body.get("visits").and_then(|v| v.as_i64()), Some(1));
}
#[tokio::test]
async fn synthesized_response_body_prefers_replace_response_body_when_present() {
let server_post_image =
br#"{"id":"doc1","pk":"pk1","visits":1,"_etag":"\"v2\"","_ts":1234567890}"#.to_vec();
let dispatcher = ScriptedDispatcher::new(vec![
ScriptedReply::ok(
br#"{"id":"doc1","pk":"pk1","visits":0,"_etag":"\"v1\"","_ts":1234567000}"#
.to_vec(),
Some("\"v1\""),
StatusCode::Ok,
),
ScriptedReply::ok(server_post_image.clone(), Some("\"v2\""), StatusCode::Ok),
]);
let op = canonical_patch_op();
let resp = execute_with_dispatcher(&dispatcher, op, OperationOptions::default(), None)
.await
.expect("PATCH should succeed");
let body_bytes = resp
.into_body()
.single()
.expect("body should be a single payload");
assert_eq!(
body_bytes.as_ref(),
server_post_image.as_slice(),
"when the Replace returned a body, the handler must surface it \
verbatim (it's the service-authoritative post-image)"
);
}
#[tokio::test]
async fn rmw_aggregates_diagnostics_across_sub_operations() {
use crate::models::CosmosResponseHeaders;
struct CapturingDispatcher {
handed_out: Mutex<Vec<Arc<DiagnosticsContext>>>,
}
#[async_trait]
impl SubOperationDispatcher for CapturingDispatcher {
async fn execute_operation(
&self,
operation: CosmosOperation,
_options: OperationOptions,
) -> crate::error::Result<CosmosResponse> {
let body = match operation.operation_type() {
OperationType::Read => br#"{"id":"doc1","pk":"pk1","visits":0}"#.to_vec(),
OperationType::Replace => br#"{"id":"doc1","pk":"pk1","visits":1}"#.to_vec(),
other => panic!("unexpected sub-op {other:?}"),
};
let mut headers = CosmosResponseHeaders::new();
headers.etag = Some(Etag::from("\"v1\""));
let diagnostics = Arc::new(
DiagnosticsContextBuilder::new(
ActivityId::new_uuid(),
Arc::new(DiagnosticsOptions::default()),
)
.complete(),
);
self.handed_out
.lock()
.unwrap()
.push(Arc::clone(&diagnostics));
Ok(from_local_body_and_driver_headers(
body,
headers,
CosmosStatus::from_parts(StatusCode::Ok, None),
diagnostics,
))
}
}
let dispatcher = CapturingDispatcher {
handed_out: Mutex::new(Vec::new()),
};
let resp = execute_with_dispatcher(
&dispatcher,
canonical_patch_op(),
OperationOptions::default(),
None,
)
.await
.expect("PATCH should succeed");
let handed_out = dispatcher.handed_out.lock().unwrap().clone();
assert_eq!(
handed_out.len(),
2,
"expected one Read + one Replace sub-op"
);
let returned = resp.diagnostics();
assert!(
!Arc::ptr_eq(&returned, &handed_out[0]),
"returned diagnostics must not be identity-equal to the Read sub-op's context"
);
assert!(
!Arc::ptr_eq(&returned, &handed_out[1]),
"returned diagnostics must not be identity-equal to the Replace sub-op's context \
(regression: handler used to return the Replace's context verbatim)"
);
assert_eq!(returned.activity_id(), handed_out[1].activity_id());
}
}