use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConsistencyMode {
Strong,
ReadYourWrites,
BoundedStaleness,
Eventual,
ProjectionOk,
CacheOk,
}
impl Default for ConsistencyMode {
fn default() -> Self {
Self::Strong
}
}
impl ConsistencyMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Strong => "strong",
Self::ReadYourWrites => "read_your_writes",
Self::BoundedStaleness => "bounded_staleness",
Self::Eventual => "eventual",
Self::ProjectionOk => "projection_ok",
Self::CacheOk => "cache_ok",
}
}
pub fn parse(token: &str) -> Option<Self> {
match token.trim().to_ascii_lowercase().as_str() {
"strong" | "linearizable" | "primary" => Some(Self::Strong),
"read_your_writes" | "ryw" | "read-your-writes" => Some(Self::ReadYourWrites),
"bounded_staleness" | "bounded-staleness" => Some(Self::BoundedStaleness),
"eventual" | "eventual_consistency" => Some(Self::Eventual),
"projection_ok" | "projection-ok" => Some(Self::ProjectionOk),
"cache_ok" | "cache-ok" => Some(Self::CacheOk),
_ => None,
}
}
pub fn parse_or_default(token: &str) -> Self {
Self::parse(token).unwrap_or_default()
}
pub fn allows_replica(self) -> bool {
!matches!(self, Self::Strong | Self::ReadYourWrites)
}
pub fn allows_projection(self) -> bool {
matches!(
self,
Self::Eventual | Self::ProjectionOk | Self::CacheOk | Self::BoundedStaleness
)
}
pub fn allows_cache(self) -> bool {
matches!(self, Self::CacheOk | Self::Eventual)
}
pub fn honours_fence(self) -> bool {
!matches!(self, Self::CacheOk)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WriteReceipt {
pub source_lsn: String,
pub outbox_seq: u64,
pub projection_task_ids: Vec<String>,
pub manifest_checksum: String,
pub written_at_unix_ms: i64,
}
impl WriteReceipt {
pub fn empty() -> Self {
Self {
source_lsn: String::new(),
outbox_seq: 0,
projection_task_ids: Vec::new(),
manifest_checksum: String::new(),
written_at_unix_ms: 0,
}
}
pub fn is_empty(&self) -> bool {
self.source_lsn.is_empty()
&& self.outbox_seq == 0
&& self.projection_task_ids.is_empty()
&& self.manifest_checksum.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ReadFence {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub min_outbox_lsn: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub projection_task_ids: Vec<String>,
#[serde(default)]
pub max_wait_ms: u64,
}
impl ReadFence {
pub fn from_receipt(receipt: &WriteReceipt, max_wait_ms: u64) -> Self {
Self {
min_outbox_lsn: receipt.source_lsn.clone(),
projection_task_ids: receipt.projection_task_ids.clone(),
max_wait_ms,
}
}
pub fn is_empty(&self) -> bool {
self.min_outbox_lsn.is_empty() && self.projection_task_ids.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum StaleReadWarning {
FenceTimedOut {
backend: String,
instance: String,
lag_ms: u64,
},
ReplicaLagExceeded {
instance: String,
lag_ms: u64,
budget_ms: u64,
},
ProjectionMissing { backend: String, resource: String },
CacheStale {
backend: String,
cache_key_prefix: String,
},
}
impl StaleReadWarning {
pub fn kind_token(&self) -> &'static str {
match self {
Self::FenceTimedOut { .. } => "fence_timed_out",
Self::ReplicaLagExceeded { .. } => "replica_lag_exceeded",
Self::ProjectionMissing { .. } => "projection_missing",
Self::CacheStale { .. } => "cache_stale",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ConsistencyPolicy {
pub mode: ConsistencyMode,
pub fence: ReadFence,
pub max_replica_lag_ms: u64,
}
impl ConsistencyPolicy {
pub fn from_request_context(
consistency_token: &str,
max_replica_lag_ms: u64,
primary_read: bool,
eventual_consistency_allowed: bool,
) -> Self {
let mode = if primary_read {
ConsistencyMode::Strong
} else if consistency_token.trim().is_empty() && eventual_consistency_allowed {
ConsistencyMode::Eventual
} else {
ConsistencyMode::parse_or_default(consistency_token)
};
Self {
mode,
fence: ReadFence::default(),
max_replica_lag_ms,
}
}
pub fn with_fence(mut self, fence: ReadFence) -> Self {
self.fence = fence;
self
}
pub fn force_primary(&self) -> bool {
matches!(self.mode, ConsistencyMode::Strong)
|| (matches!(self.mode, ConsistencyMode::ReadYourWrites) && self.fence.is_empty())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn consistency_golden_value() -> serde_json::Value {
let receipt = WriteReceipt {
source_lsn: "0/1A2B3C4D".to_string(),
outbox_seq: 42,
projection_task_ids: vec![
"projection-task-a".to_string(),
"projection-task-b".to_string(),
],
manifest_checksum: "sha256:0123456789abcdef".to_string(),
written_at_unix_ms: 1_735_689_600_000,
};
let fence = ReadFence::from_receipt(&receipt, 2_500);
serde_json::json!({
"write_receipt": receipt,
"read_fence": fence,
})
}
#[test]
fn consistency_golden_json_matches_serde_contract() {
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let committed: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(root.join("docs/generated/consistency-golden.json"))
.expect("consistency golden JSON should be readable"),
)
.expect("consistency golden JSON should parse");
assert_eq!(
committed,
consistency_golden_value(),
"docs/generated/consistency-golden.json drifted from WriteReceipt/ReadFence serde"
);
}
#[test]
fn mode_tokens_are_pinned() {
assert_eq!(ConsistencyMode::Strong.as_str(), "strong");
assert_eq!(ConsistencyMode::ReadYourWrites.as_str(), "read_your_writes");
assert_eq!(
ConsistencyMode::BoundedStaleness.as_str(),
"bounded_staleness"
);
assert_eq!(ConsistencyMode::Eventual.as_str(), "eventual");
assert_eq!(ConsistencyMode::ProjectionOk.as_str(), "projection_ok");
assert_eq!(ConsistencyMode::CacheOk.as_str(), "cache_ok");
}
#[test]
fn legacy_aliases_parse() {
assert_eq!(
ConsistencyMode::parse("linearizable"),
Some(ConsistencyMode::Strong)
);
assert_eq!(
ConsistencyMode::parse("primary"),
Some(ConsistencyMode::Strong)
);
assert_eq!(
ConsistencyMode::parse("eventual_consistency"),
Some(ConsistencyMode::Eventual)
);
assert_eq!(
ConsistencyMode::parse("ryw"),
Some(ConsistencyMode::ReadYourWrites)
);
assert_eq!(ConsistencyMode::parse("unknown_mode"), None);
assert_eq!(ConsistencyMode::parse(""), None);
assert_eq!(
ConsistencyMode::parse_or_default("unknown"),
ConsistencyMode::Strong
);
}
#[test]
fn mode_capability_matrix_is_correct() {
assert!(!ConsistencyMode::Strong.allows_replica());
assert!(!ConsistencyMode::Strong.allows_projection());
assert!(!ConsistencyMode::Strong.allows_cache());
assert!(!ConsistencyMode::ReadYourWrites.allows_replica());
assert!(!ConsistencyMode::ReadYourWrites.allows_projection());
assert!(!ConsistencyMode::ReadYourWrites.allows_cache());
assert!(ConsistencyMode::BoundedStaleness.allows_replica());
assert!(ConsistencyMode::BoundedStaleness.allows_projection());
assert!(!ConsistencyMode::BoundedStaleness.allows_cache());
assert!(ConsistencyMode::Eventual.allows_replica());
assert!(ConsistencyMode::Eventual.allows_projection());
assert!(ConsistencyMode::Eventual.allows_cache());
assert!(ConsistencyMode::ProjectionOk.allows_replica());
assert!(ConsistencyMode::ProjectionOk.allows_projection());
assert!(!ConsistencyMode::ProjectionOk.allows_cache());
assert!(ConsistencyMode::CacheOk.allows_replica());
assert!(ConsistencyMode::CacheOk.allows_projection());
assert!(ConsistencyMode::CacheOk.allows_cache());
}
#[test]
fn cache_ok_skips_fence_others_honour_it() {
assert!(!ConsistencyMode::CacheOk.honours_fence());
assert!(ConsistencyMode::Strong.honours_fence());
assert!(ConsistencyMode::ReadYourWrites.honours_fence());
assert!(ConsistencyMode::Eventual.honours_fence());
}
#[test]
fn write_receipt_converts_to_read_fence() {
let receipt = WriteReceipt {
source_lsn: "0/1A2B3C".into(),
outbox_seq: 42,
projection_task_ids: vec!["task-a".into(), "task-b".into()],
manifest_checksum: "abc123".into(),
written_at_unix_ms: 1_700_000_000_000,
};
let fence = ReadFence::from_receipt(&receipt, 5_000);
assert_eq!(fence.min_outbox_lsn, "0/1A2B3C");
assert_eq!(
fence.projection_task_ids,
vec!["task-a".to_string(), "task-b".to_string()]
);
assert_eq!(fence.max_wait_ms, 5_000);
assert!(!fence.is_empty());
}
#[test]
fn empty_receipts_and_fences_are_detectable() {
assert!(WriteReceipt::empty().is_empty());
assert!(ReadFence::default().is_empty());
}
#[test]
fn from_request_context_honours_legacy_flags() {
let p = ConsistencyPolicy::from_request_context("eventual", 0, true, false);
assert_eq!(p.mode, ConsistencyMode::Strong);
let p = ConsistencyPolicy::from_request_context("", 0, false, true);
assert_eq!(p.mode, ConsistencyMode::Eventual);
let p = ConsistencyPolicy::from_request_context("read_your_writes", 0, false, true);
assert_eq!(p.mode, ConsistencyMode::ReadYourWrites);
}
#[test]
fn force_primary_respects_ryw_fence_state() {
let strong = ConsistencyPolicy {
mode: ConsistencyMode::Strong,
..Default::default()
};
assert!(strong.force_primary());
let ryw_no_fence = ConsistencyPolicy {
mode: ConsistencyMode::ReadYourWrites,
..Default::default()
};
assert!(
ryw_no_fence.force_primary(),
"RYW with no fence behaves like Strong"
);
let ryw_with_fence = ConsistencyPolicy {
mode: ConsistencyMode::ReadYourWrites,
fence: ReadFence {
min_outbox_lsn: "0/100".into(),
..Default::default()
},
..Default::default()
};
assert!(
!ryw_with_fence.force_primary(),
"RYW with fence allows replica routing once the fence is satisfied"
);
let eventual = ConsistencyPolicy {
mode: ConsistencyMode::Eventual,
..Default::default()
};
assert!(!eventual.force_primary());
}
#[test]
fn stale_read_warning_tokens_are_pinned() {
let cases = [
(
StaleReadWarning::FenceTimedOut {
backend: "mongodb".into(),
instance: "default".into(),
lag_ms: 1234,
},
"fence_timed_out",
),
(
StaleReadWarning::ReplicaLagExceeded {
instance: "replica-1".into(),
lag_ms: 5_000,
budget_ms: 1_000,
},
"replica_lag_exceeded",
),
(
StaleReadWarning::ProjectionMissing {
backend: "qdrant".into(),
resource: "customers_vec".into(),
},
"projection_missing",
),
(
StaleReadWarning::CacheStale {
backend: "redis".into(),
cache_key_prefix: "udb:billing".into(),
},
"cache_stale",
),
];
for (warning, token) in cases {
assert_eq!(warning.kind_token(), token);
}
}
#[test]
fn serde_round_trip_matches_wire_tokens() {
for mode in [
ConsistencyMode::Strong,
ConsistencyMode::ReadYourWrites,
ConsistencyMode::BoundedStaleness,
ConsistencyMode::Eventual,
ConsistencyMode::ProjectionOk,
ConsistencyMode::CacheOk,
] {
let json = serde_json::to_string(&mode).unwrap();
let token = json.trim_matches('"');
assert_eq!(token, mode.as_str());
let back: ConsistencyMode = serde_json::from_str(&json).unwrap();
assert_eq!(back, mode);
}
}
}