#[path = "run_server_outbox_support/helpers.rs"]
mod helpers;
#[path = "run_server_outbox_support/worker.rs"]
mod worker;
use std::io::Write;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::{Duration, Instant};
const CONCURRENT_SEAM_ENV: &str = "AION_E2E_CONCURRENT_STORE_SEAM";
use aion_awl_package::compile_and_assemble_awl;
use aion_core::Event;
use aion_server::config::CliOverrides;
use aion_store::{OutboxRow, OutboxStatus, ReadableEventStore};
use aion_store_haematite::HaematiteStore;
use chrono::Utc;
use helpers::{
FAN_OUT, NAMESPACE, TestError, assert_fan_out_settled, assert_task_set, count_completed,
count_completed_for, count_kind, fetch_history_over_http, row_states, run_server_harness,
run_server_harness_with_reconciliation, start_over_http, task_ordinal, test_error,
unique_temp_dir, wait_for_history, wait_for_history_over_http, wait_for_rows, worker_result,
write_package_archive,
};
use serde_json::json;
use worker::WorkerSession;
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn run_server_outbox_happy_path_fan_out_completes_once() -> Result<(), TestError> {
let dir = unique_temp_dir("happy")?;
let db_path = dir.path().join("aion.db");
let package_path = write_package_archive(dir.path())?;
let (server, http, _grpc) = run_server_harness(dir.path(), &db_path, &package_path).await?;
let (workflow_id, run_id) = start_over_http(http).await?;
wait_for_history_over_http(http, &workflow_id, "fan-out scheduled", |events| {
count_kind(events, |event| {
matches!(event, Event::ActivityScheduled { .. })
}) == FAN_OUT
})
.await?;
server.stop_gracefully()?;
{
let reader =
HaematiteStore::open_or_create(db_path.clone(), haematite::NodeCacheBudget::Unlimited)
.await?;
let history = reader.read_history(&workflow_id).await?;
assert_eq!(
count_kind(&history, |event| matches!(
event,
Event::ActivityScheduled { .. }
)),
FAN_OUT,
"every fan-out member must be scheduled durably"
);
assert_eq!(
count_completed(&history),
0,
"no member may be completed before any worker has ever registered"
);
let states = row_states(&reader, &workflow_id, &[0, 1, 2, 3]).await?;
assert_eq!(
states.len(),
FAN_OUT,
"every ordinal must have a staged row"
);
assert!(
states
.iter()
.all(|state| state.status != OutboxStatus::Done),
"no row may be Done — a row settles on DISPATCH, and no worker ever \
registered to be dispatched to: {states:?}"
);
}
let (server, http, grpc) = run_server_harness(dir.path(), &db_path, &package_path).await?;
let mut worker = WorkerSession::connect(grpc).await?;
let mut tasks = Vec::with_capacity(FAN_OUT);
for _ in 0..FAN_OUT {
tasks.push(worker.next_task().await?);
}
assert_task_set(&tasks, &[0, 1, 2, 3])?;
for task in &tasks {
let ordinal = task_ordinal(task)?;
worker
.complete(task, worker_result(ordinal).as_bytes())
.await?;
}
wait_for_history_over_http(http, &workflow_id, "fan-out settled", |events| {
count_completed(events) == FAN_OUT
&& count_kind(events, |event| {
matches!(event, Event::WorkflowCompleted { .. })
}) == 1
})
.await?;
drop(worker);
server.stop_gracefully()?;
let reader =
HaematiteStore::open_or_create(db_path, haematite::NodeCacheBudget::Unlimited).await?;
let history = assert_fan_out_settled(&reader, &workflow_id).await?;
assert_eq!(count_completed(&history), FAN_OUT);
std::hint::black_box(run_id);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn run_server_outbox_restart_rearms_stranded_rows() -> Result<(), TestError> {
let dir = unique_temp_dir("restart")?;
let db_path = dir.path().join("aion.db");
let package_path = write_package_archive(dir.path())?;
let (server1, http1, grpc1) = run_server_harness(dir.path(), &db_path, &package_path).await?;
let mut worker1 = WorkerSession::connect(grpc1).await?;
let (workflow_id, run_id) = start_over_http(http1).await?;
let mut tasks = Vec::with_capacity(FAN_OUT);
for _ in 0..FAN_OUT {
tasks.push(worker1.next_task().await?);
}
tasks.sort_by_key(|task| task_ordinal(task).unwrap_or(u64::MAX));
assert_task_set(&tasks, &[0, 1, 2, 3])?;
complete_recorded_prefix(&worker1, &tasks).await?;
wait_for_history_over_http(http1, &workflow_id, "ordinals 0 and 1 recorded", |events| {
count_completed_for(events, 0) == 1 && count_completed_for(events, 1) == 1
})
.await?;
drop(worker1);
server1.stop()?;
{
let reader =
HaematiteStore::open_or_create(db_path.clone(), haematite::NodeCacheBudget::Unlimited)
.await?;
let states = row_states(&reader, &workflow_id, &[0, 1, 2, 3]).await?;
assert!(
states
.iter()
.all(|state| state.status == OutboxStatus::Done),
"every row was dispatched, so every row is Done — a row settles on \
dispatch, not on completion: {states:?}"
);
let stranded = reader.read_history(&workflow_id).await?;
assert_eq!(count_completed_for(&stranded, 0), 1);
assert_eq!(count_completed_for(&stranded, 1), 1);
assert_eq!(
count_completed_for(&stranded, 2),
0,
"ordinal 2's dispatch died in flight and must have recorded nothing"
);
assert_eq!(
count_completed_for(&stranded, 3),
0,
"ordinal 3's dispatch died in flight and must have recorded nothing"
);
}
let (server2, http2, grpc2) = run_server_harness(dir.path(), &db_path, &package_path).await?;
let mut worker2 = WorkerSession::connect(grpc2).await?;
let revived = collect_revived_tasks(&mut worker2).await?;
assert_task_set(&revived, &[2, 3])?;
complete_with_duplicate_first(&worker2, &revived).await?;
wait_for_history_over_http(http2, &workflow_id, "fan-out settled", |events| {
count_completed(events) == FAN_OUT
&& count_kind(events, |event| {
matches!(event, Event::WorkflowCompleted { .. })
}) == 1
})
.await?;
drop(worker2);
server2.stop_gracefully()?;
let reader =
HaematiteStore::open_or_create(db_path, haematite::NodeCacheBudget::Unlimited).await?;
let history = assert_fan_out_settled(&reader, &workflow_id).await?;
assert_eq!(count_completed(&history), FAN_OUT);
std::hint::black_box(run_id);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn run_server_outbox_live_reconciliation_rearms_stranded_claims_once() -> Result<(), TestError>
{
if std::env::var_os(CONCURRENT_SEAM_ENV).is_none() {
let mut stderr = std::io::stderr();
let _announced = writeln!(
stderr,
"SKIP (PROVES NOTHING): live reconciliation of a stale claim — not \
established by this run. Needs a haematite concurrent seam with ROUTED \
reads, or a server-side claim-aging seam (aion#114). No pinnable \
surface exists today — the absent capability IS the absence — so this \
announcement stands unpinned until #114 ships a surface. Set \
{CONCURRENT_SEAM_ENV} once the capability exists."
);
return Ok(());
}
let dir = unique_temp_dir("live-reconcile")?;
let db_path = dir.path().join("aion.db");
let package_path = write_package_archive(dir.path())?;
let (server, http, grpc) = run_server_harness_with_reconciliation(
dir.path(),
&db_path,
&package_path,
Some((50, 100)),
)
.await?;
let reader =
HaematiteStore::open_or_create(db_path.clone(), haematite::NodeCacheBudget::Unlimited)
.await?;
let mut worker = WorkerSession::connect(grpc).await?;
let (workflow_id, run_id) = start_over_http(http).await?;
let mut tasks = Vec::with_capacity(FAN_OUT);
for _ in 0..FAN_OUT {
tasks.push(worker.next_task().await?);
}
tasks.sort_by_key(|task| task_ordinal(task).unwrap_or(u64::MAX));
assert_task_set(&tasks, &[0, 1, 2, 3])?;
wait_for_rows(
&reader,
&workflow_id,
&[0, 1, 2, 3],
"initial rows done before simulated stale claim",
|statuses| statuses.iter().all(|status| *status == OutboxStatus::Done),
)
.await?;
complete_recorded_prefix(&worker, &tasks).await?;
wait_for_history(
&reader,
&workflow_id,
"ordinals 0 and 1 recorded before live reconciliation",
|events| count_completed_for(events, 0) == 1 && count_completed_for(events, 1) == 1,
)
.await?;
force_rows_to_stale_claimed(&db_path, &workflow_id, &[2, 3]).await?;
let revived = collect_revived_tasks(&mut worker).await?;
complete_with_duplicate_first(&worker, &revived).await?;
complete_original_late(&worker, &tasks[2..]).await?;
let history = assert_fan_out_settled(&reader, &workflow_id).await?;
assert_eq!(count_completed(&history), FAN_OUT);
assert_eq!(
count_kind(&history, |event| matches!(
event,
Event::WorkflowCompleted { .. }
)),
1
);
std::hint::black_box(run_id);
server.stop()?;
Ok(())
}
async fn complete_recorded_prefix(
worker: &WorkerSession,
tasks: &[aion_proto::generated::ActivityTask],
) -> Result<(), TestError> {
for task in tasks.iter().take(2) {
let ordinal = task_ordinal(task)?;
worker
.complete(task, worker_result(ordinal).as_bytes())
.await?;
}
Ok(())
}
async fn collect_revived_tasks(
worker: &mut WorkerSession,
) -> Result<Vec<aion_proto::generated::ActivityTask>, TestError> {
let mut revived = Vec::with_capacity(2);
for _ in 0..2 {
revived.push(worker.next_task().await?);
}
revived.sort_by_key(|task| task_ordinal(task).unwrap_or(u64::MAX));
assert_task_set(&revived, &[2, 3])?;
Ok(revived)
}
async fn complete_with_duplicate_first(
worker: &WorkerSession,
revived: &[aion_proto::generated::ActivityTask],
) -> Result<(), TestError> {
let first = revived
.first()
.ok_or_else(|| test_error("missing first revived task"))?;
let first_ordinal = task_ordinal(first)?;
worker
.complete(first, worker_result(first_ordinal).as_bytes())
.await?;
worker
.complete(first, worker_result(first_ordinal).as_bytes())
.await?;
let second = revived
.get(1)
.ok_or_else(|| test_error("missing second revived task"))?;
let second_ordinal = task_ordinal(second)?;
worker
.complete(second, worker_result(second_ordinal).as_bytes())
.await?;
Ok(())
}
async fn complete_original_late(
worker: &WorkerSession,
tasks: &[aion_proto::generated::ActivityTask],
) -> Result<(), TestError> {
for task in tasks {
let ordinal = task_ordinal(task)?;
worker
.complete(task, worker_result(ordinal).as_bytes())
.await?;
}
Ok(())
}
async fn force_rows_to_stale_claimed(
data_dir: &std::path::Path,
workflow_id: &aion_core::WorkflowId,
ordinals: &[u64],
) -> Result<(), TestError> {
let store = HaematiteStore::open(data_dir, haematite::NodeCacheBudget::Unlimited, |_, _| {})?;
let claimed_at = Utc::now() - chrono::Duration::seconds(60);
for ordinal in ordinals {
let dispatch_key = OutboxRow::dispatch_key_for(workflow_id, *ordinal);
store
.set_outbox_claimed_at(&dispatch_key, claimed_at)
.await?;
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn run_server_child_claims_the_harness_home_not_the_operators() -> Result<(), TestError> {
let dir = unique_temp_dir("home")?;
let db_path = dir.path().join("aion.db");
let package_path = write_package_archive(dir.path())?;
let (server, _http, _grpc) = run_server_harness(dir.path(), &db_path, &package_path).await?;
let home = server.home().to_path_buf();
assert_eq!(
home,
dir.path(),
"the harness directory is the child's home"
);
let record = aion_server::control::pid_file::read(&home)?.ok_or_else(|| {
test_error(format!(
"no pid record under the harness home {} — the child claimed some other home",
aion_server::control::pid_file::pid_file_path(&home).display()
))
})?;
assert_eq!(
record.pid,
server.pid(),
"the pid record under the harness home must name this child"
);
let note_path = aion_server::death_note::note_path(&home);
let armed = std::fs::read_to_string(¬e_path).map_err(|error| {
test_error(format!(
"no death note under the harness home at {}: {error}",
note_path.display()
))
})?;
assert!(
armed.contains(" ARMED "),
"the death note under the harness home must record this child's arming: {armed}"
);
server.stop_gracefully()?;
let disarmed = std::fs::read_to_string(¬e_path)?;
assert!(
disarmed.contains(" DISARMED "),
"after a graceful stop the harness-home death note must record the disarm: {disarmed}"
);
assert!(
aion_server::control::pid_file::read(&home)?.is_none(),
"a cleanly stopped child must release the pid file it claimed under the harness home"
);
Ok(())
}
const FANOUT_DECLARED_WORKFLOW_TYPE: &str = "fanout_declared";
const FANOUT_DECLARED_DEADLINE: Duration = Duration::from_secs(120);
const FANOUT_DECLARED_DOCUMENT: &str = r#"//! aion#193 pin: a fork over declared bodies, no worker.
workflow fanout_declared
input shas: [String]
outcome sized: type [RunOutcome], route success
type RunOutcome { exit_code: Int, stdout: String, stderr: String }
worker json_box
action tally(shas: [String]) -> RunOutcome
run "printf %s counted"
action size_of(sha: String) -> RunOutcome
run "printf sized:%s {{sha}}"
step gather
tally(shas: shas) -> counted
step measure
fork sha in shas
size_of(sha: sha)
join -> sizes
route sized(sizes)
"#;
async fn start_fanout(
address: std::net::SocketAddr,
shas: &[&str],
) -> Result<(aion_core::WorkflowId, aion_core::RunId), TestError> {
let client = reqwest::Client::new();
let response = client
.post(format!("http://{address}/workflows/start"))
.header("content-type", "application/json")
.header("x-aion-subject", "ci")
.header("x-aion-namespaces", NAMESPACE)
.json(&json!({
"namespace": NAMESPACE,
"workflow_type": FANOUT_DECLARED_WORKFLOW_TYPE,
"input": { "shas": shas },
}))
.send()
.await?;
let status = response.status();
let bytes = response.bytes().await?;
if !status.is_success() {
return Err(test_error(format!(
"start must succeed, got {status}: {}",
String::from_utf8_lossy(&bytes)
)));
}
let body: serde_json::Value = serde_json::from_slice(&bytes)?;
let workflow_id = body["workflow_id"]
.as_str()
.ok_or_else(|| test_error("start reply has no workflow id"))?
.parse::<uuid::Uuid>()?;
let run_id = body["run_id"]
.as_str()
.ok_or_else(|| test_error("start reply has no run id"))?
.parse::<uuid::Uuid>()?;
Ok((
aion_core::WorkflowId::new(workflow_id),
aion_core::RunId::new(run_id),
))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_fork_over_declared_bodies_completes_with_no_worker_on_the_outbox_path()
-> Result<(), TestError> {
let dir = unique_temp_dir("declared-fanout")?;
let db_path = dir.path().join("aion.db");
let prepared =
compile_and_assemble_awl(FANOUT_DECLARED_DOCUMENT, dir.path(), "fanout_declared.awl")?;
let package_path = dir.path().join("fanout_declared.aion");
std::fs::write(&package_path, prepared.archive)?;
let (server, http, _grpc) = run_server_harness(dir.path(), &db_path, &package_path).await?;
let (workflow_id, _run_id) = start_fanout(http, &["aaa111", "bbb222"]).await?;
let deadline = Instant::now() + FANOUT_DECLARED_DEADLINE;
let history = loop {
let history = fetch_history_over_http(http, &workflow_id).await?;
if history.iter().any(|event| {
matches!(
event,
Event::WorkflowCompleted { .. } | Event::WorkflowFailed { .. }
)
}) {
break history;
}
if Instant::now() > deadline {
return Err(test_error(format!(
"the fork over declared bodies reached no terminal state in {FANOUT_DECLARED_DEADLINE:?} — \
the members parked (NO_LIVE_POLLERS) or are still retrying: {history:#?}"
)));
}
tokio::time::sleep(Duration::from_millis(50)).await;
};
let result = history
.iter()
.find_map(|event| match event {
Event::WorkflowCompleted { result, .. } => Some(result.clone()),
_ => None,
})
.ok_or_else(|| {
test_error(format!(
"the run ended without completing — the fork members dead-lettered: {history:#?}"
))
})?;
let decoded: serde_json::Value = serde_json::from_slice(result.bytes())?;
assert_eq!(
decoded["outcome"], "sized",
"the run must route its declared outcome: {decoded}"
);
let sizes = decoded["payload"]
.as_array()
.ok_or_else(|| test_error(format!("the outcome payload is not a list: {decoded}")))?;
let stdouts: Vec<&str> = sizes
.iter()
.map(|size| size["stdout"].as_str().unwrap_or("<not a string>"))
.collect();
assert_eq!(
stdouts,
vec!["sized:aaa111", "sized:bbb222"],
"every member's body ran with its own element bound, joined in input order"
);
assert!(sizes.iter().all(|size| size["exit_code"] == 0));
assert_eq!(
count_completed(&history),
3,
"the control statement and both fork members completed: {history:#?}"
);
server.stop_gracefully()?;
let reader =
HaematiteStore::open_or_create(db_path, haematite::NodeCacheBudget::Unlimited).await?;
let states = row_states(&reader, &workflow_id, &[1, 2]).await?;
assert!(
states
.iter()
.all(|state| state.status == OutboxStatus::Done),
"every fork member's row must be Done after its body completed: {states:?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn run_server_child_process() -> Result<(), TestError> {
if std::env::var_os("AION_RUN_SERVER_CHILD").is_none() {
return Ok(());
}
let config_path = std::env::var_os("AION_RUN_SERVER_CONFIG")
.map(PathBuf::from)
.ok_or_else(|| test_error("AION_RUN_SERVER_CONFIG is required"))?;
let code = aion_server::run::run(CliOverrides {
config_path: Some(config_path),
..CliOverrides::default()
})
.await;
if code == ExitCode::SUCCESS {
Ok(())
} else {
Err(test_error(format!("run_server exited with {code:?}")))
}
}