use crate::types::crux_value::Crux;
use crate::types::error::CruxErr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ReplayMode {
#[default]
Strict,
Lenient,
}
#[derive(Debug, Clone)]
struct ReplayEntry {
name: String,
input_hash: u64,
content_hash: Option<u64>,
output: Option<serde_json::Value>,
}
pub enum ReplayResult {
Hit(serde_json::Value),
Mismatch { expected: u64, actual: u64 },
Miss,
}
#[derive(Debug, Clone, Default)]
pub struct ReplayCache {
entries: Vec<ReplayEntry>,
enabled: bool,
mode: ReplayMode,
}
impl ReplayCache {
pub fn new() -> Self {
Self::default()
}
pub fn with_mode(mode: ReplayMode) -> Self {
Self {
mode,
..Self::default()
}
}
pub fn set_mode(&mut self, mode: ReplayMode) {
self.mode = mode;
}
pub fn mode(&self) -> ReplayMode {
self.mode
}
pub fn seed_from(&mut self, previous: &Crux<serde_json::Value>) {
self.entries = previous
.steps
.iter()
.map(|s| ReplayEntry {
name: s.name.clone(),
input_hash: s.input_hash,
content_hash: s.content_hash,
output: s.output.clone(),
})
.collect();
self.enabled = true;
}
pub fn check(&self, ordinal: u32, input_hash: u64) -> ReplayResult {
if !self.enabled {
return ReplayResult::Miss;
}
match self.entries.get(ordinal as usize) {
Some(entry) if entry.input_hash == input_hash => match &entry.output {
Some(output) => ReplayResult::Hit(output.clone()),
None => ReplayResult::Miss,
},
Some(entry) => match self.mode {
ReplayMode::Strict => ReplayResult::Mismatch {
expected: entry.input_hash,
actual: input_hash,
},
ReplayMode::Lenient => ReplayResult::Miss,
},
None => ReplayResult::Miss,
}
}
pub fn check_by_name(
&self,
name: &str,
ordinal: u32,
input_hash: u64,
content_hash: Option<u64>,
) -> ReplayResult {
if !self.enabled {
return ReplayResult::Miss;
}
if let Some(entry) = self.entries.get(ordinal as usize) {
if entry.name == name && entry.input_hash == input_hash {
return match &entry.output {
Some(output) => ReplayResult::Hit(output.clone()),
None => ReplayResult::Miss,
};
}
if self.mode == ReplayMode::Strict {
if entry.name != name {
return ReplayResult::Mismatch {
expected: entry.input_hash,
actual: input_hash,
};
}
return ReplayResult::Mismatch {
expected: entry.input_hash,
actual: input_hash,
};
}
} else if self.mode == ReplayMode::Strict {
return ReplayResult::Miss;
}
let start = (ordinal as usize).saturating_add(1);
for entry in self.entries.iter().skip(start) {
if entry.name == name {
if let (Some(lookup), Some(cached)) = (content_hash, entry.content_hash) {
if lookup != cached {
continue;
}
}
return match &entry.output {
Some(output) => ReplayResult::Hit(output.clone()),
None => ReplayResult::Miss,
};
}
}
ReplayResult::Miss
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
}
impl std::fmt::Debug for ReplayResult {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Hit(_) => write!(f, "ReplayResult::Hit(...)"),
Self::Mismatch { expected, actual } => {
write!(f, "ReplayResult::Mismatch({expected} vs {actual})")
}
Self::Miss => write!(f, "ReplayResult::Miss"),
}
}
}
pub fn deserialize_replay<T: serde::de::DeserializeOwned>(
name: &str,
cached: serde_json::Value,
) -> Result<T, CruxErr> {
serde_json::from_value(cached)
.map_err(|e| CruxErr::step_failed(name, format!("replay deserialize: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::crux_value::Crux;
use crate::types::id::CruxId;
use crate::types::step::{Step, StepKind, StepStatus};
use chrono::Utc;
fn make_snapshot(steps: Vec<Step>) -> Crux<serde_json::Value> {
Crux {
id: CruxId::new(),
agent: "test".into(),
value: Ok(serde_json::json!(null)),
steps,
children: vec![],
started_at: Utc::now(),
finished_at: Some(Utc::now()),
}
}
fn make_step(name: &str, input_hash: u64, output: Option<serde_json::Value>) -> Step {
Step {
name: name.into(),
kind: StepKind::Plain,
status: StepStatus::Ok,
confidence: 1.0,
started_at: Utc::now(),
duration_ms: 0,
input_hash,
content_hash: None,
output,
error: None,
attempt: 1,
events: vec![],
}
}
#[test]
fn hit_returns_cached_output() {
let mut cache = ReplayCache::new();
let snapshot = make_snapshot(vec![make_step("a", 42, Some(serde_json::json!("hello")))]);
cache.seed_from(&snapshot);
match cache.check(0, 42) {
ReplayResult::Hit(val) => assert_eq!(val, serde_json::json!("hello")),
other => panic!("expected Hit, got {other:?}"),
}
}
#[test]
fn mismatch_on_wrong_hash() {
let mut cache = ReplayCache::new();
let snapshot = make_snapshot(vec![make_step("a", 42, Some(serde_json::json!("x")))]);
cache.seed_from(&snapshot);
match cache.check(0, 99) {
ReplayResult::Mismatch { expected, actual } => {
assert_eq!(expected, 42);
assert_eq!(actual, 99);
}
other => panic!("expected Mismatch, got {other:?}"),
}
}
#[test]
fn miss_past_cache_end() {
let mut cache = ReplayCache::new();
let snapshot = make_snapshot(vec![make_step("a", 42, Some(serde_json::json!("x")))]);
cache.seed_from(&snapshot);
assert!(matches!(cache.check(1, 99), ReplayResult::Miss));
}
#[test]
fn miss_when_disabled() {
let cache = ReplayCache::new();
assert!(matches!(cache.check(0, 42), ReplayResult::Miss));
}
#[test]
fn lenient_mode_returns_miss_on_hash_mismatch() {
let mut cache = ReplayCache::with_mode(ReplayMode::Lenient);
let snapshot = make_snapshot(vec![make_step("a", 42, Some(serde_json::json!("x")))]);
cache.seed_from(&snapshot);
assert!(matches!(cache.check(0, 99), ReplayResult::Miss));
}
#[test]
fn strict_mode_mismatch_on_wrong_hash() {
let mut cache = ReplayCache::with_mode(ReplayMode::Strict);
let snapshot = make_snapshot(vec![make_step("a", 42, Some(serde_json::json!("x")))]);
cache.seed_from(&snapshot);
assert!(matches!(cache.check(0, 99), ReplayResult::Mismatch { .. }));
}
#[test]
fn by_name_hit_at_ordinal() {
let mut cache = ReplayCache::with_mode(ReplayMode::Lenient);
let snapshot = make_snapshot(vec![
make_step("fetch", 10, Some(serde_json::json!("data"))),
make_step("parse", 20, Some(serde_json::json!("parsed"))),
]);
cache.seed_from(&snapshot);
match cache.check_by_name("fetch", 0, 10, None) {
ReplayResult::Hit(val) => assert_eq!(val, serde_json::json!("data")),
other => panic!("expected Hit, got {other:?}"),
}
}
#[test]
fn by_name_scans_forward_in_lenient() {
let mut cache = ReplayCache::with_mode(ReplayMode::Lenient);
let snapshot = make_snapshot(vec![
make_step("fetch", 10, Some(serde_json::json!("data"))),
make_step("transform", 20, Some(serde_json::json!("t"))),
make_step("parse", 30, Some(serde_json::json!("parsed"))),
]);
cache.seed_from(&snapshot);
match cache.check_by_name("parse", 1, 999, None) {
ReplayResult::Hit(val) => assert_eq!(val, serde_json::json!("parsed")),
other => panic!("expected Hit from forward scan, got {other:?}"),
}
}
#[test]
fn by_name_strict_rejects_name_mismatch() {
let mut cache = ReplayCache::with_mode(ReplayMode::Strict);
let snapshot = make_snapshot(vec![make_step(
"fetch",
10,
Some(serde_json::json!("data")),
)]);
cache.seed_from(&snapshot);
assert!(matches!(
cache.check_by_name("parse", 0, 10, None),
ReplayResult::Mismatch { .. }
));
}
#[test]
fn by_name_lenient_miss_when_name_not_found() {
let mut cache = ReplayCache::with_mode(ReplayMode::Lenient);
let snapshot = make_snapshot(vec![make_step(
"fetch",
10,
Some(serde_json::json!("data")),
)]);
cache.seed_from(&snapshot);
assert!(matches!(
cache.check_by_name("unknown", 0, 99, None),
ReplayResult::Miss
));
}
#[test]
fn by_name_lenient_miss_on_hash_mismatch() {
let mut cache = ReplayCache::with_mode(ReplayMode::Lenient);
let snapshot = make_snapshot(vec![make_step(
"fetch",
10,
Some(serde_json::json!("data")),
)]);
cache.seed_from(&snapshot);
assert!(matches!(
cache.check_by_name("fetch", 0, 99, None),
ReplayResult::Miss
));
}
fn make_step_with_content(
name: &str,
input_hash: u64,
content_hash: Option<u64>,
output: Option<serde_json::Value>,
) -> Step {
Step {
name: name.into(),
kind: StepKind::Plain,
status: StepStatus::Ok,
confidence: 1.0,
started_at: Utc::now(),
duration_ms: 0,
input_hash,
content_hash,
output,
error: None,
attempt: 1,
events: vec![],
}
}
#[test]
fn forward_scan_skips_content_hash_mismatch() {
let mut cache = ReplayCache::with_mode(ReplayMode::Lenient);
let snapshot = make_snapshot(vec![
make_step("other", 1, Some(serde_json::json!("x"))),
make_step_with_content("fetch", 10, Some(100), Some(serde_json::json!("old_input"))),
make_step_with_content("fetch", 20, Some(200), Some(serde_json::json!("new_input"))),
]);
cache.seed_from(&snapshot);
match cache.check_by_name("fetch", 0, 999, Some(200)) {
ReplayResult::Hit(val) => assert_eq!(val, serde_json::json!("new_input")),
other => panic!("expected Hit with matching content_hash, got {other:?}"),
}
}
#[test]
fn forward_scan_hits_matching_content_hash() {
let mut cache = ReplayCache::with_mode(ReplayMode::Lenient);
let snapshot = make_snapshot(vec![
make_step("other", 1, Some(serde_json::json!("x"))),
make_step_with_content("fetch", 10, Some(100), Some(serde_json::json!("data"))),
]);
cache.seed_from(&snapshot);
match cache.check_by_name("fetch", 0, 999, Some(100)) {
ReplayResult::Hit(val) => assert_eq!(val, serde_json::json!("data")),
other => panic!("expected Hit, got {other:?}"),
}
}
#[test]
fn forward_scan_falls_back_to_name_when_no_content_hash() {
let mut cache = ReplayCache::with_mode(ReplayMode::Lenient);
let snapshot = make_snapshot(vec![
make_step("other", 1, Some(serde_json::json!("x"))),
make_step_with_content("fetch", 10, Some(100), Some(serde_json::json!("data"))),
]);
cache.seed_from(&snapshot);
match cache.check_by_name("fetch", 0, 999, None) {
ReplayResult::Hit(val) => assert_eq!(val, serde_json::json!("data")),
other => panic!("expected Hit (no content_hash filter), got {other:?}"),
}
}
#[test]
fn forward_scan_falls_back_when_cached_has_no_content_hash() {
let mut cache = ReplayCache::with_mode(ReplayMode::Lenient);
let snapshot = make_snapshot(vec![
make_step("other", 1, Some(serde_json::json!("x"))),
make_step("fetch", 10, Some(serde_json::json!("data"))),
]);
cache.seed_from(&snapshot);
match cache.check_by_name("fetch", 0, 999, Some(100)) {
ReplayResult::Hit(val) => assert_eq!(val, serde_json::json!("data")),
other => panic!("expected Hit (cached has no content_hash), got {other:?}"),
}
}
#[test]
fn forward_scan_miss_when_all_content_hashes_differ() {
let mut cache = ReplayCache::with_mode(ReplayMode::Lenient);
let snapshot = make_snapshot(vec![
make_step("other", 1, Some(serde_json::json!("x"))),
make_step_with_content("fetch", 10, Some(100), Some(serde_json::json!("a"))),
make_step_with_content("fetch", 20, Some(200), Some(serde_json::json!("b"))),
]);
cache.seed_from(&snapshot);
assert!(matches!(
cache.check_by_name("fetch", 0, 999, Some(999)),
ReplayResult::Miss
));
}
}
#[cfg(test)]
mod proptest_replay {
use super::*;
use crate::types::step::{Step, StepKind, StepStatus};
use chrono::Utc;
use proptest::prelude::*;
fn arb_step_name() -> impl Strategy<Value = String> {
"[a-z]{1,8}"
}
fn arb_input_hash() -> impl Strategy<Value = u64> {
any::<u64>()
}
fn make_step_prop(name: String, hash: u64, output: Option<serde_json::Value>) -> Step {
Step {
name,
kind: StepKind::Plain,
status: StepStatus::Ok,
confidence: 1.0,
started_at: Utc::now(),
duration_ms: 0,
input_hash: hash,
content_hash: None,
output,
error: None,
attempt: 1,
events: vec![],
}
}
fn make_snapshot_prop(steps: Vec<Step>) -> Crux<serde_json::Value> {
use crate::types::id::CruxId;
Crux {
id: CruxId::new(),
agent: "test".into(),
value: Ok(serde_json::json!(null)),
steps,
children: vec![],
started_at: Utc::now(),
finished_at: Some(Utc::now()),
}
}
proptest! {
#[test]
fn disabled_cache_always_misses(ordinal in 0u32..100, hash in arb_input_hash()) {
let cache = ReplayCache::new();
prop_assert!(!cache.is_enabled());
prop_assert!(matches!(cache.check(ordinal, hash), ReplayResult::Miss));
}
#[test]
fn seeded_cache_hits_first_entry(name in arb_step_name(), hash in arb_input_hash()) {
let mut cache = ReplayCache::new();
let output = serde_json::json!("cached");
let snapshot = make_snapshot_prop(vec![make_step_prop(
name,
hash,
Some(output.clone()),
)]);
cache.seed_from(&snapshot);
prop_assert!(cache.is_enabled());
prop_assert!(matches!(cache.check(0, hash), ReplayResult::Hit(_)));
}
#[test]
fn out_of_bounds_ordinal_is_miss(
name in arb_step_name(),
hash in arb_input_hash(),
) {
let mut cache = ReplayCache::new();
let snapshot = make_snapshot_prop(vec![make_step_prop(
name,
hash,
Some(serde_json::json!(1)),
)]);
cache.seed_from(&snapshot);
prop_assert!(matches!(cache.check(1, hash), ReplayResult::Miss));
}
#[test]
fn strict_hash_mismatch_returns_mismatch(
name in arb_step_name(),
stored_hash in 1u64..u64::MAX,
) {
let lookup_hash = stored_hash - 1; let mut cache = ReplayCache::with_mode(ReplayMode::Strict);
let snapshot = make_snapshot_prop(vec![make_step_prop(
name,
stored_hash,
Some(serde_json::json!(1)),
)]);
cache.seed_from(&snapshot);
match cache.check(0, lookup_hash) {
ReplayResult::Mismatch { expected, actual } => {
prop_assert_eq!(expected, stored_hash);
prop_assert_eq!(actual, lookup_hash);
}
other => prop_assert!(false, "expected Mismatch, got {other:?}"),
}
}
#[test]
fn lenient_hash_mismatch_returns_miss(
name in arb_step_name(),
stored_hash in 1u64..u64::MAX,
) {
let lookup_hash = stored_hash - 1;
let mut cache = ReplayCache::with_mode(ReplayMode::Lenient);
let snapshot = make_snapshot_prop(vec![make_step_prop(
name,
stored_hash,
Some(serde_json::json!(1)),
)]);
cache.seed_from(&snapshot);
prop_assert!(matches!(cache.check(0, lookup_hash), ReplayResult::Miss));
}
#[test]
fn no_output_returns_miss_on_match(name in arb_step_name(), hash in arb_input_hash()) {
let mut cache = ReplayCache::new();
let snapshot = make_snapshot_prop(vec![make_step_prop(name, hash, None)]);
cache.seed_from(&snapshot);
prop_assert!(matches!(cache.check(0, hash), ReplayResult::Miss));
}
#[test]
fn multi_step_all_hit(
names in prop::collection::vec(arb_step_name(), 1..8),
hashes in prop::collection::vec(arb_input_hash(), 1..8),
) {
let len = names.len().min(hashes.len());
let names = &names[..len];
let hashes = &hashes[..len];
let steps: Vec<Step> = names
.iter()
.zip(hashes.iter())
.map(|(n, &h)| make_step_prop(n.clone(), h, Some(serde_json::json!(h))))
.collect();
let mut cache = ReplayCache::new();
let snapshot = make_snapshot_prop(steps);
cache.seed_from(&snapshot);
for (i, &hash) in hashes.iter().enumerate() {
prop_assert!(
matches!(cache.check(i as u32, hash), ReplayResult::Hit(_)),
"expected Hit at ordinal {i}"
);
}
}
}
}