use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use keel_conformance::{scenarios_dir, subset_mismatches};
use keel_core_api::{AttemptResult, ENVELOPE_VERSION, KeelError, Request};
use keel_journal::{
Clock, FlowId, Journal, ManualClock, ProcessId, SqliteJournal, StepKey, StepKind, StepOutcome,
StepStatus,
};
use keelrun_core::{Engine, FlowConfig, FlowDescriptor, FlowHandle, FlowManager};
use serde::Deserialize;
use serde_json::Value;
use tempfile::TempDir;
const T0: i64 = 1_783_728_000_000;
const PAST_LEASE_TTL_MS: i64 = 31_000;
#[derive(Debug, Deserialize)]
struct FlowScenario {
name: String,
policy: Value,
#[serde(default)]
max_attempts: Option<u32>,
flow: FlowSpec,
runs: Vec<RunSpec>,
}
#[derive(Debug, Deserialize)]
struct FlowSpec {
entrypoint: String,
args_hash: String,
#[serde(default)]
code_hash: Option<String>,
}
fn default_holder() -> String {
String::from("host-a:pid-1")
}
#[derive(Debug, Deserialize)]
struct RunSpec {
#[serde(default = "default_holder")]
holder: String,
#[serde(default)]
advance_before_ms: i64,
#[serde(default)]
policy: Option<Value>,
#[serde(default)]
code_hash: Option<String>,
#[serde(default)]
hold: bool,
#[serde(default)]
expect_enter_error: Option<Value>,
#[serde(default)]
end: RunEnd,
#[serde(default)]
expect_effect_calls: Option<usize>,
#[serde(default)]
steps: Vec<StepSpec>,
#[serde(default)]
inject_running: Option<InjectRunning>,
#[serde(default)]
expect_journal: Vec<JournalAssertion>,
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
enum RunEnd {
#[default]
Success,
Failed,
Crash,
}
#[derive(Debug, Deserialize)]
struct InjectRunning {
seq: u64,
target: String,
#[serde(default)]
args_hash: Option<String>,
}
#[derive(Debug, Deserialize)]
struct JournalAssertion {
seq: u64,
key: String,
#[serde(default)]
status: Option<String>,
#[serde(default)]
kind: Option<String>,
}
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum StepSpecKind {
#[default]
Effect,
Time,
Random,
}
#[derive(Debug, Deserialize)]
struct StepSpec {
#[serde(default)]
kind: StepSpecKind,
#[serde(default)]
target: Option<String>,
#[serde(default)]
args_hash: Option<String>,
#[serde(default)]
idempotency_key: Option<String>,
#[serde(default)]
expect_recorded_key: Option<Value>,
#[serde(default)]
key: Option<String>,
#[serde(default)]
effect: Option<AttemptResult>,
#[serde(default)]
live_value: Option<Value>,
#[serde(default)]
expect: Value,
#[serde(default)]
expect_value: Option<Value>,
}
struct ScenarioRig {
engine: Arc<Engine>,
journal: Arc<dyn Journal>,
clock: ManualClock,
clock_dyn: Arc<dyn Clock>,
config: FlowConfig,
managers: HashMap<String, FlowManager>,
held: HashMap<String, FlowHandle>,
}
impl ScenarioRig {
fn manager(&mut self, holder: &str) -> &FlowManager {
self.managers.entry(holder.to_owned()).or_insert_with(|| {
FlowManager::with_config(
Arc::clone(&self.engine),
Arc::clone(&self.journal),
Arc::clone(&self.clock_dyn),
ProcessId::new(holder),
self.config,
)
})
}
}
fn request(target: &str, args_hash: Option<&str>) -> Request {
Request {
v: ENVELOPE_VERSION,
target: target.to_owned(),
op: target.to_owned(),
idempotent: true,
args_hash: args_hash.map(str::to_owned),
}
}
async fn run_effect_step(
handle: &mut FlowHandle,
step: &StepSpec,
calls: &Arc<AtomicUsize>,
failures: &mut Vec<String>,
ri: usize,
si: usize,
) {
let target = step
.target
.as_deref()
.unwrap_or_else(|| panic!("run[{ri}] step[{si}]: an effect step needs `target`"));
if let Some(expected) = &step.expect_recorded_key {
let step_key = format!("{target}#{}", step.args_hash.as_deref().unwrap_or("-"));
let actual = handle
.recorded_idempotency_key(&step_key)
.map_or(Value::Null, Value::String);
if &actual != expected {
failures.push(format!(
"run[{ri}] step[{si}] recorded_idempotency_key({step_key:?}): expected {expected}, got {actual}"
));
}
}
let effect = step
.effect
.clone()
.unwrap_or_else(|| panic!("run[{ri}] step[{si}]: an effect step needs `effect`"));
let calls_effect = Arc::clone(calls);
let outcome = handle
.execute_step_with_idempotency_key(
&request(target, step.args_hash.as_deref()),
step.idempotency_key.as_deref(),
move |_attempt: u32| {
let effect = effect.clone();
let calls_effect = Arc::clone(&calls_effect);
async move {
calls_effect.fetch_add(1, Ordering::SeqCst);
effect
}
},
)
.await;
let actual = serde_json::to_value(&outcome).expect("outcome serializes");
let mut mismatches = Vec::new();
subset_mismatches(&actual, &step.expect, "$", &mut mismatches);
failures.extend(
mismatches
.into_iter()
.map(|m| format!("run[{ri}] step[{si}] outcome: {m}")),
);
}
fn run_value_step(
handle: &mut FlowHandle,
step: &StepSpec,
failures: &mut Vec<String>,
ri: usize,
si: usize,
) {
let key = step
.key
.as_deref()
.unwrap_or_else(|| panic!("run[{ri}] step[{si}]: a value step needs `key`"));
let result = if step.kind == StepSpecKind::Time {
let now_ms = step
.live_value
.as_ref()
.and_then(Value::as_i64)
.unwrap_or_else(|| panic!("run[{ri}] step[{si}]: a time step needs int `live_value`"));
handle
.journal_time(key, now_ms)
.map(|v| serde_json::to_value(v).expect("i64 serializes"))
} else {
let bytes: Vec<u8> = step
.live_value
.as_ref()
.and_then(Value::as_array)
.unwrap_or_else(|| {
panic!("run[{ri}] step[{si}]: a random step needs array `live_value`")
})
.iter()
.map(|b| {
u8::try_from(b.as_u64().expect("byte value is a u64")).expect("byte value fits u8")
})
.collect();
handle
.journal_random(key, bytes)
.map(|v| serde_json::to_value(v).expect("bytes serialize"))
};
match result {
Ok(actual) => {
if let Some(expected) = &step.expect_value {
let mut mismatches = Vec::new();
subset_mismatches(&actual, expected, "$", &mut mismatches);
failures.extend(
mismatches
.into_iter()
.map(|m| format!("run[{ri}] step[{si}] value: {m}")),
);
}
}
Err(e) => failures.push(format!("run[{ri}] step[{si}]: value step failed: {e}")),
}
}
fn inject_running_step(rig: &ScenarioRig, fid: &FlowId, inj: &InjectRunning) {
let key = StepKey::new(format!(
"{}#{}",
inj.target,
inj.args_hash.as_deref().unwrap_or("-")
));
let now = rig.clock.now_ms();
let outcome = StepOutcome {
kind: StepKind::Effect,
attempt: 0,
status: StepStatus::Running,
payload: None,
error_class: None,
started_at: now,
ended_at: None,
};
rig.journal
.record_step(fid, inj.seq, &key, &outcome)
.expect("inject_running record_step succeeds");
}
fn check_journal_assertion(
rig: &ScenarioRig,
fid: &FlowId,
assertion: &JournalAssertion,
ri: usize,
failures: &mut Vec<String>,
) {
let seq = assertion.seq;
match rig.journal.step_at(fid, seq) {
Ok(Some((key, outcome))) => {
if key.as_str() != assertion.key {
failures.push(format!(
"run[{ri}] expect_journal seq {seq}: key {key} != {}",
assertion.key
));
}
if let Some(status) = &assertion.status
&& outcome.status.as_str() != status
{
failures.push(format!(
"run[{ri}] expect_journal seq {seq}: status {} != {status}",
outcome.status.as_str()
));
}
if let Some(kind) = &assertion.kind
&& outcome.kind.as_str() != kind
{
failures.push(format!(
"run[{ri}] expect_journal seq {seq}: kind {} != {kind}",
outcome.kind.as_str()
));
}
}
Ok(None) => failures.push(format!(
"run[{ri}] expect_journal seq {seq}: no record present"
)),
Err(e) => failures.push(format!(
"run[{ri}] expect_journal seq {seq}: journal read failed: {e}"
)),
}
}
fn handle_expected_enter_error(
expected: &Value,
enter_result: Result<FlowHandle, KeelError>,
ri: usize,
failures: &mut Vec<String>,
) {
match enter_result {
Err(e) => {
let actual = serde_json::to_value(&e).expect("KeelError serializes");
let mut mismatches = Vec::new();
subset_mismatches(&actual, expected, "$", &mut mismatches);
failures.extend(
mismatches
.into_iter()
.map(|m| format!("run[{ri}] enter error: {m}")),
);
}
Ok(_) => failures.push(format!(
"run[{ri}]: expected enter_flow to fail, but it succeeded"
)),
}
}
async fn run_flow_scenario(scn: &FlowScenario) -> Vec<String> {
let dir = TempDir::new().unwrap();
let clock = ManualClock::new(T0);
let journal: Arc<dyn Journal> =
Arc::new(SqliteJournal::open(dir.path().join("journal.db"), clock.clone()).unwrap());
let engine = Arc::new(Engine::new());
if let Err(e) = engine.configure(&scn.policy) {
return vec![format!("configure: unexpected error {e}")];
}
let clock_dyn: Arc<dyn Clock> = Arc::new(clock.clone());
let mut rig = ScenarioRig {
engine,
journal,
clock,
clock_dyn,
config: FlowConfig {
lease_ttl: Duration::from_secs(30),
max_attempts: scn.max_attempts.unwrap_or(3),
},
managers: HashMap::new(),
held: HashMap::new(),
};
let mut failures = Vec::new();
for (ri, run) in scn.runs.iter().enumerate() {
if !run_one(&mut rig, scn, ri, run, &mut failures).await {
break;
}
}
failures
}
async fn run_one(
rig: &mut ScenarioRig,
scn: &FlowScenario,
ri: usize,
run: &RunSpec,
failures: &mut Vec<String>,
) -> bool {
if run.advance_before_ms != 0 {
rig.clock.advance(run.advance_before_ms);
}
if let Some(policy) = &run.policy
&& let Err(e) = rig.engine.configure(policy)
{
failures.push(format!("run[{ri}]: policy reconfigure failed: {e}"));
return false;
}
let desc = FlowDescriptor {
entrypoint: scn.flow.entrypoint.clone(),
args_hash: scn.flow.args_hash.clone(),
explicit_key: None,
code_hash: run.code_hash.clone().or_else(|| scn.flow.code_hash.clone()),
};
let enter_result = rig.manager(&run.holder).enter_flow(&desc);
if let Some(expected) = &run.expect_enter_error {
handle_expected_enter_error(expected, enter_result, ri, failures);
return true;
}
let mut handle = match enter_result {
Ok(handle) => handle,
Err(e) => {
failures.push(format!("run[{ri}]: enter failed: {e}"));
return false;
}
};
let fid = handle.flow_id().clone();
let calls = Arc::new(AtomicUsize::new(0));
for (si, step) in run.steps.iter().enumerate() {
match step.kind {
StepSpecKind::Effect => {
run_effect_step(&mut handle, step, &calls, failures, ri, si).await;
}
StepSpecKind::Time | StepSpecKind::Random => {
run_value_step(&mut handle, step, failures, ri, si);
}
}
}
if let Some(expected) = run.expect_effect_calls {
let got = calls.load(Ordering::SeqCst);
if got != expected {
failures.push(format!(
"run[{ri}]: expected {expected} live effect call(s), got {got}"
));
}
}
if let Some(inj) = &run.inject_running {
inject_running_step(rig, &fid, inj);
}
for assertion in &run.expect_journal {
check_journal_assertion(rig, &fid, assertion, ri, failures);
}
if run.hold {
rig.held.insert(run.holder.clone(), handle);
} else {
match run.end {
RunEnd::Success => handle.complete_success().unwrap(),
RunEnd::Failed => handle.complete_failed().unwrap(),
RunEnd::Crash => {
drop(handle);
rig.clock.advance(PAST_LEASE_TTL_MS);
}
}
}
true
}
#[tokio::test(start_paused = true)]
async fn tier2_flow_conformance() {
let dir = scenarios_dir(env!("CARGO_MANIFEST_DIR"));
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display()))
.map(|entry| entry.expect("dir entry").path())
.filter(|p| p.extension().is_some_and(|ext| ext == "json"))
.collect();
paths.sort();
let mut ran = 0;
let mut failed = Vec::new();
for path in paths {
let text = std::fs::read_to_string(&path).unwrap();
let value: Value = serde_json::from_str(&text).expect("scenario is valid JSON");
if value.get("tier").and_then(Value::as_u64) != Some(2) {
continue; }
let scenario: FlowScenario = serde_json::from_str(&text)
.unwrap_or_else(|e| panic!("bad tier-2 scenario {}: {e}", path.display()));
let mismatches = run_flow_scenario(&scenario).await;
if mismatches.is_empty() {
println!("ok {}", scenario.name);
ran += 1;
} else {
println!("FAIL {}", scenario.name);
for m in &mismatches {
println!(" {m}");
}
failed.push(scenario.name);
}
}
assert!(
ran >= 13,
"expected the tier-2 flow scenarios to run, ran {ran}"
);
assert!(failed.is_empty(), "tier-2 scenarios failed: {failed:?}");
}