use std::collections::HashMap;
use crate::reactions::checkpoint::ReactionCheckpoint;
use crate::reactions::common::base::ReactionBase;
use crate::recovery::ReactionRecoveryPolicy;
pub struct CheckpointState {
checkpoints: HashMap<String, ReactionCheckpoint>,
has_store: bool,
}
impl CheckpointState {
pub async fn load(base: &ReactionBase) -> Self {
let has_store = base.state_store().await.is_some();
Self {
checkpoints: HashMap::new(),
has_store,
}
}
async fn ensure_seeded(&mut self, base: &ReactionBase, query_id: &str) -> anyhow::Result<()> {
if self.checkpoints.contains_key(query_id) {
return Ok(());
}
let seed = if self.has_store {
base.read_checkpoint(query_id).await?
} else {
None
};
self.checkpoints.insert(
query_id.to_string(),
seed.unwrap_or(ReactionCheckpoint {
sequence: 0,
config_hash: 0,
}),
);
Ok(())
}
pub async fn advance(
&mut self,
base: &ReactionBase,
query_id: &str,
sequence: u64,
) -> anyhow::Result<()> {
self.ensure_seeded(base, query_id).await?;
let current = self
.checkpoints
.get(query_id)
.expect("checkpoint present after ensure_seeded");
if sequence <= current.sequence {
return Ok(());
}
let cp = ReactionCheckpoint {
sequence,
config_hash: current.config_hash,
};
if self.has_store {
base.write_checkpoint(query_id, &cp).await?;
}
self.checkpoints.insert(query_id.to_string(), cp);
Ok(())
}
}
pub fn batch_checkpoint_candidates<I>(items: I) -> (HashMap<String, u64>, HashMap<String, u64>)
where
I: IntoIterator<Item = (String, u64, bool)>,
{
let mut completed: HashMap<String, u64> = HashMap::new();
let mut seen: HashMap<String, u64> = HashMap::new();
for (query_id, sequence, is_terminal) in items {
let e = seen.entry(query_id.clone()).or_insert(0);
*e = (*e).max(sequence);
if is_terminal {
let e = completed.entry(query_id).or_insert(0);
*e = (*e).max(sequence);
}
}
(completed, seen)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureAction {
Stop,
SkipAndContinue,
}
impl FailureAction {
pub fn from_policy(policy: ReactionRecoveryPolicy) -> Self {
match policy {
ReactionRecoveryPolicy::AutoSkipGap => FailureAction::SkipAndContinue,
_ => FailureAction::Stop,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::reactions::common::base::ReactionBaseParams;
use std::sync::Arc;
async fn store_backed_base(id: &str) -> ReactionBase {
let base = ReactionBase::new(ReactionBaseParams::new(id, vec!["q1".to_string()]));
let store = Arc::new(crate::state_store::MemoryStateStoreProvider::new());
let (graph, _rx) = crate::component_graph::ComponentGraph::new("inst");
let ctx = crate::context::ReactionRuntimeContext::new(
"inst",
id,
Some(store),
graph.update_sender(),
None,
);
base.initialize(ctx).await;
base
}
#[tokio::test]
async fn advance_persists_monotonically_and_preserves_seeded_config_hash() {
let base = store_backed_base("ckpt-test").await;
base.write_checkpoint(
"q1",
&ReactionCheckpoint {
sequence: 3,
config_hash: 99,
},
)
.await
.unwrap();
let mut state = CheckpointState::load(&base).await;
state.advance(&base, "q1", 7).await.unwrap();
let cp = base.read_checkpoint("q1").await.unwrap().unwrap();
assert_eq!(cp.sequence, 7);
assert_eq!(
cp.config_hash, 99,
"config_hash must be preserved, not zeroed"
);
state.advance(&base, "q1", 5).await.unwrap();
assert_eq!(
base.read_checkpoint("q1").await.unwrap().unwrap().sequence,
7
);
}
#[tokio::test]
async fn advance_without_store_is_a_noop() {
let base = ReactionBase::new(ReactionBaseParams::new("no-store", vec!["q1".to_string()]));
let mut state = CheckpointState::load(&base).await;
state.advance(&base, "q1", 7).await.unwrap();
assert!(base.read_checkpoint("q1").await.unwrap().is_none());
}
#[test]
fn candidates_advance_completed_only_for_terminal_items() {
let (completed, seen) = batch_checkpoint_candidates([
("q1".to_string(), 9, false),
("q1".to_string(), 9, false),
("q1".to_string(), 9, true),
]);
assert_eq!(completed.get("q1"), Some(&9));
assert_eq!(seen.get("q1"), Some(&9));
}
#[test]
fn candidates_do_not_advance_completed_for_a_split_tail() {
let (completed, seen) = batch_checkpoint_candidates([
("q1".to_string(), 8, true), ("q1".to_string(), 9, false), ]);
assert_eq!(completed.get("q1"), Some(&8));
assert_eq!(seen.get("q1"), Some(&9));
}
#[test]
fn candidates_track_multiple_queries_independently() {
let (completed, seen) = batch_checkpoint_candidates([
("q1".to_string(), 4, true),
("q2".to_string(), 11, true),
("q1".to_string(), 5, false),
]);
assert_eq!(completed.get("q1"), Some(&4));
assert_eq!(completed.get("q2"), Some(&11));
assert_eq!(seen.get("q1"), Some(&5));
assert_eq!(seen.get("q2"), Some(&11));
}
#[test]
fn failure_action_maps_policy() {
assert_eq!(
FailureAction::from_policy(ReactionRecoveryPolicy::Strict),
FailureAction::Stop
);
assert_eq!(
FailureAction::from_policy(ReactionRecoveryPolicy::AutoReset),
FailureAction::Stop
);
assert_eq!(
FailureAction::from_policy(ReactionRecoveryPolicy::AutoSkipGap),
FailureAction::SkipAndContinue
);
}
}