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,
}
#[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
}
}
#[derive(Debug, Clone)]
pub struct SnapshotEntry {
pub rev: u64,
pub data: Vars,
pub timestamp: i64,
}
pub(crate) struct SnapshotStore {
pub(crate) options: SnapshotOptions,
entries: RwLock<HashMap<String, SnapshotEntry>>,
}
impl SnapshotStore {
pub(crate) fn new(options: SnapshotOptions) -> Self {
Self {
options,
entries: RwLock::new(HashMap::new()),
}
}
pub(crate) fn get(&self, scope: &str) -> Option<SnapshotEntry> {
let entry = self.entries.read().get(scope).cloned();
if let Some(ttl) = self.options.ttl_secs
&& let Some(e) = &entry
&& now_ms() - e.timestamp > ttl as i64 * 1000
{
self.entries.write().remove(scope);
return None;
}
entry
}
pub(crate) fn upsert(&self, scope: &str, rev: u64, data: Vars) {
self.entries.write().insert(
scope.to_string(),
SnapshotEntry {
rev,
data,
timestamp: now_ms(),
},
);
}
pub(crate) fn remove(&self, scope: &str) {
self.entries.write().remove(scope);
}
pub(crate) fn purge_expired(&self) -> usize {
let Some(ttl) = self.options.ttl_secs else {
return 0;
};
let now = now_ms();
let mut guard = self.entries.write();
let before = guard.len();
guard.retain(|_, e| now - e.timestamp <= ttl as i64 * 1000);
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)
}
#[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) {
self.runtime.register_snapshot(name, options);
}
pub fn upsert(&self, name: &str, scope: &str, rev: u64, data: Vars) {
let store = self.runtime.snapshot_store(name).unwrap_or_else(|| {
self.runtime
.register_snapshot(name, SnapshotOptions::default())
});
store.upsert(scope, rev, data);
}
pub fn remove(&self, name: &str, scope: &str) {
if let Some(store) = self.runtime.snapshot_store(name) {
store.remove(scope);
}
}
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::*;
#[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 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);
}
}