use std::time::{Duration, Instant};
use crate::runtime::consistency::{ReadFence, StaleReadWarning, WriteReceipt};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FenceOutcome {
Cleared,
Stale(StaleReadWarning),
}
fn fence_poll_interval_ms() -> u64 {
std::env::var("UDB_CONSISTENCY_FENCE_POLL_MS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|v| *v > 0)
.unwrap_or(25)
}
pub fn parse_pg_lsn(text: &str) -> Option<u64> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
let (hi, lo) = trimmed.split_once('/')?;
let hi = u64::from_str_radix(hi.trim(), 16).ok()?;
let lo = u64::from_str_radix(lo.trim(), 16).ok()?;
Some((hi << 32) | lo)
}
pub async fn build_write_receipt(
store: &dyn crate::runtime::canonical_store::SystemStores,
manifest_checksum: &str,
projection_tasks: Vec<String>,
) -> WriteReceipt {
use crate::runtime::canonical_store::CanonicalStore;
let source_lsn = CanonicalStore::current_durability_token(store)
.await
.map(|t| t.value)
.unwrap_or_default();
let outbox_seq = CanonicalStore::outbox_max_seq(store)
.await
.ok()
.map(|n| n.max(0) as u64)
.unwrap_or(0);
WriteReceipt {
source_lsn,
outbox_seq,
projection_task_ids: projection_tasks,
manifest_checksum: manifest_checksum.to_string(),
written_at_unix_ms: now_unix_ms(),
}
}
pub async fn wait_for_fence(
store: &dyn crate::runtime::canonical_store::SystemStores,
fence: &ReadFence,
backend_label: &str,
instance_label: &str,
) -> FenceOutcome {
use crate::runtime::canonical_store::system_store::ProjectionTaskStore;
use crate::runtime::canonical_store::{CanonicalStore, DurabilityToken};
if fence.is_empty() {
return FenceOutcome::Cleared;
}
let target_tasks: Vec<String> = fence
.projection_task_ids
.iter()
.filter(|s| !s.trim().is_empty())
.cloned()
.collect();
let max_wait = Duration::from_millis(fence.max_wait_ms);
let poll = Duration::from_millis(fence_poll_interval_ms());
let started = Instant::now();
let store_label = CanonicalStore::backend_label(store).to_string();
loop {
let lsn_cleared = if fence.min_outbox_lsn.is_empty() {
true
} else {
let token = DurabilityToken::new(store_label.clone(), fence.min_outbox_lsn.clone());
let remaining = max_wait.saturating_sub(started.elapsed());
if remaining.is_zero() {
false
} else {
CanonicalStore::wait_for_token(store, &token, remaining)
.await
.unwrap_or(false)
}
};
let tasks_cleared = if target_tasks.is_empty() {
true
} else {
match ProjectionTaskStore::pending_projection_task_count(store, &target_tasks).await {
Ok(n) => n == 0,
Err(_) => false,
}
};
if lsn_cleared && tasks_cleared {
return FenceOutcome::Cleared;
}
if started.elapsed() >= max_wait {
let lag_ms = started.elapsed().as_millis() as u64;
if !tasks_cleared {
let resource = target_tasks.join(",");
return FenceOutcome::Stale(StaleReadWarning::ProjectionMissing {
backend: backend_label.to_string(),
resource,
});
}
let _ = instance_label;
return FenceOutcome::Stale(StaleReadWarning::FenceTimedOut {
backend: backend_label.to_string(),
instance: instance_label.to_string(),
lag_ms,
});
}
tokio::time::sleep(poll).await;
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct RedisCacheStamp {
pub manifest_checksum: String,
pub source_lsn: String,
pub projection_version: i64,
pub written_at_unix_ms: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct StampedCacheValue<T> {
pub stamp: RedisCacheStamp,
pub value: T,
}
impl<T> StampedCacheValue<T> {
pub fn new(stamp: RedisCacheStamp, value: T) -> Self {
Self { stamp, value }
}
pub fn is_fresh_against(&self, current_checksum: &str, min_lsn: Option<u64>) -> bool {
if self.stamp.manifest_checksum != current_checksum {
return false;
}
if let Some(min) = min_lsn {
let written_lsn = parse_pg_lsn(&self.stamp.source_lsn);
match written_lsn {
Some(w) => w >= min,
None => return false,
}
} else {
true
}
}
}
fn now_unix_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::consistency::ReadFence;
#[test]
fn parse_pg_lsn_round_trips_canonical_form() {
assert_eq!(parse_pg_lsn("0/1A2B3C4D"), Some(0x1A2B3C4D));
assert_eq!(parse_pg_lsn("1/0"), Some(1u64 << 32));
assert_eq!(
parse_pg_lsn("FFFFFFFF/FFFFFFFF"),
Some(0xFFFF_FFFF_FFFF_FFFFu64)
);
assert_eq!(parse_pg_lsn(""), None);
assert_eq!(parse_pg_lsn("not-an-lsn"), None);
assert_eq!(parse_pg_lsn("0/"), None);
assert_eq!(parse_pg_lsn("/0"), None);
}
#[test]
fn empty_fence_is_cleared_immediately() {
let fence = ReadFence::default();
assert!(fence.is_empty());
}
#[test]
fn cache_stamp_rejects_stale_manifest_checksum() {
let stamp = RedisCacheStamp {
manifest_checksum: "abc123".to_string(),
source_lsn: "0/1000".to_string(),
projection_version: 0,
written_at_unix_ms: 1,
};
let cached = StampedCacheValue::new(stamp, 42u32);
assert!(cached.is_fresh_against("abc123", None));
assert!(
!cached.is_fresh_against("def456", None),
"different checksum must invalidate"
);
}
#[test]
fn cache_stamp_rejects_lsn_below_fence() {
let stamp = RedisCacheStamp {
manifest_checksum: "abc".to_string(),
source_lsn: "0/1000".to_string(),
projection_version: 0,
written_at_unix_ms: 1,
};
let cached = StampedCacheValue::new(stamp, 42u32);
assert!(!cached.is_fresh_against("abc", Some(0x2000)));
assert!(cached.is_fresh_against("abc", Some(0x0800)));
}
#[test]
fn cache_stamp_without_lsn_fails_against_lsn_fence() {
let stamp = RedisCacheStamp {
manifest_checksum: "abc".to_string(),
source_lsn: String::new(),
projection_version: 0,
written_at_unix_ms: 1,
};
let cached = StampedCacheValue::new(stamp, 0u32);
assert!(!cached.is_fresh_against("abc", Some(0x1000)));
assert!(cached.is_fresh_against("abc", None));
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn fence_e2e_against_sqlite_system_stores() {
use crate::runtime::canonical_store::SystemStores;
use crate::runtime::canonical_store::sqlite::SqliteCanonicalStore;
use crate::runtime::canonical_store::system_store::{
ProjectionOperation, ProjectionTaskInsert, ProjectionTaskStore,
};
use crate::runtime::consistency::ReadFence;
use sqlx::sqlite::SqlitePoolOptions;
use std::sync::Arc;
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite");
let store = Arc::new(SqliteCanonicalStore::new(pool, "test", "udb_outbox_events"));
ProjectionTaskStore::ensure_projection_tables(store.as_ref())
.await
.expect("ensure projection tables");
crate::runtime::canonical_store::CanonicalStore::ensure_system_tables(store.as_ref())
.await
.expect("ensure outbox");
let store_dyn: Arc<dyn SystemStores> = store.clone();
let receipt = super::build_write_receipt(
store_dyn.as_ref(),
"sha256:manifest-v1",
vec!["task-1".to_string()],
)
.await;
assert_eq!(receipt.manifest_checksum, "sha256:manifest-v1");
assert!(
!receipt.source_lsn.is_empty(),
"SQLite source_lsn must be the PRAGMA data_version string"
);
assert_eq!(receipt.outbox_seq, 0, "fresh outbox starts at 0");
assert_eq!(receipt.projection_task_ids, vec!["task-1".to_string()]);
let cleared =
super::wait_for_fence(store_dyn.as_ref(), &ReadFence::default(), "sqlite", "test")
.await;
assert_eq!(cleared, FenceOutcome::Cleared);
let cleared = super::wait_for_fence(
store_dyn.as_ref(),
&ReadFence {
min_outbox_lsn: String::new(),
projection_task_ids: vec!["ghost".to_string()],
max_wait_ms: 50,
},
"sqlite",
"test",
)
.await;
assert_eq!(cleared, FenceOutcome::Cleared);
store
.enqueue_projection_task(&ProjectionTaskInsert {
idempotency_key: "pending-task".to_string(),
project_id: "p".to_string(),
manifest_checksum: "h".to_string(),
message_type: "m".to_string(),
source_schema: "s".to_string(),
source_table: "t".to_string(),
source_row_key: serde_json::json!({"k": 1}),
operation: ProjectionOperation::Upsert,
target_backend: "qdrant".to_string(),
target_instance: "".to_string(),
projection_kind: "vector".to_string(),
resource_name: "x".to_string(),
target_options: serde_json::json!([]),
source_payload: serde_json::json!({}),
source_checksum: "c".to_string(),
})
.await
.unwrap();
let outcome = super::wait_for_fence(
store_dyn.as_ref(),
&ReadFence {
min_outbox_lsn: String::new(),
projection_task_ids: vec!["pending-task".to_string()],
max_wait_ms: 100,
},
"sqlite",
"test",
)
.await;
match outcome {
FenceOutcome::Stale(
crate::runtime::consistency::StaleReadWarning::ProjectionMissing {
backend,
resource,
},
) => {
assert_eq!(backend, "sqlite");
assert!(resource.contains("pending-task"));
}
other => panic!("expected ProjectionMissing, got {other:?}"),
}
}
#[test]
fn stamped_cache_value_round_trips_through_json() {
let stamp = RedisCacheStamp {
manifest_checksum: "abc".to_string(),
source_lsn: "0/100".to_string(),
projection_version: 7,
written_at_unix_ms: 12_345,
};
let v = StampedCacheValue::new(stamp.clone(), "hello".to_string());
let json = serde_json::to_string(&v).unwrap();
assert!(json.contains(r#""stamp":{"manifest_checksum":"abc""#));
assert!(json.contains(r#""source_lsn":"0/100""#));
assert!(json.contains(r#""projection_version":7"#));
assert!(json.contains(r#""value":"hello""#));
let back: StampedCacheValue<String> = serde_json::from_str(&json).unwrap();
assert_eq!(back.stamp, stamp);
assert_eq!(back.value, "hello");
}
}