use std::sync::Arc;
use aion_core::{Event, PackageVersion, Payload, RunId, WorkflowId};
use aion_store::{EventStore, InMemoryStore, ReadableEventStore};
use chrono::Utc;
use super::{ContinuationOutcome, outcome_must_end_the_process, record_continuation};
use crate::durability::{DurabilityError, Recorder, WorkflowStartRecord};
use crate::runtime::engine_tasks::EngineTaskRuntime;
use crate::runtime::nif_context::NifContextError;
use crate::store_faults::FlakyStore;
type TestResult = Result<(), Box<dyn std::error::Error>>;
async fn seed_running_run(
store: Arc<dyn EventStore>,
) -> Result<(Recorder, RunId), Box<dyn std::error::Error>> {
let workflow_id = WorkflowId::new_v4();
let run_id = RunId::new_v4();
let deadline_id = crate::time::deadline_timer_id(&run_id)?;
let mut recorder = Recorder::new(workflow_id, store);
recorder
.record_workflow_started(
Utc::now(),
WorkflowStartRecord {
workflow_type: "continuer".to_owned(),
input: Payload::from_json(&serde_json::json!({}))?,
run_id: run_id.clone(),
parent_run_id: None,
parent_workflow_id: None,
package_version: PackageVersion::new("a".repeat(64)),
},
)
.await?;
recorder
.record_timer_started(Utc::now(), deadline_id, Utc::now())
.await?;
Ok((recorder, run_id))
}
fn continued(history: &[Event]) -> bool {
history
.iter()
.any(|event| matches!(event, Event::WorkflowContinuedAsNew { .. }))
}
fn started(history: &[Event]) -> bool {
history
.iter()
.any(|event| matches!(event, Event::WorkflowStarted { .. }))
}
fn retired_a_timer(history: &[Event]) -> bool {
history
.iter()
.any(|event| matches!(event, Event::TimerCancelled { .. }))
}
#[tokio::test(flavor = "multi_thread")]
async fn a_closed_epoch_refuses_the_continue_as_new_terminal() -> TestResult {
let store = Arc::new(InMemoryStore::default());
let tasks = EngineTaskRuntime::new()?;
let (mut control_recorder, control_run) =
seed_running_run(Arc::clone(&store) as Arc<dyn EventStore>).await?;
let control_id = control_recorder.workflow_id().clone();
let control_outcome = record_continuation(
&mut control_recorder,
&tasks,
&control_run,
Payload::from_json(&serde_json::json!({"round": 2}))?,
)
.await?;
assert!(
matches!(control_outcome, ContinuationOutcome::Complete),
"control: an unfaulted transition must report BOTH halves done — a `TerminalOnly` here \
would mean the deadline retirement silently failed and every assertion below about \
what a completed transition looks like is measuring the wrong thing"
);
let control_history = store.read_history(&control_id).await?;
assert!(
continued(&control_history),
"control: an open epoch must record WorkflowContinuedAsNew, or the refusal below \
proves nothing: {control_history:#?}"
);
assert!(
retired_a_timer(&control_history),
"control: the transition also retires the predecessor's deadline — without this the \
treatment could be measuring a half-built transition: {control_history:#?}"
);
let (mut recorder, run_id) =
seed_running_run(Arc::clone(&store) as Arc<dyn EventStore>).await?;
let workflow_id = recorder.workflow_id().clone();
let before = store.read_history(&workflow_id).await?.len();
tasks.begin_close();
let refusal = record_continuation(
&mut recorder,
&tasks,
&run_id,
Payload::from_json(&serde_json::json!({"round": 2}))?,
)
.await;
let Err(error) = refusal else {
return Err("a closed epoch must refuse the continue-as-new terminal".into());
};
assert!(
matches!(error, DurabilityError::EngineTaskEpochClosed { .. }),
"the refusal must name the epoch, not be laundered through a generic durability \
fault — an operator reading `history shape error` would go looking for corrupt \
history: {error:?}"
);
let history = store.read_history(&workflow_id).await?;
assert!(
!continued(&history),
"a closed epoch must NOT record WorkflowContinuedAsNew: the replacement run it obliges \
is already refused, so the terminal would end this run with no continuation: \
{history:#?}"
);
assert_eq!(
history.len(),
before,
"the refusal must append NOTHING — the gate sits before the first durable write, not \
between the terminal and the deadline retirement"
);
assert!(
started(&history),
"the run must still be live in history after the refusal — that is the whole point of \
refusing, and an empty history would pass the two assertions above vacuously: \
{history:#?}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn an_already_terminal_run_reports_its_own_cause_not_the_epoch() -> TestResult {
let store = Arc::new(InMemoryStore::default());
let tasks = EngineTaskRuntime::new()?;
let (mut recorder, run_id) =
seed_running_run(Arc::clone(&store) as Arc<dyn EventStore>).await?;
recorder
.record_workflow_completed(Utc::now(), Payload::from_json(&serde_json::json!("done"))?)
.await?;
tasks.begin_close();
let refusal = record_continuation(
&mut recorder,
&tasks,
&run_id,
Payload::from_json(&serde_json::json!({}))?,
)
.await;
let Err(error) = refusal else {
return Err("a run that already recorded a terminal cannot continue".into());
};
assert!(
matches!(error, DurabilityError::HistoryShape { .. }),
"an already-terminal run must report THAT, even with the epoch closed — reporting the \
epoch would send an operator to look at engine shutdown for a run that simply \
finished: {error:?}"
);
Ok(())
}
#[test]
fn the_process_ends_whenever_the_terminal_landed_and_for_one_refusal_besides() {
assert!(
outcome_must_end_the_process(&Ok(ContinuationOutcome::Complete)),
"a completed transition must end the process: the run is terminal and its successor is \
started only by the process-exit monitor, so a process that does not exit is a chain \
that never continues"
);
assert!(
outcome_must_end_the_process(&Ok(ContinuationOutcome::TerminalOnly(
DurabilityError::HistoryShape {
reason: "the deadline retirement failed after the terminal landed".to_owned(),
}
))),
"a HALF-completed transition must end the process too — the terminal is durable, so the \
run is over whatever happened next, and this is the exact case the old error-keyed \
predicate got backwards"
);
let epoch_closed = NifContextError::Durability(DurabilityError::EngineTaskEpochClosed {
reason: "this engine has begun closing".to_owned(),
});
assert!(
outcome_must_end_the_process(&Err(epoch_closed)),
"a closed epoch must end the process: it is the one condition workflow code cannot \
improve on and cannot outlive, and leaving the process alive leaves a durable writer \
running against a store a successor may already own"
);
let already_terminal = NifContextError::Durability(DurabilityError::HistoryShape {
reason: "run already recorded a terminal event".to_owned(),
});
assert!(
!outcome_must_end_the_process(&Err(already_terminal)),
"an already-terminal run is spared here NOT because it is live — its terminal is \
already durable — but because whichever seam recorded that terminal owns the \
teardown: a kill from this one would race the owners that end the pid and usurp \
the ones that only deregister"
);
let poisoned = NifContextError::RecorderPoisoned;
assert!(
!outcome_must_end_the_process(&Err(poisoned)),
"a poisoned recorder is not this engine standing down; the process must be left to \
handle it, not destroyed"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_deadline_retirement_that_fails_after_the_terminal_reports_terminal_only() -> TestResult {
let store = Arc::new(FlakyStore::new());
let tasks = EngineTaskRuntime::new()?;
let (mut control_recorder, control_run) =
seed_running_run(Arc::clone(&store) as Arc<dyn EventStore>).await?;
let control_id = control_recorder.workflow_id().clone();
let control = record_continuation(
&mut control_recorder,
&tasks,
&control_run,
Payload::from_json(&serde_json::json!({"round": 2}))?,
)
.await?;
assert!(
matches!(control, ContinuationOutcome::Complete),
"control: an unfaulted FlakyStore must complete the whole transition, or the treatment \
is measuring the fixture rather than the fault"
);
let control_history = store.recorded_history(&control_id).await?;
assert!(
retired_a_timer(&control_history),
"control: the unfaulted transition must retire the deadline — the treatment's claim that \
it did NOT is meaningless unless this one did: {control_history:#?}"
);
let (mut recorder, run_id) =
seed_running_run(Arc::clone(&store) as Arc<dyn EventStore>).await?;
let workflow_id = recorder.workflow_id().clone();
store.fail_appends_after(1, 1);
let outcome = record_continuation(
&mut recorder,
&tasks,
&run_id,
Payload::from_json(&serde_json::json!({"round": 2}))?,
)
.await?;
assert!(
matches!(outcome, ContinuationOutcome::TerminalOnly(_)),
"a post-terminal append failure must be reported as TerminalOnly, not as Complete and \
not as an error: the run has continued and the caller must be able to see that"
);
let history = store.recorded_history(&workflow_id).await?;
assert!(
continued(&history),
"the terminal must be DURABLE — that is the whole premise of the TerminalOnly report, \
and if the fault had landed on the terminal instead this test would be pinning the \
pre-terminal case under a post-terminal name: {history:#?}"
);
assert!(
!retired_a_timer(&history),
"the injected fault must have taken the deadline retirement specifically — a run whose \
deadline WAS retired has nothing left over to report: {history:#?}"
);
assert!(
outcome_must_end_the_process(&Ok(outcome)),
"a half-completed transition must still end the workflow process: the run is terminal, \
and a live process on a terminal run writes into a closed history alongside the \
successor the sweep will start"
);
Ok(())
}
fn live_runtime() -> Result<Arc<crate::runtime::RuntimeHandle>, Box<dyn std::error::Error>> {
let runtime = Arc::new(crate::runtime::RuntimeHandle::new(
crate::runtime::config::RuntimeConfig::new(Some(2)),
)?);
let mut registration = crate::runtime::nif::NifRegistration::new();
registration.add_engine_nifs();
runtime.install_nifs(registration)?;
runtime.register_module(
"aion_continue_fixture",
include_bytes!("../../tests/fixtures/aion_continue_fixture.beam"),
)?;
Ok(runtime)
}
fn install_determinism_context(
runtime: &Arc<crate::runtime::RuntimeHandle>,
registry: &Arc<crate::registry::Registry>,
store: &Arc<dyn EventStore>,
tokio_runtime: &tokio::runtime::Runtime,
) {
crate::runtime::nif_determinism::install_nif_context_source(
runtime.nif_state(),
Arc::new(crate::runtime::nif_determinism::NifContextSource::new(
Arc::clone(registry),
tokio_runtime.handle().clone(),
Arc::clone(store),
runtime.signal_delivery(),
)),
);
}
enum SeededDeadline {
None,
Outstanding,
}
fn register_live_run(
tokio_runtime: &tokio::runtime::Runtime,
registry: &crate::registry::Registry,
store: Arc<dyn EventStore>,
pid: crate::Pid,
deadline: &SeededDeadline,
) -> Result<WorkflowId, Box<dyn std::error::Error>> {
let workflow_id = WorkflowId::new_v4();
let run_id = RunId::new_v4();
let mut recorder = Recorder::new(workflow_id.clone(), store);
tokio_runtime.block_on(recorder.record_workflow_started(
Utc::now(),
WorkflowStartRecord {
workflow_type: "continuer".to_owned(),
input: Payload::from_json(&serde_json::json!({}))?,
run_id: run_id.clone(),
parent_run_id: None,
parent_workflow_id: None,
package_version: PackageVersion::new("a".repeat(64)),
},
))?;
match deadline {
SeededDeadline::None => {}
SeededDeadline::Outstanding => {
let deadline_id = crate::time::deadline_timer_id(&run_id)?;
tokio_runtime.block_on(recorder.record_timer_started(
Utc::now(),
deadline_id,
Utc::now(),
))?;
}
}
registry.insert(
(workflow_id.clone(), run_id.clone()),
crate::registry::WorkflowHandle::new(crate::registry::WorkflowHandleParts {
workflow_id: workflow_id.clone(),
run_id,
pid,
workflow_type: "continuer".to_owned(),
namespace: "default".to_owned(),
loaded_version: aion_package::ContentHash::from_bytes([9; 32]),
cached_status: aion_core::WorkflowStatus::Running,
residency: crate::registry::HandleResidency::Resident,
recorder,
completion: crate::registry::CompletionNotifier::new(),
}),
)?;
Ok(workflow_id)
}
const SETTLE_BUDGET: std::time::Duration = std::time::Duration::from_secs(10);
fn settles(probe: impl Fn() -> bool) -> bool {
let deadline = std::time::Instant::now() + SETTLE_BUDGET;
while std::time::Instant::now() < deadline {
if probe() {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
probe()
}
struct GateHarness {
tokio_runtime: tokio::runtime::Runtime,
runtime: Arc<crate::runtime::RuntimeHandle>,
registry: Arc<crate::registry::Registry>,
store: Arc<FlakyStore>,
torn_down: std::cell::Cell<bool>,
}
impl Drop for GateHarness {
fn drop(&mut self) {
if self.torn_down.get() {
return;
}
if let Err(error) = self.runtime.shutdown() {
eprintln!("GateHarness: teardown after a failing condition also failed: {error}");
}
}
}
impl GateHarness {
fn build() -> Result<Self, Box<dyn std::error::Error>> {
let tokio_runtime = tokio::runtime::Runtime::new()?;
let runtime = live_runtime()?;
let registry = Arc::new(crate::registry::Registry::default());
let store = Arc::new(FlakyStore::new());
crate::runtime::install_nif_runtime_context(
runtime.nif_state(),
Arc::clone(®istry),
Arc::clone(&runtime),
tokio_runtime.handle().clone(),
);
install_determinism_context(
&runtime,
®istry,
&(Arc::clone(&store) as Arc<dyn EventStore>),
&tokio_runtime,
);
Ok(Self {
tokio_runtime,
runtime,
registry,
store,
torn_down: std::cell::Cell::new(false),
})
}
fn shutdown(&self) -> Result<(), Box<dyn std::error::Error>> {
self.torn_down.set(true);
Ok(self.runtime.shutdown()?)
}
fn spawn_continuer(&self) -> Result<crate::Pid, Box<dyn std::error::Error>> {
Ok(self.runtime.spawn_workflow(
"aion_continue_fixture",
"await_then_continue",
crate::runtime::handle::RuntimeInput::default(),
)?)
}
fn register(
&self,
pid: crate::Pid,
deadline: &SeededDeadline,
) -> Result<WorkflowId, Box<dyn std::error::Error>> {
register_live_run(
&self.tokio_runtime,
&self.registry,
Arc::clone(&self.store) as Arc<dyn EventStore>,
pid,
deadline,
)
}
fn history(&self, workflow: &WorkflowId) -> Result<Vec<Event>, Box<dyn std::error::Error>> {
Ok(self
.tokio_runtime
.block_on(self.store.recorded_history(workflow))?)
}
}
fn control_two_spared_refusal(h: &GateHarness) -> TestResult {
let spared = h.spawn_continuer()?;
h.store.fail_appends_after(1, 1);
let spared_workflow = h.register(spared, &SeededDeadline::None)?;
assert!(
settles(|| h.store.unspent_append_failures() == 0),
"CONTROL 2 never reached the durable half — the injected APPEND failure was never spent, \
so this process's survival says nothing about the gate"
);
let spared_died = settles(|| !h.runtime.is_live(spared));
let spared_history = h.history(&spared_workflow)?;
assert!(
!spared_died,
"CONTROL 2: a store failure raised BEFORE the terminal must leave the workflow \
process ALIVE, and must go on leaving it alive for as long as the treatment is \
given to die — terminating here would turn a transient backend blip into a dead \
run, the mirror of the defect the gate exists to prevent. Diagnosis: terminal in \
history = {terminal} (true means the fault was NOT taken by the terminal append \
and the gate fired correctly for a landed terminal — suspect the fault-arming \
window, or a stale test binary: a restore that preserves an old mtime leaves the \
previous mutation's binary looking fresh); unspent append failures = {unspent} \
(non-zero means the durable call never happened and the death is the harness's, \
not the gate's); history at death: {spared_history:#?}",
terminal = continued(&spared_history),
unspent = h.store.unspent_append_failures(),
);
assert!(
!continued(&spared_history),
"CONTROL 2's refusal must also be pre-terminal, or it is testing the Ok arm under an Err \
name: {spared_history:#?}"
);
Ok(())
}
fn treatment_a_terminal_only(h: &GateHarness) -> TestResult {
let half_completed = h.spawn_continuer()?;
h.store.fail_appends_after(3, 1);
let half_workflow = h.register(half_completed, &SeededDeadline::Outstanding)?;
assert!(
settles(|| h.store.unspent_append_failures() == 0),
"TREATMENT A never reached the deadline retirement — the injected APPEND failure was \
never spent, so nothing here measured the TerminalOnly arm. Either the fixture never \
called, or the terminal append itself was refused (which is CONTROL 2's condition, \
not this one)"
);
let half_died = settles(|| !h.runtime.is_live(half_completed));
let half_history = h.history(&half_workflow)?;
assert!(
half_died,
"TREATMENT A: a transition whose terminal LANDED must end the calling process even \
though the deadline retirement failed — this is the gate consuming Ok(TerminalOnly), \
the arm the type exists for. A process left alive here keeps writing timers, \
activities, children and signals into a history that already holds its terminal, \
concurrently with the successor the sweep will start. Diagnosis: terminal in history \
= {terminal} (false means the terminal append was refused and this measured the \
wrong arm); timer retired = {retired} (true means the transition COMPLETED and this \
measured the Complete arm); unspent append failures = {unspent}; history: \
{half_history:#?}",
terminal = continued(&half_history),
retired = retired_a_timer(&half_history),
unspent = h.store.unspent_append_failures(),
);
assert!(
continued(&half_history),
"TREATMENT A's terminal must have LANDED — a death without a durable terminal is the \
epoch refusal's condition wearing this one's name: {half_history:#?}"
);
assert!(
!retired_a_timer(&half_history),
"TREATMENT A's deadline retirement must have FAILED — a retired deadline means the \
transition completed and this measured Ok(Complete), not Ok(TerminalOnly): \
{half_history:#?}"
);
Ok(())
}
fn treatment_b_complete(h: &GateHarness) -> TestResult {
let completed = h.spawn_continuer()?;
let completed_workflow = h.register(completed, &SeededDeadline::Outstanding)?;
let completed_died = settles(|| !h.runtime.is_live(completed));
let completed_history = h.history(&completed_workflow)?;
assert!(
completed_died,
"TREATMENT B: a transition that COMPLETED — terminal durable, deadline retired — must \
end the calling process; this is the gate consuming Ok(Complete), the ordinary path \
every real continue-as-new takes. Diagnosis: terminal in history = {terminal}; timer \
retired = {retired} (false/false means the fixture never made the call and this \
measured the harness); history: {completed_history:#?}",
terminal = continued(&completed_history),
retired = retired_a_timer(&completed_history),
);
assert!(
continued(&completed_history) && retired_a_timer(&completed_history),
"TREATMENT B's transition must have run to completion — terminal AND retirement \
durable — or the death above was measured under the wrong input class: \
{completed_history:#?}"
);
Ok(())
}
fn treatment_c_epoch_refusal(h: &GateHarness) -> TestResult {
let refused = h.spawn_continuer()?;
h.runtime.engine_tasks().begin_close();
let refused_workflow = h.register(refused, &SeededDeadline::None)?;
assert!(
settles(|| !h.runtime.is_live(refused)),
"the epoch refusal must END the workflow process: it holds the run's only Recorder, and \
every durable NIF it goes on to call writes into a history this closing engine may no \
longer own"
);
let refused_history = h.history(&refused_workflow)?;
assert!(
!continued(&refused_history),
"the refusal must leave history UNMOVED — the whole point of gating before the first \
append: {refused_history:#?}"
);
Ok(())
}
#[test]
fn the_nif_gate_ends_a_live_process_for_each_outcome_that_must_end_it_and_spares_the_rest()
-> TestResult {
let harness = GateHarness::build()?;
let parked = harness.runtime.spawn_workflow(
"aion_continue_fixture",
"park_only",
crate::runtime::handle::RuntimeInput::default(),
)?;
control_two_spared_refusal(&harness)?;
treatment_a_terminal_only(&harness)?;
treatment_b_complete(&harness)?;
treatment_c_epoch_refusal(&harness)?;
assert!(
harness.runtime.is_live(parked),
"CONTROL 1: a workflow process that never calls the NIF must survive the whole test. If \
this dies, none of the results above say anything about cancel_pid — though note it \
survives a harness in which every CALLING process dies, which is why CONTROL 2 exists"
);
harness.shutdown()?;
Ok(())
}