#[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;
const CONCURRENT_SEAM_ENV: &str = "AION_E2E_CONCURRENT_STORE_SEAM";
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, TestError, assert_fan_out_settled, assert_task_set, count_completed,
count_completed_for, count_kind, 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 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_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:?}")))
}
}