use crate::{
Act, Action, Config, Engine, Vars, Workflow,
config::ConfigData,
data,
event::EventAction,
scheduler::{NodeContent, NodeTree, Process, Runtime, TaskState},
store::{DbCollectionIden, KvStore, MemoryStore, ScanOptions, Store, StoreBatchOp},
utils,
};
use std::sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
};
#[tokio::test]
async fn cache_evict_breaks_proc_task_cycle() {
let engine = Engine::builder().start().await.unwrap();
let rt = engine.runtime();
let cache = rt.cache();
let workflow = Workflow::new()
.with_id("m1")
.with_step(|s| s.with_id("step1"));
let pid = utils::longid();
let proc = rt.create_proc(&pid, &workflow);
let root = proc
.create_task(&proc.tree().node("step1").unwrap(), None)
.unwrap();
cache.start_proc(&proc, Some(&root)).await.unwrap();
assert_eq!(cache.count(), 1);
assert_eq!(proc.tasks().len(), 1);
let weak = Arc::downgrade(&proc);
cache.evict(&pid);
assert_eq!(cache.count(), 0);
assert_eq!(proc.tasks().len(), 1);
drop(proc);
drop(root);
assert!(
weak.upgrade().is_none(),
"the evicted process must be deallocated — its tasks kept the cycle alive"
);
rt.close().await;
}
#[tokio::test]
async fn cache_restore_dynamic_acts() {
let engine = Engine::builder().start().await.unwrap();
let rt = engine.runtime();
let store = rt.cache().store();
let workflow = Workflow::new()
.with_id("m1")
.with_step(|step| step.with_id("step1"));
let pid = utils::longid();
let proc = rt.create_proc(&pid, &workflow);
let act_ids;
{
let tree = proc.tree();
let step1 = tree.node("step1").unwrap();
let mut prev = step1.clone();
let mut acts = [
Act::irq(|r| r.with_params_vars(|v| v.with("key", "act1"))),
Act::irq(|r| r.with_params_vars(|v| v.with("key", "act2"))),
Act::irq(|r| r.with_params_vars(|v| v.with("key", "act3"))),
];
for act in acts.iter_mut() {
if act.id.is_empty() {
act.id = utils::shortid();
}
let node = tree
.append_node(
&step1,
&act.id,
NodeContent::Act(act.clone()),
step1.level + 1,
)
.unwrap();
if node.level == prev.level {
prev.set_next(&node, true);
} else {
node.set_parent(&step1);
}
prev = node;
}
act_ids = acts.iter().map(|a| a.id.clone()).collect::<Vec<_>>();
}
let step_node = proc.tree().node("step1").unwrap();
let step_task = proc.create_task(&step_node, None).unwrap();
let mut prev_task = step_task;
for id in &act_ids {
let node = proc.tree().node(id).unwrap();
let task = proc.create_task(&node, Some(prev_task.clone())).unwrap();
prev_task = task;
}
for task in proc.tasks() {
store.upsert_task(&task).await.unwrap();
}
store.upsert_proc(&proc).await.unwrap();
let restored = store.load_proc(&pid, &rt).await.unwrap().unwrap();
let act_tasks = restored
.tasks()
.into_iter()
.filter(|t| t.node().kind() == crate::scheduler::NodeKind::Act)
.collect::<Vec<_>>();
assert_eq!(act_tasks.len(), 3);
let a1 = restored.tree().node(&act_ids[0]).unwrap();
let a2 = restored.tree().node(&act_ids[1]).unwrap();
let a3 = restored.tree().node(&act_ids[2]).unwrap();
assert_eq!(a1.next().upgrade().unwrap().id(), a2.id());
assert_eq!(a2.prev().upgrade().unwrap().id(), a1.id());
assert_eq!(a2.next().upgrade().unwrap().id(), a3.id());
assert!(a3.next().upgrade().is_none());
assert_eq!(a1.parent().unwrap().id(), "step1");
let step = restored.tree().node("step1").unwrap();
assert_eq!(step.children().len(), 1);
assert_eq!(step.children()[0].id(), a1.id());
}
#[tokio::test]
async fn cache_count() {
let engine = Engine::builder().cache_size(10).start().await.unwrap();
let rt = engine.runtime();
let cache = rt.cache();
let proc = Process::new(&utils::longid(), &rt);
cache.push_proc(&proc).await.unwrap();
assert_eq!(cache.count(), 1);
}
#[tokio::test]
async fn cache_push_get() {
let engine = Engine::builder().cache_size(10).start().await.unwrap();
let rt = engine.runtime();
let cache = rt.cache();
let pid = utils::longid();
let proc = Process::new(&pid, &rt);
cache.push_proc(&proc).await.unwrap();
assert_eq!(cache.count(), 1);
let proc = cache.proc(&pid, &engine.runtime()).await.unwrap();
assert!(proc.is_some());
}
#[tokio::test]
async fn cache_push_to_store() {
let engine = Engine::builder().cache_size(1).start().await.unwrap();
let rt = engine.runtime();
let cache = rt.cache();
let mut pids = Vec::new();
for _ in 0..5 {
let pid = utils::longid();
let proc = Process::new(&pid, &rt);
cache.push_proc(&proc).await.unwrap();
pids.push(pid);
}
assert_eq!(cache.count(), 5);
for pid in pids.iter() {
let exists = cache.store().procs().exists(pid).await.unwrap();
assert!(exists);
}
}
#[tokio::test]
async fn cache_remove() {
let engine = Engine::builder().cache_size(10).start().await.unwrap();
let rt = engine.runtime();
let cache = rt.cache();
let mut pids = Vec::new();
for _ in 0..5 {
let pid = utils::longid();
let proc = Process::new(&pid, &rt);
cache.push_proc(&proc).await.unwrap();
pids.push(pid);
}
assert_eq!(cache.count(), 5);
for pid in pids.iter() {
let exists = cache.store().procs().exists(pid).await.unwrap();
assert!(exists);
cache.remove(pid).await.unwrap();
assert!(cache.proc(pid, &engine.runtime()).await.unwrap().is_none());
let exists = cache.store().procs().exists(pid).await.unwrap();
assert!(!exists);
}
assert_eq!(cache.count(), 0);
}
#[tokio::test]
async fn cache_upsert() {
let engine = Engine::builder().cache_size(10).start().await.unwrap();
let rt = engine.runtime();
let mut workflow = Workflow::new().with_step(|step| step.with_name("step1"));
let pid = utils::longid();
let tree = NodeTree::build(&mut workflow).unwrap();
let cache = rt.cache();
let proc = Process::new(&pid, &rt);
cache.push_proc(&proc).await.unwrap();
assert_eq!(cache.count(), 1);
let node = tree.root.as_ref().unwrap();
let task = proc.create_task(node, None).unwrap();
proc.set_state(TaskState::Running);
cache.upsert(&task).await.unwrap();
let proc = cache.proc(&pid, &engine.runtime()).await.unwrap().unwrap();
assert_eq!(proc.state(), TaskState::Running);
}
#[tokio::test]
async fn cache_remove_after_writer_writes_drops_all_rows() {
let engine = Engine::builder().cache_size(10).start().await.unwrap();
let rt = engine.runtime();
let cache = rt.cache();
let store = cache.store();
let pid = utils::longid();
let proc = Process::new(&pid, &rt);
cache.push_proc(&proc).await.unwrap();
assert!(store.procs().exists(&pid).await.unwrap());
let mut workflow = Workflow::new().with_step(|step| step.with_name("step1"));
let tree = NodeTree::build(&mut workflow).unwrap();
let node = tree.root.as_ref().unwrap();
let task = proc.create_task(node, None).unwrap();
let tid = task.id.clone();
let task_row_id = utils::Id::new(&pid, &tid).id();
proc.set_state(TaskState::Completed);
cache.upsert_async(&task).await.unwrap();
cache.remove(&pid).await.unwrap();
assert!(!store.procs().exists(&pid).await.unwrap());
assert!(store.tasks().find(&task_row_id).await.is_err());
assert!(cache.proc(&pid, &rt).await.unwrap().is_none());
cache.flush().await.unwrap();
}
#[tokio::test]
async fn cache_writes_after_remove_are_skipped() {
let engine = Engine::builder().cache_size(10).start().await.unwrap();
let rt = engine.runtime();
let cache = rt.cache();
let store = cache.store();
let pid = utils::longid();
let proc = Process::new(&pid, &rt);
cache.push_proc(&proc).await.unwrap();
let mut workflow = Workflow::new().with_step(|step| step.with_name("step1"));
let tree = NodeTree::build(&mut workflow).unwrap();
let node = tree.root.as_ref().unwrap();
let task = proc.create_task(node, None).unwrap();
let tid = task.id.clone();
let task_row_id = utils::Id::new(&pid, &tid).id();
proc.set_state(TaskState::Completed);
cache.upsert_async(&task).await.unwrap();
cache.flush().await.unwrap();
assert!(store.tasks().find(&task_row_id).await.is_ok());
cache.remove(&pid).await.unwrap();
assert!(store.tasks().find(&task_row_id).await.is_err());
cache.upsert_async(&task).await.unwrap();
cache.flush().await.unwrap();
assert!(
store.tasks().find(&task_row_id).await.is_err(),
"late write resurrected the task row of a removed process"
);
assert!(!store.procs().exists(&pid).await.unwrap());
}
#[tokio::test]
async fn cache_start_parked_refills_only_parked_none_rows() {
let config = Config {
data: ConfigData {
cache_cap: Some(5),
..Default::default()
},
table: Default::default(),
};
let rt = Runtime::new(&config, None).unwrap();
let cache = rt.cache();
let model = Workflow::new()
.with_id("m1")
.with_step(|step| step.with_name("step1"));
cache.store().deploy(&model, None).await.unwrap();
let seed = |state: TaskState| data::Proc {
id: utils::longid(),
name: "test".to_string(),
mid: "m1".to_string(),
state: state.to_string(),
start_time: 0,
end_time: 0,
timestamp: 0,
model: model.to_json().unwrap(),
env: "{}".to_string(),
err: None,
removable: false,
v: data::Proc::version(),
};
assert_eq!(cache.count(), 0);
let parked = [
seed(TaskState::None),
seed(TaskState::None),
seed(TaskState::None),
];
let parked_ids: Vec<String> = parked.iter().map(|p| p.id.clone()).collect();
let ignored = [
seed(TaskState::Ready),
seed(TaskState::Running),
seed(TaskState::Pending),
seed(TaskState::Completed),
seed(TaskState::Error),
];
for proc in parked.into_iter().chain(ignored.into_iter()) {
cache.store().procs().create(&proc).await.unwrap();
}
cache.start_parked(&rt).await.unwrap();
assert_eq!(cache.count(), 3);
let resident: Vec<String> = cache.procs().iter().map(|p| p.id().to_string()).collect();
for pid in &parked_ids {
assert!(resident.contains(pid), "parked row {pid} must be started");
let row = cache.store().procs().find(pid).await.unwrap();
assert!(
TaskState::from(row.state.as_str()).is_running(),
"parked row {pid} must be Running"
);
}
let unexpected: Vec<&String> = resident
.iter()
.filter(|p| !parked_ids.contains(p))
.collect();
assert!(
unexpected.is_empty(),
"start_parked must not load non-parked rows: {unexpected:?}"
);
rt.close().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn cache_finished_proc_frees_slot_for_restore() {
let engine = Engine::builder().cache_size(4).start().await.unwrap();
let rt = engine.runtime();
let cache = rt.cache();
let store = cache.store();
let model = Workflow::new()
.with_id("m1")
.with_step(|step| step.with_name("step1"));
store.deploy(&model, None).await.unwrap();
let mut seeds = Vec::new();
for _ in 0..3 {
let pid = utils::longid();
let proc = data::Proc {
id: pid.clone(),
name: "seed".to_string(),
mid: "m1".to_string(),
state: TaskState::None.into(),
start_time: 0,
end_time: 0,
timestamp: 0,
model: model.to_json().unwrap(),
env: "{}".to_string(),
err: None,
removable: false,
v: data::Proc::version(),
};
store.procs().create(&proc).await.unwrap();
seeds.push(pid);
}
let (a, b) = tokio::join!(rt.start(&model, Vars::new()), rt.start(&model, Vars::new()));
let running = [a.unwrap(), b.unwrap()];
let mut pids = seeds.clone();
pids.extend(running.iter().map(|p| p.id().to_string()));
tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
let mut done = true;
for pid in &pids {
if let Ok(row) = store.procs().find(pid).await
&& !TaskState::from(row.state.as_str()).is_completed()
{
done = false;
}
}
if done {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
})
.await
.expect("finished/restored processes never reached a terminal state in time");
}
#[tokio::test]
async fn cache_park_over_cap_then_refill_on_terminal() {
let engine = Engine::builder().cache_size(2).start().await.unwrap();
let rt = engine.runtime();
let cache = rt.cache();
let model = Workflow::new()
.with_id("m1")
.with_step(|step| step.with_name("step1"));
cache.store().deploy(&model, None).await.unwrap();
let make = |tag: &str| {
let pid = format!("park-{tag}");
let proc = Process::new(&pid, &rt);
proc.load(&model).unwrap();
(pid, proc)
};
let (pid1, p1) = make("1");
let (pid2, p2) = make("2");
let (pid3, p3) = make("3");
assert!(cache.admit(&p1).await.unwrap());
assert!(cache.admit(&p2).await.unwrap());
assert_eq!(cache.count(), 2);
assert!(!cache.admit(&p3).await.unwrap());
assert_eq!(cache.count(), 2);
let row = cache.store().procs().find(&pid3).await.unwrap();
assert_eq!(row.state, TaskState::None.to_string());
let resident: Vec<String> = cache.procs().iter().map(|p| p.id().to_string()).collect();
assert!(resident.contains(&pid1));
assert!(resident.contains(&pid2));
assert!(!resident.contains(&pid3));
let got = cache.proc(&pid3, &rt).await.unwrap().unwrap();
assert_eq!(got.id(), pid3);
assert!(got.state().is_none());
assert_eq!(cache.count(), 2);
cache.evict(&pid1);
cache.start_parked(&rt).await.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
match cache.store().procs().find(&pid3).await {
Ok(row) if !TaskState::from(row.state.as_str()).is_completed() => {}
_ => break,
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
})
.await
.expect("parked process never ran to completion after refill");
}
#[tokio::test(flavor = "multi_thread")]
async fn cache_concurrent_proc_miss_returns_one_instance() {
let config = Config::default();
let rt = Runtime::new(&config, None).unwrap();
let cache = rt.cache();
let model = Workflow::new()
.with_id("m1")
.with_step(|step| step.with_name("step1"));
cache.store().deploy(&model, None).await.unwrap();
let pid = utils::longid();
let row = data::Proc {
id: pid.clone(),
name: "test".to_string(),
mid: "m1".to_string(),
state: TaskState::Running.to_string(),
start_time: 0,
end_time: 0,
timestamp: 0,
model: model.to_json().unwrap(),
env: "{}".to_string(),
err: None,
removable: false,
v: data::Proc::version(),
};
cache.store().procs().create(&row).await.unwrap();
let mut handles = Vec::new();
for _ in 0..50 {
let cache = cache.clone();
let rt = rt.clone();
let pid = pid.clone();
handles.push(tokio::spawn(async move {
cache.proc(&pid, &rt).await.unwrap().unwrap()
}));
}
let mut handles = handles.into_iter();
let first = handles.next().unwrap().await.unwrap();
let expected = Arc::as_ptr(&first);
let mut pointers = std::collections::HashSet::from([expected]);
for handle in handles {
let proc = handle.await.unwrap();
assert!(Arc::ptr_eq(&first, &proc));
pointers.insert(Arc::as_ptr(&proc));
}
assert_eq!(pointers.len(), 1);
assert_eq!(cache.count(), 1);
rt.close().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn cache_admit_same_pid_has_single_winner() {
let config = Config {
data: ConfigData {
cache_cap: Some(1),
..Default::default()
},
table: Default::default(),
};
let rt = Runtime::new(&config, None).unwrap();
let cache = rt.cache();
let model = Workflow::new()
.with_id("m1")
.with_step(|step| step.with_name("step1"));
let pid = "concurrent-admit";
let make = || {
let proc = Process::new(pid, &rt);
proc.load(&model).unwrap();
proc
};
let mut handles = Vec::new();
for _ in 0..20 {
let cache = cache.clone();
let proc = make();
handles.push(tokio::spawn(async move { cache.admit(&proc).await }));
}
let mut admitted = 0;
for handle in handles {
match handle.await.unwrap() {
Ok(true) => admitted += 1,
Ok(false) => panic!("cap is 1, so no second process should be parked"),
Err(err) => assert!(err.to_string().contains("duplicated")),
}
}
assert_eq!(admitted, 1);
assert_eq!(cache.count(), 1);
rt.close().await;
}
#[tokio::test]
async fn cache_parked_refill_is_oldest_first_within_cap() {
let config = Config {
data: ConfigData {
cache_cap: Some(2),
..Default::default()
},
table: Default::default(),
};
let rt = Runtime::new(&config, None).unwrap();
let cache = rt.cache();
let model = Workflow::new()
.with_id("m1")
.with_step(|step| step.with_name("step1"));
cache.store().deploy(&model, None).await.unwrap();
let make = |tag: &str, ts: i64| {
let pid = format!("park-fifo-{tag}");
let proc = Process::new_with_timestamp(&pid, ts, &rt);
proc.load(&model).unwrap();
(pid, proc)
};
let (pid1, p1) = make("1", 100);
let (_pid2, p2) = make("2", 200);
let (old, p_old) = make("old", 10);
let (new, p_new) = make("new", 999);
assert!(cache.admit(&p1).await.unwrap());
assert!(cache.admit(&p2).await.unwrap());
assert_eq!(cache.count(), 2);
assert!(!cache.admit(&p_old).await.unwrap());
assert!(!cache.admit(&p_new).await.unwrap());
cache.evict(&pid1);
cache.start_parked(&rt).await.unwrap();
assert_eq!(cache.count(), 2);
let resident: Vec<String> = cache.procs().iter().map(|p| p.id().to_string()).collect();
assert!(
resident.contains(&old),
"oldest parked row must be refilled first: {resident:?}"
);
assert!(!resident.contains(&new));
let still = cache.store().procs().find(&new).await.unwrap();
assert_eq!(still.state, TaskState::None.to_string());
let started = cache.store().procs().find(&old).await.unwrap();
assert!(TaskState::from(started.state.as_str()).is_running());
rt.close().await;
}
#[tokio::test]
async fn cache_vars_row_written_only_on_mutation() {
let engine = Engine::builder().start().await.unwrap();
let rt = engine.runtime();
let store = rt.cache().store();
let workflow = Workflow::new()
.with_id("m1")
.with_step(|s| s.with_id("step1"));
let pid = utils::longid();
let proc = rt.create_proc(&pid, &workflow);
let root = proc
.create_task(&proc.tree().node("step1").unwrap(), None)
.unwrap();
let task_id = utils::Id::new(&pid, &root.id).id();
proc.set_state(TaskState::Running);
root.set_pure_state(TaskState::Running);
root.set_start_time(1);
store.persist_task_rows(&root).await.unwrap();
assert!(
store.vars().find(&task_id).await.is_err(),
"a lifecycle-only write must not write a scope vars row"
);
let row = store.tasks().find(&task_id).await.unwrap();
let json = serde_json::to_string(&row).unwrap();
assert!(
!json.contains("\"data\"") && !json.contains("\"sealed\""),
"the lifecycle row must not carry scope vars: {json}"
);
root.set_data(&Vars::new().with("var1", 10));
store.persist_task_rows(&root).await.unwrap();
let vars = store.vars().find(&task_id).await.unwrap();
let data: Vars = serde_json::from_str(&vars.data).unwrap();
assert_eq!(data.get::<i32>("var1").unwrap(), 10);
assert!(
!root.is_vars_dirty(),
"vars dirty flag must clear after the flush"
);
root.set_pure_state(TaskState::Completed);
root.set_end_time(2);
store.persist_task_rows(&root).await.unwrap();
let vars = store.vars().find(&task_id).await.unwrap();
let data: Vars = serde_json::from_str(&vars.data).unwrap();
assert_eq!(
data.get::<i32>("var1").unwrap(),
10,
"vars row must not be rewritten"
);
}
#[tokio::test]
async fn cache_vars_ancestor_scope_round_trip() {
let engine = Engine::builder().start().await.unwrap();
let rt = engine.runtime();
let store = rt.cache().store();
let workflow = Workflow::new()
.with_id("m1")
.with_step(|s| s.with_id("step1"));
let pid = utils::longid();
let proc = rt.create_proc(&pid, &workflow);
proc.set_state(TaskState::Running);
let root_node = proc.tree().root.clone().unwrap();
let root = proc.create_task(&root_node, None).unwrap();
let step1_node = proc.tree().node("step1").unwrap();
let step1 = proc.create_task(&step1_node, Some(root.clone())).unwrap();
let act_id = utils::shortid();
{
let tree = proc.tree();
let act = Act::irq(|r| r.with_params_vars(|v| v.with("key", "a1"))).with_id(&act_id);
let node = tree
.append_node(
&step1_node,
&act_id,
NodeContent::Act(act),
step1_node.level + 1,
)
.unwrap();
node.set_parent(&step1_node);
}
let act_node = proc.tree().node(&act_id).unwrap();
let act = proc.create_task(&act_node, Some(step1.clone())).unwrap();
let step1_tid = step1.id.clone();
let root_tid = root.id.clone();
step1.set_data_with(|data| data.set("x", 1));
store.persist_task_rows(&step1).await.unwrap();
store.persist_task_rows(&root).await.unwrap();
assert!(
store
.vars()
.find(&utils::Id::new(&pid, &root_tid).id())
.await
.is_err(),
"root scope has no vars row — it never mutated"
);
act.set_data_with(|data| data.set("x", 2));
act.update_data(&act.data());
assert!(
step1.is_vars_dirty(),
"the owner scope must be marked dirty"
);
assert!(!root.is_vars_dirty(), "the root scope must stay untouched");
store.persist_task_rows(&act).await.unwrap();
let step1_vars = store
.vars()
.find(&utils::Id::new(&pid, &step1_tid).id())
.await
.unwrap();
let step1_data: Vars = serde_json::from_str(&step1_vars.data).unwrap();
assert_eq!(
step1_data.get::<i32>("x").unwrap(),
2,
"owner scope row updated"
);
assert!(
store
.vars()
.find(&utils::Id::new(&pid, &root_tid).id())
.await
.is_err(),
"root scope still has no vars row"
);
store.upsert_proc(&proc).await.unwrap();
let restored = store.load_proc(&pid, &rt).await.unwrap().unwrap();
let step1 = restored.task(&step1_tid).unwrap();
assert_eq!(
step1.with_data(|d| d.get::<i32>("x")),
Some(2),
"restored owner scope keeps its updated var"
);
assert_eq!(
restored
.task(&root_tid)
.unwrap()
.with_data(|d| d.get::<i32>("x")),
None,
"the untouched root scope stays empty"
);
}
#[tokio::test]
async fn cache_resume_loads_in_flight_before_parked() {
let config = Config {
data: ConfigData {
cache_cap: Some(3),
..Default::default()
},
table: Default::default(),
};
let rt = Runtime::new(&config, None).unwrap();
let cache = rt.cache();
let model = Workflow::new()
.with_id("m1")
.with_step(|step| step.with_id("step1"))
.with_step(|step| step.with_id("step2"));
cache.store().deploy(&model, None).await.unwrap();
let inflight = {
let proc = Process::new_with_timestamp("resume-inflight", 5, &rt);
proc.load(&model).unwrap();
proc.set_pure_state(TaskState::Running);
proc
};
let root = inflight
.create_task(&inflight.tree().root.clone().unwrap(), None)
.unwrap();
let step1 = inflight
.create_task(&inflight.tree().node("step1").unwrap(), Some(root.clone()))
.unwrap();
root.set_pure_state(TaskState::Running);
step1.set_pure_state(TaskState::Ready);
cache.store().upsert_proc(&inflight).await.unwrap();
cache.store().upsert_task(&root).await.unwrap();
cache.store().upsert_task(&step1).await.unwrap();
let mut parked = Vec::new();
for (tag, ts) in [("a", 10), ("b", 20), ("new", 999)] {
let pid = format!("resume-parked-{tag}");
let proc = Process::new_with_timestamp(&pid, ts, &rt);
proc.load(&model).unwrap();
proc.set_pure_state(TaskState::None);
cache.store().upsert_proc(&proc).await.unwrap();
parked.push(pid);
}
assert_eq!(cache.count(), 0);
rt.resume().await.unwrap();
assert_eq!(cache.count(), 3);
let resident: Vec<String> = cache.procs().iter().map(|p| p.id().to_string()).collect();
assert!(
resident.contains(&"resume-inflight".to_string()),
"in-flight process must be loaded first: {resident:?}"
);
assert!(resident.contains(&"resume-parked-a".to_string()));
assert!(resident.contains(&"resume-parked-b".to_string()));
assert!(!resident.contains(&"resume-parked-new".to_string()));
let waiting = cache
.store()
.procs()
.find("resume-parked-new")
.await
.unwrap();
assert_eq!(waiting.state, TaskState::None.to_string());
let loaded = cache.proc("resume-inflight", &rt).await.unwrap().unwrap();
assert!(loaded.state().is_running());
assert_eq!(
loaded.task_by_nid("step1").first().unwrap().state(),
TaskState::Ready
);
rt.close().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn cache_resume_in_flight_proc_after_restart() {
let kv: Arc<dyn crate::store::KvStore> = Arc::new(MemoryStore::new());
let engine1 = Engine::builder()
.set_store(kv.clone())
.start()
.await
.unwrap();
let store = engine1.runtime().cache().store();
let model = Workflow::new()
.with_id("m1")
.with_step(|step| step.with_id("step1"))
.with_step(|step| step.with_id("step2"));
store.deploy(&model, None).await.unwrap();
let pid = "resume-restart".to_string();
let proc = engine1.runtime().create_proc(&pid, &model);
proc.set_pure_state(TaskState::Running);
let root = proc
.create_task(&proc.tree().root.clone().unwrap(), None)
.unwrap();
let step1 = proc
.create_task(&proc.tree().node("step1").unwrap(), Some(root.clone()))
.unwrap();
root.set_pure_state(TaskState::Running);
step1.set_pure_state(TaskState::Ready);
store.upsert_proc(&proc).await.unwrap();
store.upsert_task(&root).await.unwrap();
store.upsert_task(&step1).await.unwrap();
engine1.close().await;
let engine2 = Engine::builder()
.set_store(kv.clone())
.start()
.await
.unwrap();
let store2 = engine2.runtime().cache().store();
tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
match store2.procs().find(&pid).await {
Ok(row) if !TaskState::from(row.state.as_str()).is_completed() => {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
_ => break,
}
}
})
.await
.expect("resumed process never reached a terminal state after restart");
engine2.close().await;
}
#[tokio::test]
async fn cache_resume_overflow_drains_on_free_slot() {
let config = Config {
data: ConfigData {
cache_cap: Some(2),
..Default::default()
},
table: Default::default(),
};
let rt = Runtime::new(&config, None).unwrap();
let cache = rt.cache();
let model = Workflow::new()
.with_id("m1")
.with_step(|step| step.with_id("step1"));
cache.store().deploy(&model, None).await.unwrap();
let mut pids = Vec::new();
for i in 0..4 {
let pid = format!("overflow-{i}");
let proc = Process::new_with_timestamp(&pid, i as i64 + 1, &rt);
proc.load(&model).unwrap();
proc.set_pure_state(TaskState::Running);
cache.store().upsert_proc(&proc).await.unwrap();
pids.push(pid);
}
assert_eq!(cache.count(), 0);
rt.resume().await.unwrap();
assert_eq!(cache.count(), 2);
assert_eq!(
cache.pending_resume_ids(),
vec![pids[2].clone(), pids[3].clone()]
);
rt.resume().await.unwrap();
assert_eq!(cache.count(), 2);
assert_eq!(
cache.pending_resume_ids(),
vec![pids[2].clone(), pids[3].clone()]
);
cache.evict(&pids[0]);
rt.restore().await.unwrap();
assert_eq!(cache.count(), 2);
let resident: Vec<String> = cache.procs().iter().map(|p| p.id().to_string()).collect();
assert!(resident.contains(&pids[1]));
assert!(resident.contains(&pids[2]));
assert_eq!(cache.pending_resume_ids(), vec![pids[3].clone()]);
cache.evict(&pids[1]);
rt.restore().await.unwrap();
assert_eq!(cache.count(), 2);
let resident: Vec<String> = cache.procs().iter().map(|p| p.id().to_string()).collect();
assert!(resident.contains(&pids[2]));
assert!(resident.contains(&pids[3]));
assert!(cache.pending_resume_ids().is_empty());
rt.close().await;
}
struct GatedKv {
inner: MemoryStore,
find_gate: AtomicBool,
find_entered: AtomicUsize,
write_gate: AtomicBool,
write_entered: AtomicUsize,
}
impl GatedKv {
fn new() -> Self {
Self {
inner: MemoryStore::new(),
find_gate: AtomicBool::new(false),
find_entered: AtomicUsize::new(0),
write_gate: AtomicBool::new(false),
write_entered: AtomicUsize::new(0),
}
}
fn arm_find(&self) {
self.find_gate.store(true, Ordering::SeqCst);
}
fn disarm_find(&self) {
self.find_gate.store(false, Ordering::SeqCst);
}
fn arm_write(&self) {
self.write_gate.store(true, Ordering::SeqCst);
}
fn disarm_write(&self) {
self.write_gate.store(false, Ordering::SeqCst);
}
async fn wait_in_find(&self, entered: usize) {
while self.find_entered.load(Ordering::SeqCst) < entered {
tokio::task::yield_now().await;
}
}
async fn wait_in_write(&self, entered: usize) {
while self.write_entered.load(Ordering::SeqCst) < entered {
tokio::task::yield_now().await;
}
}
async fn park(armed: &AtomicBool, entered: &AtomicUsize) {
entered.fetch_add(1, Ordering::SeqCst);
while armed.load(Ordering::SeqCst) {
tokio::time::sleep(std::time::Duration::from_millis(1)).await;
}
}
}
#[async_trait::async_trait]
impl KvStore for GatedKv {
async fn one(&self, key: &str) -> crate::Result<Option<Vec<u8>>> {
self.inner.one(key).await
}
async fn put(&self, key: &str, value: Vec<u8>) -> crate::Result<()> {
if self.write_gate.load(Ordering::SeqCst) {
Self::park(&self.write_gate, &self.write_entered).await;
}
self.inner.put(key, value).await
}
async fn delete(&self, key: &str) -> crate::Result<()> {
if self.write_gate.load(Ordering::SeqCst) {
Self::park(&self.write_gate, &self.write_entered).await;
}
self.inner.delete(key).await
}
async fn batch(&self, ops: &[StoreBatchOp]) -> crate::Result<()> {
if self.write_gate.load(Ordering::SeqCst) {
Self::park(&self.write_gate, &self.write_entered).await;
}
self.inner.batch(ops).await
}
async fn scan_prefix(
&self,
key: &str,
options: ScanOptions,
) -> crate::Result<Vec<(String, Vec<u8>)>> {
if self.find_gate.load(Ordering::SeqCst) {
Self::park(&self.find_gate, &self.find_entered).await;
}
self.inner.scan_prefix(key, options).await
}
}
#[tokio::test(flavor = "multi_thread")]
async fn cache_admit_not_blocked_by_restore_io() {
let config = Config {
data: ConfigData {
cache_cap: Some(4),
..Default::default()
},
table: Default::default(),
};
let kv = Arc::new(GatedKv::new());
let kv_store: Arc<dyn KvStore> = kv.clone();
let rt = Runtime::new(&config, Some(kv_store)).unwrap();
let cache = rt.cache();
let model = Workflow::new()
.with_id("m1")
.with_step(|step| step.with_id("step1"));
let resident = Process::new("restore-io-resident", &rt);
resident.load(&model).unwrap();
assert!(cache.admit(&resident).await.unwrap());
assert_eq!(cache.count(), 1);
let parked = Process::new_with_timestamp("restore-io-parked", 1, &rt);
parked.load(&model).unwrap();
parked.set_pure_state(TaskState::None);
cache.store().upsert_proc(&parked).await.unwrap();
kv.arm_find();
let restore = {
let cache = cache.clone();
let rt = rt.clone();
tokio::spawn(async move { cache.start_parked(&rt).await })
};
kv.wait_in_find(1).await;
let fresh = Process::new("restore-io-fresh", &rt);
fresh.load(&model).unwrap();
let admitted = tokio::time::timeout(std::time::Duration::from_secs(5), cache.admit(&fresh))
.await
.expect("admit must not block behind a restore's store I/O")
.unwrap();
assert!(admitted);
kv.disarm_find();
restore.await.unwrap().unwrap();
assert_eq!(cache.count(), 3, "resident + fresh + refilled parked");
let resident: Vec<String> = cache.procs().iter().map(|p| p.id().to_string()).collect();
assert!(resident.contains(&"restore-io-parked".to_string()));
rt.close().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn cache_concurrent_restore_passes_do_not_block_or_double_load() {
let config = Config {
data: ConfigData {
cache_cap: Some(2),
..Default::default()
},
table: Default::default(),
};
let kv = Arc::new(GatedKv::new());
let kv_store: Arc<dyn KvStore> = kv.clone();
let rt = Runtime::new(&config, Some(kv_store)).unwrap();
let cache = rt.cache();
let model = Workflow::new()
.with_id("m1")
.with_step(|step| step.with_id("step1"));
for (tag, ts) in [("a", 1), ("b", 2)] {
let pid = format!("restore-concurrent-{tag}");
let proc = Process::new_with_timestamp(&pid, ts, &rt);
proc.load(&model).unwrap();
proc.set_pure_state(TaskState::None);
cache.store().upsert_proc(&proc).await.unwrap();
}
kv.arm_find();
let first = {
let cache = cache.clone();
let rt = rt.clone();
tokio::spawn(async move { cache.start_parked(&rt).await })
};
kv.wait_in_find(1).await;
tokio::time::timeout(std::time::Duration::from_secs(5), cache.start_parked(&rt))
.await
.expect("a restore pass must not park on another pass's store I/O")
.unwrap();
kv.disarm_find();
first.await.unwrap().unwrap();
assert_eq!(cache.count(), 2, "each parked row started exactly once");
let resident: Vec<String> = cache.procs().iter().map(|p| p.id().to_string()).collect();
assert!(resident.contains(&"restore-concurrent-a".to_string()));
assert!(resident.contains(&"restore-concurrent-b".to_string()));
rt.close().await;
}
#[tokio::test(flavor = "multi_thread")]
async fn cache_proc_miss_overlapping_load_reuses_instance() {
let kv = Arc::new(GatedKv::new());
let kv_store: Arc<dyn KvStore> = kv.clone();
let rt = Runtime::new(&Config::default(), Some(kv_store)).unwrap();
let cache = rt.cache();
let model = Workflow::new()
.with_id("m1")
.with_step(|step| step.with_id("step1"));
cache.store().deploy(&model, None).await.unwrap();
let pid = utils::longid();
let row = data::Proc {
id: pid.clone(),
name: "test".to_string(),
mid: "m1".to_string(),
state: TaskState::Running.to_string(),
start_time: 0,
end_time: 0,
timestamp: 0,
model: model.to_json().unwrap(),
env: "{}".to_string(),
err: None,
removable: false,
v: data::Proc::version(),
};
cache.store().procs().create(&row).await.unwrap();
kv.arm_find();
kv.arm_write();
let leader = {
let cache = cache.clone();
let rt = rt.clone();
let pid = pid.clone();
tokio::spawn(async move { cache.proc(&pid, &rt).await.unwrap().unwrap() })
};
kv.wait_in_find(1).await;
let action = Action::new(&pid, "op-tid", EventAction::Next, Vars::new());
cache.enqueue_action(&action).await.unwrap();
kv.wait_in_find(2).await;
let waiter = {
let cache = cache.clone();
let rt = rt.clone();
let pid = pid.clone();
tokio::spawn(async move { cache.proc(&pid, &rt).await.unwrap().unwrap() })
};
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
assert_eq!(cache.count(), 0, "the leader is still parked in its load");
kv.disarm_find();
tokio::time::timeout(std::time::Duration::from_secs(5), kv.wait_in_write(1))
.await
.expect("the queued write must park on the write gate");
let first = leader.await.unwrap();
assert_eq!(cache.count(), 1, "the leader caches the loaded process");
kv.disarm_write();
let second = tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
.await
.expect("the second caller must not hang in flush")
.unwrap();
assert!(
Arc::ptr_eq(&first, &second),
"a caller that missed before the load landed must reuse its instance"
);
assert_eq!(cache.count(), 1);
rt.close().await;
}
struct FailKv;
#[async_trait::async_trait]
impl KvStore for FailKv {
async fn one(&self, _key: &str) -> crate::Result<Option<Vec<u8>>> {
Err(crate::ActError::Store("backend unavailable".to_string()))
}
async fn put(&self, _key: &str, _value: Vec<u8>) -> crate::Result<()> {
Err(crate::ActError::Store("backend unavailable".to_string()))
}
async fn delete(&self, _key: &str) -> crate::Result<()> {
Err(crate::ActError::Store("backend unavailable".to_string()))
}
async fn scan_prefix(
&self,
_key: &str,
_options: ScanOptions,
) -> crate::Result<Vec<(String, Vec<u8>)>> {
Err(crate::ActError::Store("backend unavailable".to_string()))
}
}
#[tokio::test]
async fn find_opt_normalizes_only_missing_rows() {
let store = Store::new(Arc::new(MemoryStore::new()));
assert!(store.procs().find_opt("absent").await.unwrap().is_none());
assert!(store.procs().find("absent").await.is_err());
}
#[tokio::test]
async fn cache_store_error_is_not_an_empty_success() {
let kv_store: Arc<dyn KvStore> = Arc::new(FailKv);
let rt = Runtime::new(&Config::default(), Some(kv_store)).unwrap();
let store = rt.cache().store();
let err = store.load_proc("any-pid", &rt).await.unwrap_err();
assert!(matches!(err, crate::ActError::Store(_)), "{err:?}");
let err = store
.set_delivery("any-delivery", data::DeliveryStatus::Acked)
.await
.unwrap_err();
assert!(matches!(err, crate::ActError::Store(_)), "{err:?}");
let err = store.mark_delivered("any-delivery").await.unwrap_err();
assert!(matches!(err, crate::ActError::Store(_)), "{err:?}");
let err = store
.with_no_response_deliveries(1_000, 3)
.await
.unwrap_err();
assert!(matches!(err, crate::ActError::Store(_)), "{err:?}");
rt.close().await;
}