use crate::{ActError, Vars, config::MissingParamAction, scheduler::Runtime};
use parking_lot::RwLock;
use std::{
collections::HashMap,
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SnapshotPolicy {
#[default]
PerProc,
PerTask,
}
pub const MAX_TTL_SECS: u64 = i64::MAX as u64 / 1000;
#[derive(Debug, Clone)]
pub struct SnapshotOptions {
pub policy: SnapshotPolicy,
pub scope: Vec<String>,
pub on_missing: MissingParamAction,
pub ttl_secs: Option<u64>,
}
impl Default for SnapshotOptions {
fn default() -> Self {
Self {
policy: SnapshotPolicy::PerProc,
scope: Vec::new(),
on_missing: MissingParamAction::Skip,
ttl_secs: None,
}
}
}
impl SnapshotOptions {
pub fn per_proc() -> Self {
Self::default()
}
pub fn per_task() -> Self {
Self {
policy: SnapshotPolicy::PerTask,
..Default::default()
}
}
pub fn with_ttl(mut self, ttl_secs: u64) -> Self {
self.ttl_secs = Some(ttl_secs);
self
}
pub fn validate(&self) -> crate::Result<()> {
match self.ttl_secs {
Some(ttl) if ttl > MAX_TTL_SECS => Err(ActError::Config(format!(
"snapshot ttl {ttl}s exceeds the maximum of {MAX_TTL_SECS}s"
))),
_ => Ok(()),
}
}
}
#[derive(Debug, Clone)]
pub struct SnapshotEntry {
pub rev: u64,
pub data: Vars,
pub timestamp: i64,
}
pub(crate) struct SnapshotStore {
pub(crate) options: SnapshotOptions,
ttl_ms: Option<u64>,
entries: RwLock<HashMap<String, SnapshotEntry>>,
}
impl SnapshotStore {
pub(crate) fn new(options: SnapshotOptions) -> Self {
let ttl_ms = options.ttl_secs.map(|ttl| ttl.saturating_mul(1000));
Self {
options,
ttl_ms,
entries: RwLock::new(HashMap::new()),
}
}
pub(crate) fn get(&self, scope: &str) -> Option<SnapshotEntry> {
let observed = self.entries.read().get(scope).cloned()?;
if !self.is_expired(&observed) {
return Some(observed);
}
self.evict_expired(scope, &observed)
}
fn is_expired(&self, entry: &SnapshotEntry) -> bool {
self.ttl_ms
.is_some_and(|ttl_ms| is_expired_at(ttl_ms, entry.timestamp, now_ms()))
}
fn evict_expired(&self, scope: &str, observed: &SnapshotEntry) -> Option<SnapshotEntry> {
let mut entries = self.entries.write();
let cur = entries.get(scope)?;
if cur.rev != observed.rev || cur.timestamp != observed.timestamp {
return (!self.is_expired(cur)).then(|| cur.clone());
}
entries.remove(scope);
None
}
pub(crate) fn upsert(&self, scope: &str, rev: u64, data: Vars) {
let now = now_ms();
let mut entries = self.entries.write();
if let Some(entry) = entries.get_mut(scope) {
if rev > entry.rev {
entry.rev = rev;
entry.data = data;
entry.timestamp = now;
} else if rev == entry.rev {
entry.timestamp = now;
}
return;
}
entries.insert(
scope.to_string(),
SnapshotEntry {
rev,
data,
timestamp: now,
},
);
}
pub(crate) fn remove(&self, scope: &str) {
self.entries.write().remove(scope);
}
pub(crate) fn purge_expired(&self) -> usize {
if self.options.ttl_secs.is_none() {
return 0;
}
let mut guard = self.entries.write();
let before = guard.len();
guard.retain(|_, entry| !self.is_expired(entry));
before - guard.len()
}
pub(crate) fn list(&self) -> Vec<(String, SnapshotEntry)> {
self.entries
.read()
.iter()
.map(|(scope, entry)| (scope.clone(), entry.clone()))
.collect()
}
}
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
fn is_expired_at(ttl_ms: u64, timestamp: i64, now: i64) -> bool {
now.saturating_sub(timestamp).max(0) as u64 >= ttl_ms
}
#[derive(Clone)]
pub struct SnapshotManager {
runtime: Arc<Runtime>,
}
impl SnapshotManager {
pub(crate) fn new(runtime: &Arc<Runtime>) -> Self {
Self {
runtime: runtime.clone(),
}
}
pub fn register(&self, name: &str, options: SnapshotOptions) -> crate::Result<()> {
self.runtime.register_snapshot(name, options)?;
Ok(())
}
pub fn upsert(&self, name: &str, scope: &str, rev: u64, data: Vars) -> crate::Result<()> {
let store = match self.runtime.snapshot_store(name) {
Some(store) => store,
None => self
.runtime
.register_snapshot(name, SnapshotOptions::default())?,
};
store.upsert(scope, rev, data);
Ok(())
}
pub fn remove(&self, name: &str, scope: &str) -> crate::Result<()> {
let store = self
.runtime
.snapshot_store(name)
.ok_or_else(|| ActError::Runtime(format!("snapshot '{name}' is not registered")))?;
store.remove(scope);
Ok(())
}
pub fn read(&self, name: &str, scope: &str) -> Option<SnapshotEntry> {
self.runtime.snapshot_store(name)?.get(scope)
}
pub fn list(&self, name: &str) -> Vec<(String, SnapshotEntry)> {
match self.runtime.snapshot_store(name) {
Some(store) => store.list(),
None => Vec::new(),
}
}
}
#[allow(dead_code)]
fn missing_err(name: &str, missing: &[String]) -> crate::ActError {
ActError::Runtime(format!(
"snapshot '{name}' missing required params: {missing:?}"
))
}
fn scope_fragment(v: &serde_json::Value) -> String {
match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
}
}
pub(crate) fn join_scope(values: &[serde_json::Value]) -> String {
let mut scope = String::new();
for v in values {
if !scope.is_empty() {
scope.push('/');
}
scope.push_str(&scope_fragment(v));
}
scope
}
pub(crate) fn resolve_scope_params(
task: &crate::scheduler::Task,
options: &SnapshotOptions,
) -> std::result::Result<Vec<serde_json::Value>, Vec<String>> {
let mut values = Vec::with_capacity(options.scope.len());
let mut missing = Vec::new();
for p in &options.scope {
match task.find::<serde_json::Value>(p) {
Some(v) => values.push(v),
None => missing.push(p.clone()),
}
}
if missing.is_empty() {
Ok(values)
} else {
Err(missing)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Barrier;
#[test]
fn join_scope_strings_and_numbers() {
assert_eq!(join_scope(&[]), "");
assert_eq!(join_scope(&[serde_json::json!("u1")]), "u1");
assert_eq!(
join_scope(&[serde_json::json!("u1"), serde_json::json!("proj-a")]),
"u1/proj-a"
);
assert_eq!(join_scope(&[serde_json::json!(7)]), "7");
}
#[test]
fn store_upsert_read_remove() {
let store = SnapshotStore::new(SnapshotOptions::default());
assert!(store.get("s1").is_none());
store.upsert("s1", 1, Vars::new().with("a", 1));
let entry = store.get("s1").unwrap();
assert_eq!(entry.rev, 1);
assert_eq!(entry.data.get::<i32>("a").unwrap(), 1);
assert!(entry.timestamp > 0);
store.upsert("s1", 2, Vars::new().with("a", 2));
assert_eq!(store.get("s1").unwrap().rev, 2);
assert!(store.get("s2").is_none());
store.remove("s1");
assert!(store.get("s1").is_none());
}
#[test]
fn store_upsert_ignores_stale_and_duplicate_rev() {
let store = SnapshotStore::new(SnapshotOptions::default());
store.upsert("s1", 2, Vars::new().with("a", 2));
store.upsert("s1", 1, Vars::new().with("a", 1));
let entry = store.get("s1").unwrap();
assert_eq!(entry.rev, 2);
assert_eq!(entry.data.get::<i32>("a").unwrap(), 2);
store.upsert("s1", 2, Vars::new().with("a", 99));
let entry = store.get("s1").unwrap();
assert_eq!(entry.rev, 2);
assert_eq!(entry.data.get::<i32>("a").unwrap(), 2);
store.upsert("s1", 3, Vars::new().with("a", 3));
let entry = store.get("s1").unwrap();
assert_eq!(entry.rev, 3);
assert_eq!(entry.data.get::<i32>("a").unwrap(), 3);
}
#[test]
fn store_upsert_replay_refreshes_ttl_basis() {
let store = SnapshotStore::new(SnapshotOptions::default().with_ttl(1));
store.upsert("s1", 1, Vars::new().with("a", 1));
{
let mut entries = store.entries.write();
entries.get_mut("s1").unwrap().timestamp = 1;
}
store.upsert("s1", 1, Vars::new().with("a", 2));
let entry = store.get("s1").unwrap();
assert_eq!(entry.rev, 1);
assert_eq!(entry.data.get::<i32>("a").unwrap(), 1);
assert!(entry.timestamp > 1, "replay must refresh the ttl basis");
}
#[test]
fn store_upsert_concurrent_revs_keep_max() {
let store = Arc::new(SnapshotStore::new(SnapshotOptions::default()));
let barrier = Arc::new(Barrier::new(8));
let mut handles = Vec::new();
for rev in 1..=8u64 {
let store = store.clone();
let barrier = barrier.clone();
handles.push(std::thread::spawn(move || {
barrier.wait();
store.upsert("s1", rev, Vars::new().with("rev", rev as i64));
}));
}
for handle in handles {
handle.join().unwrap();
}
let entry = store.get("s1").unwrap();
assert_eq!(entry.rev, 8);
assert_eq!(entry.data.get::<i64>("rev").unwrap(), 8);
}
#[test]
fn missing_err_format() {
let err = missing_err("profile", &["unit".to_string(), "project".to_string()]);
assert!(
err.to_string().contains("profile") && err.to_string().contains("unit"),
"got: {err}"
);
}
#[test]
fn store_ttl_expires_on_read_and_purges() {
let store = SnapshotStore::new(SnapshotOptions::default().with_ttl(1));
store.upsert("s1", 1, Vars::new().with("a", 1));
assert!(store.get("s1").is_some());
let forever = SnapshotStore::new(SnapshotOptions::default());
forever.upsert("keep", 1, Vars::new().with("a", 1));
std::thread::sleep(std::time::Duration::from_millis(1100));
assert!(store.get("s1").is_none());
assert!(forever.get("keep").is_some());
store.upsert("s1", 2, Vars::new().with("a", 2));
assert!(store.get("s1").is_some());
let multi = SnapshotStore::new(SnapshotOptions::default().with_ttl(1));
multi.upsert("old", 1, Vars::new().with("a", 1));
std::thread::sleep(std::time::Duration::from_millis(1100));
multi.upsert("new", 2, Vars::new().with("a", 2));
assert_eq!(multi.purge_expired(), 1);
assert!(multi.get("old").is_none());
assert!(multi.get("new").is_some());
assert_eq!(multi.purge_expired(), 0);
}
fn age(store: &SnapshotStore, scope: &str) {
store.entries.write().get_mut(scope).unwrap().timestamp = 1;
}
#[test]
fn expired_read_does_not_drop_a_concurrent_refresh() {
let store = SnapshotStore::new(SnapshotOptions::default().with_ttl(1));
store.upsert("s1", 1, Vars::new().with("a", 1));
age(&store, "s1");
let observed = store.entries.read().get("s1").cloned().unwrap();
store.upsert("s1", 2, Vars::new().with("a", 2));
let got = store.evict_expired("s1", &observed).unwrap();
assert_eq!(got.rev, 2);
assert_eq!(got.data.get::<i32>("a").unwrap(), 2);
assert_eq!(store.entries.read().get("s1").unwrap().rev, 2);
age(&store, "s1");
let observed = store.entries.read().get("s1").cloned().unwrap();
assert!(store.evict_expired("s1", &observed).is_none());
assert!(store.entries.read().get("s1").is_none());
}
#[test]
fn expired_read_and_refresh_race_keeps_the_refresh() {
let store = Arc::new(SnapshotStore::new(SnapshotOptions::default().with_ttl(1)));
for i in 0..64 {
let scope = format!("s{i}");
store.upsert(&scope, 1, Vars::new().with("a", 1));
age(&store, &scope);
let barrier = Arc::new(Barrier::new(2));
let reader = {
let store = store.clone();
let scope = scope.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
store.get(&scope);
})
};
let writer = {
let store = store.clone();
let scope = scope.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
store.upsert(&scope, 2, Vars::new().with("a", 2));
})
};
reader.join().unwrap();
writer.join().unwrap();
let entry = store.get(&scope).expect("refresh must survive the read");
assert_eq!(entry.rev, 2);
assert_eq!(entry.data.get::<i32>("a").unwrap(), 2);
}
}
#[test]
fn ttl_validation_bounds() {
assert!(SnapshotOptions::default().with_ttl(0).validate().is_ok());
assert!(
SnapshotOptions::default()
.with_ttl(MAX_TTL_SECS)
.validate()
.is_ok()
);
assert!(
SnapshotOptions::default()
.with_ttl(MAX_TTL_SECS + 1)
.validate()
.is_err()
);
assert!(
SnapshotOptions::default()
.with_ttl(u64::MAX)
.validate()
.is_err()
);
}
#[test]
fn ttl_expiry_boundary_is_inclusive() {
assert!(is_expired_at(1000, 1_000, 2_000));
assert!(!is_expired_at(1000, 1_000, 1_999));
assert!(is_expired_at(0, 1_000, 1_000));
assert!(!is_expired_at(1000, 2_000, 1_000));
}
#[test]
fn ttl_zero_expires_on_read_and_purge() {
let store = SnapshotStore::new(SnapshotOptions::default().with_ttl(0));
store.upsert("s1", 1, Vars::new().with("a", 1));
assert!(store.get("s1").is_none(), "ttl 0 is never a valid window");
assert_eq!(store.purge_expired(), 0, "the read already evicted it");
store.upsert("s1", 2, Vars::new().with("a", 2));
assert_eq!(store.purge_expired(), 1);
}
#[test]
fn out_of_range_ttl_never_panics_or_expires() {
for ttl in [u64::MAX, MAX_TTL_SECS + 1] {
let store = SnapshotStore::new(SnapshotOptions::default().with_ttl(ttl));
store.upsert("s1", 1, Vars::new().with("a", 1));
age(&store, "s1");
assert!(
store.get("s1").is_some(),
"ttl {ttl} must not expire a live entry"
);
assert_eq!(store.purge_expired(), 0, "ttl {ttl}");
}
}
}