use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub(crate) enum PipelineNodeState {
Drained,
Request {
#[serde(default, skip_serializing_if = "Option::is_none")]
server_continuation: Option<String>,
},
SequentialDrain {
left_most_undrained_epk: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
active_tokens: Vec<RangedToken>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct RangedToken {
pub(crate) min_epk: String,
pub(crate) max_epk: String,
pub(crate) server_continuation: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ChildSnapshotContribution {
Drained,
Pending { server_continuation: Option<String> },
}
impl PipelineNodeState {
pub(crate) fn into_child_contribution(
self,
parent: &str,
idx: usize,
total: usize,
) -> crate::error::Result<ChildSnapshotContribution> {
match self {
PipelineNodeState::Drained => Ok(ChildSnapshotContribution::Drained),
PipelineNodeState::Request {
server_continuation,
} => Ok(ChildSnapshotContribution::Pending {
server_continuation,
}),
other => Err(crate::error::CosmosError::builder()
.with_status(
crate::error::CosmosStatus::CLIENT_CONTINUATION_TOKEN_UNEXPECTED_NESTED_SHAPE,
)
.with_message(format!(
"{parent} child {idx} of {total} produced an unsupported snapshot shape: {}",
match &other {
PipelineNodeState::Drained => "Drained",
PipelineNodeState::Request { .. } => "Request",
PipelineNodeState::SequentialDrain { .. } => "SequentialDrain",
},
))
.build()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn token(min: &str, max: &str, t: &str) -> RangedToken {
RangedToken {
min_epk: min.to_owned(),
max_epk: max.to_owned(),
server_continuation: t.to_owned(),
}
}
#[test]
fn drained_round_trips() {
let json = serde_json::to_string(&PipelineNodeState::Drained).unwrap();
assert_eq!(json, r#"{"kind":"drained"}"#);
let parsed: PipelineNodeState = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, PipelineNodeState::Drained);
}
#[test]
fn request_round_trips_with_and_without_token() {
let with = PipelineNodeState::Request {
server_continuation: Some("abc".to_owned()),
};
let json = serde_json::to_string(&with).unwrap();
assert_eq!(json, r#"{"kind":"request","server_continuation":"abc"}"#);
let parsed: PipelineNodeState = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, with);
let without = PipelineNodeState::Request {
server_continuation: None,
};
let json = serde_json::to_string(&without).unwrap();
assert_eq!(json, r#"{"kind":"request"}"#);
let parsed: PipelineNodeState = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, without);
}
#[test]
fn sequential_drain_round_trips_cursor_only() {
let state = PipelineNodeState::SequentialDrain {
left_most_undrained_epk: "80".to_owned(),
active_tokens: vec![],
};
let json = serde_json::to_string(&state).unwrap();
assert_eq!(
json, r#"{"kind":"sequential_drain","left_most_undrained_epk":"80"}"#,
"empty active_tokens must be omitted from the wire form",
);
let parsed: PipelineNodeState = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, state);
}
#[test]
fn sequential_drain_round_trips_with_active_tokens() {
let state = PipelineNodeState::SequentialDrain {
left_most_undrained_epk: String::new(),
active_tokens: vec![token("00", "55", "t1"), token("55", "AA", "t2")],
};
let json = serde_json::to_string(&state).unwrap();
let parsed: PipelineNodeState = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, state);
}
#[test]
fn sequential_drain_deserializes_with_absent_active_tokens_field() {
let json = r#"{"kind":"sequential_drain","left_most_undrained_epk":"40"}"#;
let parsed: PipelineNodeState = serde_json::from_str(json).unwrap();
assert_eq!(
parsed,
PipelineNodeState::SequentialDrain {
left_most_undrained_epk: "40".to_owned(),
active_tokens: vec![],
},
);
}
#[test]
fn legacy_lossy_sequential_drain_shape_fails_to_deserialize() {
let legacy = r#"{"kind":"sequential_drain","current_min_epk":"00","current_max_epk":"FF","left_most":{"kind":"request","server_continuation":"tok"}}"#;
let result: Result<PipelineNodeState, _> = serde_json::from_str(legacy);
assert!(
result.is_err(),
"legacy SequentialDrain shape must fail to deserialize under the new schema"
);
}
#[test]
fn unknown_kind_fails_to_deserialize() {
let bogus = r#"{"kind":"something_new"}"#;
let result: Result<PipelineNodeState, _> = serde_json::from_str(bogus);
assert!(result.is_err());
}
#[test]
fn into_child_contribution_maps_drained() {
let contrib = PipelineNodeState::Drained
.into_child_contribution("Parent", 0, 1)
.expect("Drained must always reduce successfully");
assert_eq!(contrib, ChildSnapshotContribution::Drained);
}
#[test]
fn into_child_contribution_maps_request_with_token() {
let contrib = PipelineNodeState::Request {
server_continuation: Some("tok".to_owned()),
}
.into_child_contribution("Parent", 0, 1)
.expect("Request must always reduce successfully");
assert_eq!(
contrib,
ChildSnapshotContribution::Pending {
server_continuation: Some("tok".to_owned()),
}
);
}
#[test]
fn into_child_contribution_maps_request_without_token() {
let contrib = PipelineNodeState::Request {
server_continuation: None,
}
.into_child_contribution("Parent", 1, 4)
.expect("Request must always reduce successfully");
assert_eq!(
contrib,
ChildSnapshotContribution::Pending {
server_continuation: None,
}
);
}
#[test]
fn into_child_contribution_rejects_nested_sequential_drain() {
let err = PipelineNodeState::SequentialDrain {
left_most_undrained_epk: "80".to_owned(),
active_tokens: vec![],
}
.into_child_contribution("Parent", 2, 5)
.expect_err("nested SequentialDrain is not a supported child shape");
let msg = format!("{err:?}");
assert!(
msg.contains("Parent child 2 of 5"),
"error should carry parent/idx/total context: {msg}"
);
assert!(
msg.contains("SequentialDrain"),
"error should name the offending variant: {msg}"
);
}
}