use std::sync::Arc;
use aion_core::{Event, Payload, RunId, WorkflowId, current_lease_terminal, run_segment};
use aion_store::EventStore;
use aion_store::visibility::VisibilityStore;
use tokio::runtime::Handle;
use crate::EngineError;
use crate::loader::WorkflowCatalog;
use crate::registry::{Registry, Residency, TerminalOutcome, WorkflowHandle};
use crate::runtime::{RuntimeHandle, WorkflowProcessOutcome};
use crate::supervision::SupervisionTree;
use super::completion_retry::{
CompletionFailure, TerminalIntent, TerminalIntentOutcome, TerminalProgress,
arm_completion_retry, complete_process_exit,
};
use super::continuation::{self, ContinuationOrigin, ContinuationOutcome, ContinuationRequest};
use super::start::StartWorkflowContext;
use super::visibility::upsert_workflow_visibility;
#[derive(Clone)]
pub struct ProcessExitContext {
pub store: Arc<dyn EventStore>,
pub visibility_store: Arc<dyn VisibilityStore>,
pub registry: Arc<Registry>,
pub catalog: Arc<WorkflowCatalog>,
pub runtime: Arc<RuntimeHandle>,
pub supervision: Arc<SupervisionTree>,
pub tokio_handle: Handle,
pub search_attribute_schema: Arc<aion_core::SearchAttributeSchema>,
}
pub fn handle_process_exit(
context: ProcessExitContext,
handle: WorkflowHandle,
outcome: Result<WorkflowProcessOutcome, EngineError>,
) -> Result<(), EngineError> {
context
.tokio_handle
.clone()
.block_on(handle_process_exit_async(context, handle, outcome))
}
async fn handle_process_exit_async(
context: ProcessExitContext,
handle: WorkflowHandle,
outcome: Result<WorkflowProcessOutcome, EngineError>,
) -> Result<(), EngineError> {
let intent = TerminalIntent::from_outcome(outcome);
match complete_process_exit(&context, &handle, &intent, TerminalProgress::NotRecorded).await {
Ok(_progress) => Ok(()),
Err(CompletionFailure::Invariant(error, _)) => Err(error),
Err(CompletionFailure::Retryable(error, progress)) => {
arm_completion_retry(context, handle, intent, &error, progress);
Ok(())
}
}
}
fn monitor_stands_down(
context: &ProcessExitContext,
handle: &WorkflowHandle,
progress: TerminalProgress,
) -> bool {
let superseded_by_a_newer_lease =
match context.registry.get(handle.workflow_id(), handle.run_id()) {
Ok(current) => current.is_some_and(|current| current.pid() != handle.pid()),
Err(error) => {
tracing::error!(
workflow_id = %handle.workflow_id(),
run_id = %handle.run_id(),
monitor_pid = handle.pid(),
error = %error,
"the workflow registry could not be read while deciding whether a newer \
lease had superseded this monitor; proceeding to attempt the terminal \
append, where the store's sequence check is the authority"
);
false
}
};
if matches!(progress, TerminalProgress::Recorded) || superseded_by_a_newer_lease {
tracing::info!(
workflow_id = %handle.workflow_id(),
run_id = %handle.run_id(),
monitor_pid = handle.pid(),
superseded_by_a_newer_lease,
"abandoning workflow completion retry: this run's terminal is no longer \
this monitor's to write — either it already recorded one, or the run was \
reopened and a newer lease now holds the writer slot"
);
return true;
}
false
}
fn refuse_if_epoch_closed(
context: &ProcessExitContext,
handle: &WorkflowHandle,
) -> Result<(), EngineError> {
if context.runtime.engine_tasks().is_epoch_open() {
return Ok(());
}
Err(EngineError::EngineTaskEpochClosed {
workflow_id: handle.workflow_id().to_string(),
run_id: handle.run_id().to_string(),
})
}
pub(super) async fn handle_process_exit_attempt(
context: &ProcessExitContext,
handle: &WorkflowHandle,
intent: &TerminalIntent,
progress: &mut TerminalProgress,
) -> Result<(), EngineError> {
let recorded = {
let recorder = handle.recorder();
let mut recorder = recorder.lock().await;
refuse_if_epoch_closed(context, handle)?;
let history = context.store.read_history(handle.workflow_id()).await?;
if let Some(existing) = terminal_outcome_from_history(&history, handle.run_id()) {
*progress = TerminalProgress::Recorded;
if !matches!(existing, TerminalOutcome::TimedOut(_)) {
crate::time::retire_run_deadline(&mut recorder, &history, handle.run_id()).await?;
}
Err(existing)
} else {
if monitor_stands_down(context, handle, *progress) {
return Ok(());
}
let outcome = match &intent.outcome {
TerminalIntentOutcome::Completed(result) => {
recorder
.record_workflow_completed(intent.exit_time, result.clone())
.await?;
TerminalOutcome::Completed(result.clone())
}
TerminalIntentOutcome::Failed(error) => {
recorder
.record_workflow_failed(intent.exit_time, error.clone())
.await?;
TerminalOutcome::Failed(error.clone())
}
};
*progress = TerminalProgress::Recorded;
crate::time::retire_run_deadline(&mut recorder, &history, handle.run_id()).await?;
Ok(outcome)
}
};
let terminal = match recorded {
Err(existing) => {
handle.completion().notify(existing.clone());
if !matches!(existing, TerminalOutcome::TimedOut(_)) {
upsert_workflow_visibility(
Arc::clone(&context.store),
Arc::clone(&context.visibility_store),
handle.workflow_id(),
handle.run_id(),
)
.await?;
}
reconcile_terminal_registry(context, handle.workflow_id(), handle.run_id()).await?;
if let TerminalOutcome::ContinuedAsNew {
input,
workflow_type,
parent_run_id,
} = existing
{
start_continuation_replacement(
context,
handle,
input,
workflow_type,
parent_run_id,
)
.await?;
}
return Ok(());
}
Ok(terminal) => terminal,
};
handle.completion().notify(terminal);
crate::lifecycle::terminal_reconcile_gate::hold(handle.workflow_id(), handle.run_id()).await;
upsert_workflow_visibility(
Arc::clone(&context.store),
Arc::clone(&context.visibility_store),
handle.workflow_id(),
handle.run_id(),
)
.await?;
reconcile_terminal_registry(context, handle.workflow_id(), handle.run_id()).await?;
Ok(())
}
async fn reconcile_terminal_registry(
context: &ProcessExitContext,
id: &WorkflowId,
run: &RunId,
) -> Result<(), EngineError> {
let history = context.store.read_history(id).await?;
context.registry.reconcile(id, run, &history)?;
context
.registry
.replace_residency(id, run, Residency::Suspended)?;
Ok(())
}
async fn start_continuation_replacement(
context: &ProcessExitContext,
handle: &WorkflowHandle,
input: Payload,
workflow_type: Option<String>,
parent_run_id: RunId,
) -> Result<(), EngineError> {
refuse_if_epoch_closed(context, handle)?;
let replacement_type = workflow_type
.as_deref()
.unwrap_or_else(|| handle.workflow_type())
.to_owned();
let outcome = continuation::open_successor_generation(
&StartWorkflowContext {
store: Arc::clone(&context.store),
visibility_store: Arc::clone(&context.visibility_store),
catalog: Arc::clone(&context.catalog),
runtime: Arc::clone(&context.runtime),
supervision: Arc::clone(&context.supervision),
registry: Arc::clone(&context.registry),
signal_handoff: None,
search_attribute_schema: Arc::clone(&context.search_attribute_schema),
monitor_tokio_handle: context.tokio_handle.clone(),
},
handle,
ContinuationRequest {
predecessor_run: parent_run_id,
origin: ContinuationOrigin::TerminalAlreadyRecorded,
workflow_type: replacement_type,
input,
},
)
.await?;
if matches!(outcome, ContinuationOutcome::AlreadyOpen) {
tracing::debug!(
workflow_id = %handle.workflow_id(),
run_id = %handle.run_id(),
"continue-as-new successor was already started; the exit monitor appended nothing"
);
}
Ok(())
}
pub(crate) fn terminal_outcome_from_history(
events: &[Event],
run_id: &RunId,
) -> Option<TerminalOutcome> {
match current_lease_terminal(run_segment(events, run_id))? {
Event::WorkflowCompleted { result, .. } => Some(TerminalOutcome::Completed(result.clone())),
Event::WorkflowFailed { error, .. } => Some(TerminalOutcome::Failed(error.clone())),
Event::WorkflowCancelled { reason, .. } => Some(TerminalOutcome::Cancelled(reason.clone())),
Event::WorkflowTimedOut { timeout, .. } => Some(TerminalOutcome::TimedOut(timeout.clone())),
Event::WorkflowContinuedAsNew {
input,
workflow_type,
parent_run_id,
..
} if parent_run_id == run_id => Some(TerminalOutcome::ContinuedAsNew {
input: input.clone(),
workflow_type: workflow_type.clone(),
parent_run_id: parent_run_id.clone(),
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use aion_core::{Event, Payload, WorkflowStatus};
use aion_package::ContentHash;
use aion_store::visibility::VisibilityStore;
use aion_store::{EventStore, InMemoryStore};
use serde_json::json;
use super::super::completion_retry::{
CompletionRetryContext, FaultReporter, failure_discriminant, retry_process_exit,
};
use super::{
CompletionFailure, ProcessExitContext, TerminalIntent, TerminalIntentOutcome,
TerminalProgress, complete_process_exit, handle_process_exit_async,
terminal_outcome_from_history,
};
use crate::durability::Recorder;
use crate::loader::WorkflowCatalog;
use crate::registry::{
CompletionNotifier, HandleResidency, Registry, TerminalOutcome, WorkflowHandle,
WorkflowHandleParts,
};
use crate::runtime::{RuntimeConfig, RuntimeHandle, WorkflowProcessOutcome};
use crate::supervision::SupervisionTree;
struct ActiveWorkflow {
context: ProcessExitContext,
handle: WorkflowHandle,
}
fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
Payload::from_json(&json!({ "label": label }))
}
fn workflow_error(message: &str) -> aion_core::WorkflowError {
aion_core::WorkflowError {
message: message.to_owned(),
details: None,
}
}
fn fast_backoff()
-> Result<crate::runtime::CompletionRetryConfig, crate::runtime::InvalidCompletionRetryLadder>
{
crate::runtime::CompletionRetryConfig::try_new(
std::time::Duration::from_millis(1),
std::time::Duration::from_millis(5),
)
}
async fn flaky_workflow()
-> Result<(ActiveWorkflow, Arc<crate::store_faults::FlakyStore>), Box<dyn std::error::Error>>
{
flaky_workflow_with_retry(fast_backoff()?).await
}
async fn flaky_workflow_with_retry(
completion_retry: crate::runtime::CompletionRetryConfig,
) -> Result<(ActiveWorkflow, Arc<crate::store_faults::FlakyStore>), Box<dyn std::error::Error>>
{
let flaky = Arc::new(crate::store_faults::FlakyStore::new());
let visibility = Arc::new(InMemoryStore::default());
let active = active_workflow_over(
Arc::clone(&flaky) as Arc<dyn EventStore>,
visibility as Arc<dyn VisibilityStore>,
completion_retry,
)
.await?;
Ok((active, flaky))
}
async fn active_workflow() -> Result<ActiveWorkflow, Box<dyn std::error::Error>> {
let backing = Arc::new(InMemoryStore::default());
active_workflow_over(
Arc::clone(&backing) as Arc<dyn EventStore>,
backing as Arc<dyn VisibilityStore>,
fast_backoff()?,
)
.await
}
async fn active_workflow_over(
store: Arc<dyn EventStore>,
visibility_store: Arc<dyn VisibilityStore>,
completion_retry: crate::runtime::CompletionRetryConfig,
) -> Result<ActiveWorkflow, Box<dyn std::error::Error>> {
let registry = Arc::new(Registry::default());
let workflow_id = aion_core::WorkflowId::new_v4();
let run_id = aion_core::RunId::new_v4();
let mut recorder = Recorder::new(workflow_id.clone(), Arc::clone(&store));
recorder
.record_workflow_started(
chrono::Utc::now(),
crate::durability::WorkflowStartRecord {
workflow_type: "checkout".to_owned(),
input: payload("input")?,
run_id: run_id.clone(),
parent_run_id: None,
parent_workflow_id: None,
package_version: aion_core::PackageVersion::new("a".repeat(64)),
},
)
.await?;
let handle = WorkflowHandle::new(WorkflowHandleParts {
workflow_id: workflow_id.clone(),
run_id: run_id.clone(),
pid: 1,
workflow_type: "checkout".to_owned(),
namespace: String::from("default"),
loaded_version: ContentHash::from_bytes([9; 32]),
cached_status: WorkflowStatus::Running,
residency: HandleResidency::Resident,
recorder,
completion: CompletionNotifier::new(),
});
registry.insert((workflow_id, run_id), handle.clone())?;
Ok(ActiveWorkflow {
context: ProcessExitContext {
store,
visibility_store,
registry,
catalog: Arc::new(WorkflowCatalog::new()),
runtime: Arc::new(RuntimeHandle::new(
RuntimeConfig::new(Some(1), crate::runtime::config::TEST_STOP_DRAIN_TIMEOUT)
.with_completion_retry(completion_retry),
)?),
supervision: Arc::new(SupervisionTree::new()),
tokio_handle: tokio::runtime::Handle::current(),
search_attribute_schema: Arc::new(aion_core::SearchAttributeSchema::new()),
},
handle,
})
}
#[tokio::test]
async fn normal_exit_records_completed_reconciles_and_notifies()
-> Result<(), Box<dyn std::error::Error>> {
let active = active_workflow().await?;
let result = payload("result")?;
let mut early = active.handle.completion().subscribe();
handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(result.clone())),
)
.await?;
early.changed().await?;
assert_eq!(
early.borrow().clone(),
Some(TerminalOutcome::Completed(result.clone()))
);
assert_eq!(
active.handle.completion().subscribe().borrow().clone(),
Some(TerminalOutcome::Completed(result.clone()))
);
let registered = active
.context
.registry
.get(active.handle.workflow_id(), active.handle.run_id())?
.ok_or("missing registered handle")?;
assert_eq!(registered.cached_status(), WorkflowStatus::Completed);
assert_eq!(registered.residency(), HandleResidency::Suspended);
let history = active
.context
.store
.read_history(active.handle.workflow_id())
.await?;
match history.as_slice() {
[
Event::WorkflowStarted { .. },
Event::WorkflowCompleted {
result: recorded, ..
},
] => {
assert_eq!(recorded, &result);
}
other => return Err(format!("expected started then completed, found {other:?}").into()),
}
Ok(())
}
#[test]
fn terminal_outcome_is_scoped_to_requested_run_segment()
-> Result<(), Box<dyn std::error::Error>> {
let old_run_id = aion_core::RunId::new(uuid::Uuid::from_u128(1));
let new_run_id = aion_core::RunId::new(uuid::Uuid::from_u128(2));
let input = payload("next")?;
let result = payload("done")?;
let workflow_id = aion_core::WorkflowId::new_v4();
let envelope = |seq| aion_core::EventEnvelope {
seq,
recorded_at: chrono::Utc::now(),
workflow_id: workflow_id.clone(),
};
let events = vec![
Event::WorkflowStarted {
envelope: envelope(1),
workflow_type: "checkout".to_owned(),
input: payload("first")?,
run_id: old_run_id.clone(),
parent_run_id: None,
parent_workflow_id: None,
package_version: aion_core::PackageVersion::new("a".repeat(64)),
},
Event::WorkflowContinuedAsNew {
envelope: envelope(2),
input: input.clone(),
workflow_type: None,
parent_run_id: old_run_id.clone(),
},
Event::WorkflowStarted {
envelope: envelope(3),
workflow_type: "checkout".to_owned(),
input,
run_id: new_run_id.clone(),
parent_run_id: Some(old_run_id.clone()),
parent_workflow_id: None,
package_version: aion_core::PackageVersion::new("a".repeat(64)),
},
Event::WorkflowCompleted {
envelope: envelope(4),
result: result.clone(),
},
];
assert_eq!(
terminal_outcome_from_history(&events, &old_run_id),
Some(TerminalOutcome::ContinuedAsNew {
input: payload("next")?,
workflow_type: None,
parent_run_id: old_run_id,
})
);
assert_eq!(
terminal_outcome_from_history(&events, &new_run_id),
Some(TerminalOutcome::Completed(result))
);
Ok(())
}
#[tokio::test]
async fn process_exit_resumes_interrupted_deadline_cancellation()
-> Result<(), Box<dyn std::error::Error>> {
let active = active_workflow().await?;
let run_id = active.handle.run_id().clone();
let deadline_id = crate::time::deadline_timer_id(&run_id)?;
let result = payload("result")?;
{
let recorder = active.handle.recorder();
let mut recorder = recorder.lock().await;
recorder
.record_timer_started(chrono::Utc::now(), deadline_id.clone(), chrono::Utc::now())
.await?;
recorder
.record_workflow_completed(chrono::Utc::now(), result.clone())
.await?;
}
let before = active
.context
.store
.read_history(active.handle.workflow_id())
.await?;
assert!(
crate::time::outstanding_deadline_timer(&before, &run_id).is_some(),
"the deadline is outstanding before the resume"
);
handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(result.clone())),
)
.await?;
let history = active
.context
.store
.read_history(active.handle.workflow_id())
.await?;
assert_eq!(
crate::time::outstanding_deadline_timer(&history, &run_id),
None,
"re-encountering the own terminal completes the deadline cancellation: {history:#?}"
);
Ok(())
}
#[tokio::test]
async fn process_exit_does_not_retire_a_timed_out_deadline()
-> Result<(), Box<dyn std::error::Error>> {
let active = active_workflow().await?;
let run_id = active.handle.run_id().clone();
let deadline_id = crate::time::deadline_timer_id(&run_id)?;
{
let recorder = active.handle.recorder();
let mut recorder = recorder.lock().await;
recorder
.record_timer_started(chrono::Utc::now(), deadline_id.clone(), chrono::Utc::now())
.await?;
recorder
.record_workflow_timed_out(chrono::Utc::now(), String::from("workflow"))
.await?;
}
handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(payload("late")?)),
)
.await?;
let history = active
.context
.store
.read_history(active.handle.workflow_id())
.await?;
assert!(
crate::time::outstanding_deadline_timer(&history, &run_id).is_some(),
"the monitor must leave a TimedOut deadline live for its owning teardown: {history:#?}"
);
Ok(())
}
#[tokio::test]
async fn process_exit_leaves_a_timed_out_runs_visibility_to_its_teardown()
-> Result<(), Box<dyn std::error::Error>> {
let active = active_workflow().await?;
let run_id = active.handle.run_id().clone();
let deadline_id = crate::time::deadline_timer_id(&run_id)?;
{
let recorder = active.handle.recorder();
let mut recorder = recorder.lock().await;
recorder
.record_timer_started(chrono::Utc::now(), deadline_id.clone(), chrono::Utc::now())
.await?;
recorder
.record_workflow_timed_out(chrono::Utc::now(), String::from("workflow"))
.await?;
}
handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(payload("late")?)),
)
.await?;
let row = active
.context
.visibility_store
.get_visibility(active.handle.workflow_id())
.await?;
assert!(
row.as_ref()
.is_none_or(|row| row.status != WorkflowStatus::TimedOut),
"the monitor must not project a timed-out run's terminal into the index — that write \
belongs to the teardown it races: {row:#?}"
);
Ok(())
}
#[tokio::test]
async fn a_reopened_run_is_not_appended_into_by_a_monitor_that_never_recorded()
-> Result<(), Box<dyn std::error::Error>> {
let active = active_workflow().await?;
let run_id = active.handle.run_id().clone();
terminal_by_another_writer(&active, payload("first")?).await?;
reopen_by_its_own_recorder(&active, &run_id).await?;
let successor = respawned_lease(&active).await?;
active.context.registry.insert(
(active.handle.workflow_id().clone(), run_id.clone()),
successor,
)?;
let completed_before = completed_count(&active, &run_id).await?;
assert_eq!(
completed_before, 1,
"fixture control: the run must carry exactly one terminal before the monitor runs"
);
assert_ne!(
active
.context
.registry
.get(active.handle.workflow_id(), &run_id)?
.map(|current| current.pid()),
Some(active.handle.pid()),
"fixture control: the registry must hold a DIFFERENT lease than the monitor's, or \
this test is not exercising a superseded monitor at all"
);
let outcome = handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(payload("late")?)),
)
.await;
assert!(
outcome.is_ok(),
"a superseded monitor tried to append into a reopened run, the store refused it on \
sequence, and the operator was handed this codebase's double-writer indicator for \
what is an ordinary reopen: {outcome:?}"
);
assert_eq!(
completed_count(&active, &run_id).await?,
1,
"the monitor appended a second terminal into a run that had been reopened — it is no \
longer this run's writer"
);
Ok(())
}
#[tokio::test]
async fn a_reopened_run_that_finishes_again_records_its_terminal()
-> Result<(), Box<dyn std::error::Error>> {
let active = active_workflow().await?;
let run_id = active.handle.run_id().clone();
{
let recorder = active.handle.recorder();
let mut recorder = recorder.lock().await;
recorder
.record_workflow_failed(chrono::Utc::now(), workflow_error("first attempt"))
.await?;
recorder
.record_workflow_reopened(chrono::Utc::now(), run_id.clone(), Vec::new())
.await?;
}
let history = active
.context
.store
.read_history(active.handle.workflow_id())
.await?;
assert!(
terminal_outcome_from_history(&history, &run_id).is_none(),
"fixture control: the reopen must have superseded the first terminal, or this test \
would pass without the monitor recording anything"
);
assert_eq!(
active
.context
.registry
.get(active.handle.workflow_id(), &run_id)?
.map(|current| current.pid()),
Some(active.handle.pid()),
"fixture control: this handle must be the lease the registry currently holds — the \
whole point of the case is that it has NOT been superseded"
);
handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(payload("second")?)),
)
.await?;
let history = active
.context
.store
.read_history(active.handle.workflow_id())
.await?;
assert!(
matches!(
terminal_outcome_from_history(&history, &run_id),
Some(TerminalOutcome::Completed(_))
),
"the reopened run's own lease exited normally and its terminal was not recorded — the \
run projects Running forever and every waiter on it is stranded: {:#?}",
terminal_outcome_from_history(&history, &run_id)
);
Ok(())
}
#[tokio::test]
async fn a_recorded_monitor_stands_down_inside_the_reopen_window()
-> Result<(), Box<dyn std::error::Error>> {
let active = active_workflow().await?;
let run_id = active.handle.run_id().clone();
{
let recorder = active.handle.recorder();
let mut recorder = recorder.lock().await;
recorder
.record_workflow_completed(chrono::Utc::now(), payload("recorded by me")?)
.await?;
}
reopen_by_its_own_recorder(&active, &run_id).await?;
active
.context
.registry
.remove(active.handle.workflow_id(), &run_id)?;
assert_eq!(
completed_count(&active, &run_id).await?,
1,
"fixture control: exactly one terminal before the attempt"
);
assert!(
active
.context
.registry
.get(active.handle.workflow_id(), &run_id)?
.is_none(),
"fixture control: the registry must be EMPTY for this key — with a lease present the \
lease disjunct would carry the test and `progress` would prove nothing"
);
let intent = TerminalIntent {
outcome: TerminalIntentOutcome::Completed(payload("second")?),
exit_time: chrono::Utc::now(),
};
let mut progress = TerminalProgress::Recorded;
let outcome = super::handle_process_exit_attempt(
&active.context,
&active.handle,
&intent,
&mut progress,
)
.await;
assert!(
outcome.is_ok(),
"a monitor that had already recorded its terminal attempted an append inside the \
reopen window, the store refused it on sequence, and the operator was handed a \
`SequenceConflict` — the double-writer indicator — for what is an ordinary reopen: \
{outcome:?}"
);
assert_eq!(
completed_count(&active, &run_id).await?,
1,
"a monitor that had already recorded its terminal appended a second one into a run \
mid-reopen — the registry could not see the successor yet, so nothing but its own \
progress stood between it and a double write"
);
Ok(())
}
async fn separate_recorder(
active: &ActiveWorkflow,
) -> Result<Recorder, Box<dyn std::error::Error>> {
let history = active
.context
.store
.read_history(active.handle.workflow_id())
.await?;
let head = history.last().map(Event::seq).unwrap_or_default();
Ok(Recorder::resume_at(
active.handle.workflow_id().clone(),
Arc::clone(&active.context.store),
head,
))
}
async fn terminal_by_another_writer(
active: &ActiveWorkflow,
result: Payload,
) -> Result<(), Box<dyn std::error::Error>> {
separate_recorder(active)
.await?
.record_workflow_completed(chrono::Utc::now(), result)
.await?;
Ok(())
}
async fn reopen_by_its_own_recorder(
active: &ActiveWorkflow,
run_id: &aion_core::RunId,
) -> Result<(), Box<dyn std::error::Error>> {
separate_recorder(active)
.await?
.with_visibility(run_id.clone(), Arc::clone(&active.context.visibility_store))
.record_workflow_reopened(chrono::Utc::now(), run_id.clone(), Vec::new())
.await?;
Ok(())
}
async fn respawned_lease(
active: &ActiveWorkflow,
) -> Result<WorkflowHandle, Box<dyn std::error::Error>> {
Ok(WorkflowHandle::new(WorkflowHandleParts {
workflow_id: active.handle.workflow_id().clone(),
run_id: active.handle.run_id().clone(),
pid: active.handle.pid() + 1,
workflow_type: active.handle.workflow_type().to_owned(),
namespace: active.handle.namespace().to_owned(),
loaded_version: active.handle.loaded_version().clone(),
cached_status: WorkflowStatus::Running,
residency: HandleResidency::Resident,
recorder: separate_recorder(active).await?,
completion: CompletionNotifier::new(),
}))
}
async fn completed_count(
active: &ActiveWorkflow,
run_id: &aion_core::RunId,
) -> Result<usize, Box<dyn std::error::Error>> {
let history = active
.context
.store
.read_history(active.handle.workflow_id())
.await?;
Ok(aion_core::run_segment(&history, run_id)
.iter()
.filter(|event| matches!(event, Event::WorkflowCompleted { .. }))
.count())
}
#[tokio::test]
async fn abnormal_exit_records_failed_reconciles_and_notifies()
-> Result<(), Box<dyn std::error::Error>> {
let active = active_workflow().await?;
let error = workflow_error("process crashed: error");
let mut early = active.handle.completion().subscribe();
handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Failed(error.clone())),
)
.await?;
early.changed().await?;
assert_eq!(
early.borrow().clone(),
Some(TerminalOutcome::Failed(error.clone()))
);
assert_eq!(
active.handle.completion().subscribe().borrow().clone(),
Some(TerminalOutcome::Failed(error.clone()))
);
let registered = active
.context
.registry
.get(active.handle.workflow_id(), active.handle.run_id())?
.ok_or("missing registered handle")?;
assert_eq!(registered.cached_status(), WorkflowStatus::Failed);
assert_eq!(registered.residency(), HandleResidency::Suspended);
let history = active
.context
.store
.read_history(active.handle.workflow_id())
.await?;
match history.as_slice() {
[
Event::WorkflowStarted { .. },
Event::WorkflowFailed {
error: recorded, ..
},
] => {
assert_eq!(recorded, &error);
}
other => return Err(format!("expected started then failed, found {other:?}").into()),
}
Ok(())
}
const RETRY_DEADLINE: std::time::Duration = std::time::Duration::from_secs(10);
async fn await_terminal(
store: &Arc<crate::store_faults::FlakyStore>,
workflow_id: &aion_core::WorkflowId,
) -> Result<Vec<Event>, Box<dyn std::error::Error>> {
let deadline = std::time::Instant::now() + RETRY_DEADLINE;
loop {
let history = store.recorded_history(workflow_id).await?;
if history
.iter()
.any(|event| matches!(event, Event::WorkflowCompleted { .. }))
{
return Ok(history);
}
if std::time::Instant::now() >= deadline {
return Err(format!(
"the terminal never landed within {RETRY_DEADLINE:?}: {history:#?}"
)
.into());
}
tokio::time::sleep(std::time::Duration::from_millis(2)).await;
}
}
#[tokio::test]
async fn a_transient_history_read_failure_still_lands_the_terminal()
-> Result<(), Box<dyn std::error::Error>> {
let (active, flaky) = flaky_workflow().await?;
let result = payload("result")?;
flaky.fail_next_reads(3);
handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(result.clone())),
)
.await?;
let history = await_terminal(&flaky, active.handle.workflow_id()).await?;
let terminals = history
.iter()
.filter(|event| matches!(event, Event::WorkflowCompleted { .. }))
.count();
assert_eq!(
terminals, 1,
"exactly one terminal, never a double: {history:#?}"
);
Ok(())
}
#[tokio::test]
async fn a_transient_append_failure_still_lands_the_terminal()
-> Result<(), Box<dyn std::error::Error>> {
let (active, flaky) = flaky_workflow().await?;
let result = payload("result")?;
flaky.fail_next_appends(3);
handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(result.clone())),
)
.await?;
let history = await_terminal(&flaky, active.handle.workflow_id()).await?;
match history
.iter()
.filter(|event| matches!(event, Event::WorkflowCompleted { .. }))
.collect::<Vec<_>>()
.as_slice()
{
[
Event::WorkflowCompleted {
result: recorded, ..
},
] => {
assert_eq!(recorded, &result, "the retry records the ORIGINAL outcome");
}
other => return Err(format!("expected exactly one completion, found {other:?}").into()),
}
Ok(())
}
#[tokio::test]
async fn a_retrying_completion_returns_with_the_retry_still_armed()
-> Result<(), Box<dyn std::error::Error>> {
let (stalled, flaky) = flaky_workflow().await?;
flaky.fail_next_reads(200);
handle_process_exit_async(
stalled.context.clone(),
stalled.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(payload("stalled")?)),
)
.await?;
assert_eq!(
stalled
.context
.runtime
.engine_tasks()
.armed_completion_retry_count(),
1,
"the call returned while the run was still retrying: the loop is armed elsewhere, \
not run inline on the caller"
);
Ok(())
}
#[tokio::test]
async fn a_resumed_completion_appends_nothing_and_still_refreshes_visibility()
-> Result<(), Box<dyn std::error::Error>> {
let active = active_workflow().await?;
let result = payload("result")?;
{
let recorder = active.handle.recorder();
let mut recorder = recorder.lock().await;
recorder
.record_workflow_completed(chrono::Utc::now(), result.clone())
.await?;
}
let before = active
.context
.store
.read_history(active.handle.workflow_id())
.await?;
assert!(
active
.context
.visibility_store
.get_visibility(active.handle.workflow_id())
.await?
.is_none_or(|row| row.status != WorkflowStatus::Completed),
"the index must NOT already show the terminal, or this proves nothing"
);
handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(result)),
)
.await?;
let after = active
.context
.store
.read_history(active.handle.workflow_id())
.await?;
assert_eq!(
before.len(),
after.len(),
"a resumed completion must append nothing: {after:#?}"
);
let row = active
.context
.visibility_store
.get_visibility(active.handle.workflow_id())
.await?;
assert!(
row.as_ref()
.is_some_and(|row| row.status == WorkflowStatus::Completed),
"the resume path refreshes the index it used to skip: {row:#?}"
);
Ok(())
}
#[tokio::test]
async fn one_monitor_lease_can_never_hold_more_than_one_completion_retry()
-> Result<(), Box<dyn std::error::Error>> {
let (active, flaky) = flaky_workflow().await?;
let tasks = active.context.runtime.engine_tasks();
assert_eq!(
tasks.armed_completion_retry_count(),
0,
"the control: nothing is armed for this run before the first exit, so a later count \
of one is this test's doing and not the fixture's"
);
flaky.fail_next_reads(500);
for arm in 1..=25_u32 {
handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(payload("result")?)),
)
.await?;
assert_eq!(
tasks.armed_completion_retry_count(),
1,
"after arm {arm} of 25 the run holds exactly one retry; a count of 0 here means a \
refused arm evicted the live retry's registration, and anything above 1 means two \
writers of one run's terminal"
);
}
Ok(())
}
#[tokio::test]
async fn runtime_shutdown_closes_the_completion_retry_epoch()
-> Result<(), Box<dyn std::error::Error>> {
let (active, flaky) = flaky_workflow().await?;
flaky.fail_next_reads(500);
handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(payload("result")?)),
)
.await?;
let tasks = active.context.runtime.engine_tasks();
assert_eq!(
tasks.armed_completion_retry_count(),
1,
"the fixture must actually have a retry armed, or the assert below is vacuous"
);
active.context.runtime.shutdown()?;
assert_eq!(
tasks.armed_completion_retry_count(),
0,
"shutting the runtime down must leave the completion-retry epoch empty, so no \
armed retry can append a terminal after this point"
);
Ok(())
}
#[tokio::test]
async fn the_configured_backoff_ladder_governs_when_a_retry_lands()
-> Result<(), Box<dyn std::error::Error>> {
let configured = std::time::Duration::from_millis(500);
let ladder = crate::runtime::CompletionRetryConfig::try_new(configured, configured)?;
let observation_window = configured / 5;
assert!(
observation_window
> crate::runtime::CompletionRetryConfig::default().initial_backoff() * 10,
"this test is only decisive while the window it watches is far wider than the sleep an \
INHERITED first backoff would take — otherwise 'not yet landed' is satisfied by a \
retry that ignored the config entirely; if the default first backoff moves, move \
this value"
);
let (active, flaky) = flaky_workflow_with_retry(ladder).await?;
flaky.fail_next_reads(1);
handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(payload("result")?)),
)
.await?;
assert_eq!(
active
.context
.runtime
.engine_tasks()
.armed_completion_retry_count(),
1,
"the first attempt must have failed and armed a retry, or both assertions below are \
vacuous"
);
tokio::time::sleep(observation_window).await;
let early = flaky.recorded_history(active.handle.workflow_id()).await?;
assert!(
!early
.iter()
.any(|event| matches!(event, Event::WorkflowCompleted { .. })),
"a retry told to wait {configured:?} had already appended its terminal after \
{observation_window:?} — it is sleeping on something other than the configured \
ladder: {early:#?}"
);
let history = await_terminal(&flaky, active.handle.workflow_id()).await?;
assert_eq!(
history
.iter()
.filter(|event| matches!(event, Event::WorkflowCompleted { .. }))
.count(),
1,
"and once the configured interval elapses it lands, exactly once: {history:#?}"
);
Ok(())
}
#[tokio::test]
async fn at_the_ceiling_every_failing_attempt_states_itself()
-> Result<(), Box<dyn std::error::Error>> {
let interval = std::time::Duration::from_millis(1);
let ladder = crate::runtime::CompletionRetryConfig::try_new(interval, interval)?;
let (active, flaky) = flaky_workflow_with_retry(ladder).await?;
let failing_reads: u32 = 64;
flaky.fail_next_reads(failing_reads);
let intent =
TerminalIntent::from_outcome(Ok(WorkflowProcessOutcome::Completed(payload("result")?)));
let retry_context = CompletionRetryContext::downgrade(active.context.clone());
let (captured, subscriber) = crate::log_capture::LogCapture::new()?;
{
let _installed = tracing::subscriber::set_default(subscriber);
retry_process_exit(
&retry_context,
&active.handle,
&intent,
active.context.runtime.completion_retry(),
TerminalProgress::NotRecorded,
)
.await;
}
let warnings = captured.at_level("WARN")?;
let stated: Vec<&crate::log_capture::CapturedEvent> = warnings
.iter()
.filter(|event| event.mentions("retrying with backoff"))
.collect();
let attempts: Vec<u64> = stated
.iter()
.filter_map(|event| event.field("attempts")?.parse().ok())
.collect();
let total_captured = captured.events()?.len();
let warning_count = warnings.len();
assert!(
attempts.len() >= 3,
"fewer than three failing attempts SPOKE, so every assertion below would be vacuous. \
Two causes, and this assertion cannot separate them: the retry loop stopped early, \
or this thread's capture never saw the emissions. Discriminate on the totals — \
{total_captured} events captured at any level, {warning_count} at WARN. Both zero \
points at the capture (see `crate::log_capture`), not at the loop. Stated: {stated:?}"
);
assert_eq!(
attempts,
(1..=attempts.len() as u64).collect::<Vec<u64>>(),
"at the ceiling the stated attempts must be contiguous; a gap is the attempt ladder \
speaking alone, which is the silence this obligation exists to close: {stated:?}"
);
assert_eq!(
attempts.len(),
failing_reads as usize,
"the outage was {failing_reads} failing reads and every attempt on this fixture's \
fail-fast path spends exactly one, so {failing_reads} attempts must have spoken; a \
shorter run means either the ladder fell silent partway or an attempt spent more \
than one read, and both are findings: {stated:?}"
);
for event in &stated {
assert_eq!(
event.field("at_ceiling"),
Some("true"),
"an at-ceiling statement must say so: {event}"
);
for field in ["elapsed_seconds", "fault"] {
assert!(
event.field(field).is_some(),
"an at-ceiling statement must carry {field}, or it tells the operator the \
retry is still running without telling them how long or against what: \
{event}"
);
}
}
let history = flaky.recorded_history(active.handle.workflow_id()).await?;
assert!(
history
.iter()
.any(|event| matches!(event, Event::WorkflowCompleted { .. })),
"the loop must have landed the terminal once the budget drained: {history:#?}"
);
Ok(())
}
#[tokio::test]
async fn a_sequence_conflict_is_reported_not_retried() -> Result<(), Box<dyn std::error::Error>>
{
let (active, flaky) = flaky_workflow().await?;
flaky.fail_next_reads_with_conflict(1);
let error = handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(payload("result")?)),
)
.await
.err()
.ok_or("a conflict must not be reported as handled")?;
let store_error = match &error {
crate::EngineError::Store(store)
| crate::EngineError::Durability(crate::durability::DurabilityError::Store(store)) => {
store
}
other => return Err(format!("expected a store fault, found {other:?}").into()),
};
assert!(
matches!(store_error, aion_store::StoreError::SequenceConflict { .. }),
"the double-writer indicator must reach the caller intact: {store_error:?}"
);
assert_eq!(
active
.context
.runtime
.engine_tasks()
.armed_completion_retry_count(),
0,
"nothing may be left retrying a conflict"
);
Ok(())
}
#[tokio::test]
async fn a_lost_ownership_refusal_is_reported_not_retried()
-> Result<(), Box<dyn std::error::Error>> {
let (active, flaky) = flaky_workflow().await?;
flaky.fail_next_reads_with_lost_ownership(1);
let error = handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(payload("result")?)),
)
.await
.err()
.ok_or("a lost shard must not be reported as handled")?;
let store_error = match &error {
crate::EngineError::Store(store)
| crate::EngineError::Durability(crate::durability::DurabilityError::Store(store)) => {
store
}
other => return Err(format!("expected a store fault, found {other:?}").into()),
};
assert!(
matches!(store_error, aion_store::StoreError::NotOwner { .. }),
"the ownership refusal must reach the caller intact: {store_error:?}"
);
assert_eq!(
active
.context
.runtime
.engine_tasks()
.armed_completion_retry_count(),
0,
"nothing may be left spinning against a shard this node has lost"
);
Ok(())
}
#[tokio::test]
async fn an_attempt_refuses_to_append_once_the_epoch_is_closed()
-> Result<(), Box<dyn std::error::Error>> {
let (active, flaky) = flaky_workflow().await?;
let before = flaky.recorded_history(active.handle.workflow_id()).await?;
active.context.runtime.engine_tasks().begin_close();
let error = handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(payload("result")?)),
)
.await
.err()
.ok_or("an append after the epoch closed must not be reported as handled")?;
assert!(
matches!(error, crate::EngineError::EngineTaskEpochClosed { .. }),
"the refusal must name itself, so an operator is not left reading a store fault \
for an engine-lifetime decision: {error:?}"
);
let after = flaky.recorded_history(active.handle.workflow_id()).await?;
assert_eq!(
before.len(),
after.len(),
"a refused attempt must append NOTHING: {after:#?}"
);
assert_eq!(
active
.context
.runtime
.engine_tasks()
.armed_completion_retry_count(),
0,
"and it must not arm a retry that would re-attempt the same append"
);
Ok(())
}
#[tokio::test]
async fn the_continuation_start_refuses_once_the_epoch_is_closed()
-> Result<(), Box<dyn std::error::Error>> {
let active = active_workflow().await?;
let parent_run_id = active.handle.run_id().clone();
let input = payload("successor-input")?;
{
let recorder = active.handle.recorder();
let mut recorder = recorder.lock().await;
recorder
.record_workflow_continued_as_new(
chrono::Utc::now(),
input.clone(),
None,
parent_run_id.clone(),
)
.await?;
}
let before = active
.context
.store
.read_history(active.handle.workflow_id())
.await?;
assert_eq!(
before
.iter()
.filter(|event| matches!(event, Event::WorkflowStarted { .. }))
.count(),
1,
"fixture: exactly the parent run has started, or the count below proves nothing: \
{before:#?}"
);
active.context.runtime.engine_tasks().begin_close();
let error = super::start_continuation_replacement(
&active.context,
&active.handle,
input,
None,
parent_run_id,
)
.await
.err()
.ok_or("a continuation start after the epoch closed must not be reported as handled")?;
assert!(
matches!(error, crate::EngineError::EngineTaskEpochClosed { .. }),
"the refusal must name itself rather than surface as a store or spawn fault: {error:?}"
);
let after = active
.context
.store
.read_history(active.handle.workflow_id())
.await?;
assert_eq!(
after
.iter()
.filter(|event| matches!(event, Event::WorkflowStarted { .. }))
.count(),
1,
"a refused continuation must not have started a successor run — a second \
`WorkflowStarted` here is a run this dying engine handed to a successor while \
still executing it, which is the double-writer hazard the gate exists to \
prevent: {after:#?}"
);
Ok(())
}
#[tokio::test]
async fn the_resume_arm_refuses_to_append_once_the_epoch_is_closed()
-> Result<(), Box<dyn std::error::Error>> {
let active = active_workflow().await?;
let run_id = active.handle.run_id().clone();
let deadline_id = crate::time::deadline_timer_id(&run_id)?;
let result = payload("result")?;
{
let recorder = active.handle.recorder();
let mut recorder = recorder.lock().await;
recorder
.record_timer_started(chrono::Utc::now(), deadline_id.clone(), chrono::Utc::now())
.await?;
recorder
.record_workflow_completed(chrono::Utc::now(), result.clone())
.await?;
}
let before = active
.context
.store
.read_history(active.handle.workflow_id())
.await?;
assert!(
crate::time::outstanding_deadline_timer(&before, &run_id).is_some(),
"control: the resume arm must have a real append owed to it, or a passing \
refusal proves nothing"
);
active.context.runtime.engine_tasks().begin_close();
let error = handle_process_exit_async(
active.context.clone(),
active.handle.clone(),
Ok(WorkflowProcessOutcome::Completed(result)),
)
.await
.err()
.ok_or("the resume arm must refuse once the epoch has closed, not report success")?;
assert!(
matches!(error, crate::EngineError::EngineTaskEpochClosed { .. }),
"and the refusal must name itself on this arm too: {error:?}"
);
let after = active
.context
.store
.read_history(active.handle.workflow_id())
.await?;
assert_eq!(
before.len(),
after.len(),
"a refused resume must append NOTHING — not even the deadline retirement, which \
is a real durable write into a history a successor engine may already own: \
{after:#?}"
);
assert!(
crate::time::outstanding_deadline_timer(&after, &run_id).is_some(),
"the deadline therefore stays outstanding; the successor's startup sweep retires \
it, which is the mechanism that actually owns this run now"
);
Ok(())
}
#[test]
fn the_fault_key_is_the_typed_variant_not_the_rendered_error() {
let first = crate::EngineError::Store(aion_store::StoreError::Backend(
"peer 10.0.0.7:9042 unreachable after 1.20s".to_owned(),
));
let second = crate::EngineError::Store(aion_store::StoreError::Backend(
"peer 10.0.0.9:9042 unreachable after 4.80s; leader hint node-3".to_owned(),
));
assert_ne!(
first.to_string(),
second.to_string(),
"the rendered strings must differ, or this test cannot tell the two keys apart"
);
assert_eq!(
failure_discriminant(&first),
failure_discriminant(&second),
"one backend fault reported twice with different detail is ONE fault"
);
let other = crate::EngineError::Store(aion_store::StoreError::NotOwner { shard: 3 });
assert_ne!(
failure_discriminant(&first),
failure_discriminant(&other),
"and genuinely different faults must still be distinguishable, or the key is a \
constant and every change is suppressed"
);
}
#[test]
fn an_unchanging_fault_is_restated_on_attempt_count_doubling() {
let mut reporter = FaultReporter::new();
let stated_at: Vec<u64> = (1..=32)
.filter(|attempt| reporter.should_report("store/backend-unavailable", *attempt))
.collect();
assert_eq!(
stated_at,
vec![1, 2, 4, 8, 16, 32],
"an unchanged fault is re-stated on the powers of two and stays quiet between them"
);
}
#[test]
fn a_changed_fault_is_stated_at_once_and_does_not_defer_the_escalation() {
let mut reporter = FaultReporter::new();
assert!(reporter.should_report("store/backend-unavailable", 1));
assert!(
!reporter.should_report("store/backend-unavailable", 2 - 1),
"the same fault at the same attempt says nothing new"
);
assert!(
reporter.should_report("engine/terminal-writer-held", 1),
"a different fault is news the moment it appears"
);
assert!(
reporter.should_report("engine/terminal-writer-held", 2),
"and the escalation point set by attempt 1 still fires at attempt 2"
);
}
#[tokio::test]
async fn a_failure_after_the_terminal_landed_reports_it_as_recorded()
-> Result<(), Box<dyn std::error::Error>> {
let (active, flaky) = flaky_workflow().await?;
let result = payload("result")?;
flaky.fail_reads_after(1, 1);
let intent =
TerminalIntent::from_outcome(Ok(WorkflowProcessOutcome::Completed(result.clone())));
let failure = complete_process_exit(
&active.context,
&active.handle,
&intent,
TerminalProgress::NotRecorded,
)
.await
.err()
.ok_or("the injected fault must have failed the attempt")?;
let recorded = flaky.recorded_history(active.handle.workflow_id()).await?;
assert!(
recorded
.iter()
.any(|event| matches!(event, Event::WorkflowCompleted { .. })),
"control: the terminal must actually be durable, or 'recorded' would be the wrong \
answer and this test would be passing for the wrong reason: {recorded:#?}"
);
match failure {
CompletionFailure::Retryable(_, TerminalProgress::Recorded)
| CompletionFailure::Invariant(_, TerminalProgress::Recorded) => Ok(()),
other => Err(format!(
"a failure raised after a durable terminal must carry Recorded, found {other:?}"
)
.into()),
}
}
}