mod support;
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, Request};
use keel_journal::{Clock, Journal, ManualClock, PostgresJournal, ProcessId};
use keelrun_core::{Engine, FlowConfig, FlowDescriptor, FlowManager};
use serde::Deserialize;
use serde_json::Value;
use support::ScratchPg;
const T0: i64 = 1_783_728_000_000;
const CRASH_WAIT: Duration = Duration::from_millis(500);
const LEASE_TTL: Duration = Duration::from_millis(150);
#[derive(Debug, Deserialize)]
struct FlowScenario {
name: String,
policy: Value,
flow: FlowSpec,
runs: Vec<RunSpec>,
}
#[derive(Debug, Deserialize)]
struct FlowSpec {
entrypoint: String,
args_hash: String,
#[serde(default)]
code_hash: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RunSpec {
#[serde(default)]
end: RunEnd,
#[serde(default)]
expect_effect_calls: Option<usize>,
steps: Vec<StepSpec>,
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
#[serde(rename_all = "snake_case")]
enum RunEnd {
#[default]
Success,
Failed,
Crash,
}
#[derive(Debug, Deserialize)]
struct StepSpec {
target: String,
#[serde(default)]
args_hash: Option<String>,
#[serde(default)]
idempotency_key: Option<String>,
#[serde(default)]
expect_recorded_key: Option<Value>,
effect: AttemptResult,
#[serde(default)]
expect: Value,
}
fn needs_extended_shape(scenario: &Value) -> bool {
const RUN_KEYS: [&str; 8] = [
"holder",
"advance_before_ms",
"policy",
"code_hash",
"hold",
"expect_enter_error",
"inject_running",
"expect_journal",
];
scenario["runs"].as_array().is_some_and(|runs| {
runs.iter().any(|run| {
!run.get("steps").is_some_and(Value::is_array)
|| RUN_KEYS.iter().any(|k| run.get(*k).is_some())
|| run["steps"]
.as_array()
.is_some_and(|steps| steps.iter().any(|s| s.get("kind").is_some()))
})
})
}
async fn run_flow_scenario(scn: &FlowScenario, journal: Arc<dyn Journal>) -> Vec<String> {
let clock = ManualClock::new(T0);
let engine = 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 manager = FlowManager::with_config(
Arc::new(engine),
Arc::clone(&journal),
clock_dyn,
ProcessId::new("host-conformance-pg:pid-1"),
FlowConfig {
lease_ttl: LEASE_TTL,
max_attempts: 3,
},
);
let desc = FlowDescriptor {
entrypoint: scn.flow.entrypoint.clone(),
args_hash: scn.flow.args_hash.clone(),
explicit_key: None,
code_hash: scn.flow.code_hash.clone(),
};
let mut failures = Vec::new();
for (ri, run) in scn.runs.iter().enumerate() {
let mut handle = match manager.enter_flow(&desc) {
Ok(handle) => handle,
Err(e) => {
failures.push(format!("run[{ri}]: enter failed: {e}"));
return failures;
}
};
let calls = Arc::new(AtomicUsize::new(0));
for (si, step) in run.steps.iter().enumerate() {
if let Some(expected) = &step.expect_recorded_key {
let step_key = format!(
"{}#{}",
step.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 request = Request {
v: ENVELOPE_VERSION,
target: step.target.clone(),
op: step.target.clone(),
idempotent: true,
args_hash: step.args_hash.clone(),
};
let effect = step.effect.clone();
let calls_effect = Arc::clone(&calls);
let outcome = handle
.execute_step_with_idempotency_key(
&request,
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}")),
);
}
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}"
));
}
}
match run.end {
RunEnd::Success => handle.complete_success(),
RunEnd::Failed => handle.complete_failed(),
RunEnd::Crash => {
drop(handle);
tokio::time::sleep(CRASH_WAIT).await;
}
}
}
failures
}
#[tokio::test]
async fn tier2_flow_conformance_over_postgres() {
let Some(pg) = ScratchPg::start() else {
eprintln!("skipping: no local `initdb` found (see tests/support)");
return;
};
let journal: Arc<dyn Journal> =
Arc::new(PostgresJournal::open(&pg.url()).expect("open postgres journal"));
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; }
if needs_extended_shape(&value) {
println!(
"skip {} (needs the extended SQLite-leg shape)",
path.display()
);
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, Arc::clone(&journal)).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 >= 2,
"expected the tier-2 flow scenarios to run, ran {ran}"
);
assert!(failed.is_empty(), "tier-2 scenarios failed: {failed:?}");
}