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::WorkflowDeadlineHandler;
use crate::durability::Recorder;
use crate::registry::{
CompletionNotifier, HandleResidency, Registry, TerminalOutcome, WorkflowHandle,
WorkflowHandleParts,
};
use crate::runtime::{RuntimeConfig, RuntimeHandle};
use crate::time::DeadlineHandler;
type TestResult = Result<(), Box<dyn std::error::Error>>;
struct TimedRun {
handler: WorkflowDeadlineHandler,
store: Arc<dyn EventStore>,
visibility_store: Arc<dyn VisibilityStore>,
registry: Arc<Registry>,
runtime: Arc<RuntimeHandle>,
handle: WorkflowHandle,
}
async fn arm_deadline(run: &TimedRun) -> Result<(), Box<dyn std::error::Error>> {
let deadline_id = crate::time::deadline_timer_id(run.handle.run_id())?;
let recorder = run.handle.recorder();
let mut recorder = recorder.lock().await;
recorder
.record_timer_started(chrono::Utc::now(), deadline_id, chrono::Utc::now())
.await?;
Ok(())
}
async fn timed_run() -> Result<TimedRun, Box<dyn std::error::Error>> {
let backing = Arc::new(InMemoryStore::default());
let store: Arc<dyn EventStore> = Arc::clone(&backing) as Arc<dyn EventStore>;
let visibility_store: Arc<dyn VisibilityStore> = backing;
let runtime = Arc::new(RuntimeHandle::new(RuntimeConfig::new(Some(1)))?);
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: "sleeper".to_owned(),
input: Payload::from_json(&json!({}))?,
run_id: run_id.clone(),
parent_run_id: None,
package_version: aion_core::PackageVersion::new("a".repeat(64)),
},
)
.await?;
let pid = runtime.spawn_test_process_with_trap_exit(true)?;
let handle = WorkflowHandle::new(WorkflowHandleParts {
workflow_id: workflow_id.clone(),
run_id: run_id.clone(),
pid,
workflow_type: "sleeper".to_owned(),
namespace: String::from("default"),
loaded_version: ContentHash::from_bytes([7; 32]),
cached_status: WorkflowStatus::Running,
residency: HandleResidency::Resident,
recorder,
completion: CompletionNotifier::new(),
});
registry.insert((workflow_id, run_id), handle.clone())?;
let handler = WorkflowDeadlineHandler::new(
Arc::downgrade(&runtime),
Arc::clone(&store),
Arc::clone(&visibility_store),
Arc::clone(®istry),
);
Ok(TimedRun {
handler,
store,
visibility_store,
registry,
runtime,
handle,
})
}
#[tokio::test(flavor = "multi_thread")]
async fn deadline_records_timed_out_and_tears_down() -> TestResult {
let run = timed_run().await?;
let workflow_id = run.handle.workflow_id().clone();
let run_id = run.handle.run_id().clone();
arm_deadline(&run).await?;
let mut receiver = run.handle.completion().subscribe();
run.handler
.on_deadline_elapsed(workflow_id.clone(), run_id.clone())
.await?;
receiver.changed().await?;
let history = run.store.read_history(&workflow_id).await?;
assert_eq!(
count_timed_out(&history),
1,
"one timeout terminal: {history:#?}"
);
match history
.iter()
.find(|event| matches!(event, Event::WorkflowTimedOut { .. }))
{
Some(Event::WorkflowTimedOut { timeout, .. }) => assert_eq!(timeout, "workflow"),
_ => return Err("no WorkflowTimedOut recorded".into()),
}
assert_eq!(
aion_core::status_from_events(&history),
WorkflowStatus::TimedOut
);
assert_eq!(
receiver.borrow().clone(),
Some(TerminalOutcome::TimedOut(String::from("workflow")))
);
assert_eq!(run.registry.get(&workflow_id, &run_id)?, None);
run.runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn deadline_loses_to_an_already_recorded_terminal() -> TestResult {
let run = timed_run().await?;
let workflow_id = run.handle.workflow_id().clone();
let run_id = run.handle.run_id().clone();
{
let recorder = run.handle.recorder();
let mut recorder = recorder.lock().await;
recorder
.record_workflow_completed(chrono::Utc::now(), Payload::from_json(&json!("done"))?)
.await?;
}
run.handler
.on_deadline_elapsed(workflow_id.clone(), run_id.clone())
.await?;
let history = run.store.read_history(&workflow_id).await?;
assert!(
!history
.iter()
.any(|event| matches!(event, Event::WorkflowTimedOut { .. })),
"the losing deadline must record no WorkflowTimedOut: {history:#?}"
);
assert_eq!(
aion_core::status_from_events(&history),
WorkflowStatus::Completed,
"the concurrent completion stands"
);
run.runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn deadline_for_deregistered_run_is_a_noop() -> TestResult {
let run = timed_run().await?;
let workflow_id = run.handle.workflow_id().clone();
let run_id = run.handle.run_id().clone();
run.registry.remove(&workflow_id, &run_id)?;
run.handler
.on_deadline_elapsed(workflow_id.clone(), run_id.clone())
.await?;
let history = run.store.read_history(&workflow_id).await?;
assert!(
!history
.iter()
.any(|event| matches!(event, Event::WorkflowTimedOut { .. })),
"a deregistered run's deadline records nothing: {history:#?}"
);
run.runtime.shutdown()?;
Ok(())
}
fn count_timed_out(history: &[Event]) -> usize {
history
.iter()
.filter(|event| matches!(event, Event::WorkflowTimedOut { .. }))
.count()
}
#[tokio::test(flavor = "multi_thread")]
async fn cold_start_finalizes_timed_out_teardown_without_a_registered_handle() -> TestResult {
let backing = Arc::new(InMemoryStore::default());
let store: Arc<dyn EventStore> = Arc::clone(&backing) as Arc<dyn EventStore>;
let visibility_store: Arc<dyn VisibilityStore> = backing;
let runtime = Arc::new(RuntimeHandle::new(RuntimeConfig::new(Some(1)))?);
let registry = Arc::new(Registry::default());
let workflow_id = aion_core::WorkflowId::new_v4();
let run_id = aion_core::RunId::new_v4();
let deadline_id = crate::time::deadline_timer_id(&run_id)?;
let mut recorder = Recorder::new(workflow_id.clone(), Arc::clone(&store));
recorder
.record_workflow_started(
chrono::Utc::now(),
crate::durability::WorkflowStartRecord {
workflow_type: "sleeper".to_owned(),
input: Payload::from_json(&json!({}))?,
run_id: run_id.clone(),
parent_run_id: None,
package_version: aion_core::PackageVersion::new("a".repeat(64)),
},
)
.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?;
let handler = WorkflowDeadlineHandler::new(
Arc::downgrade(&runtime),
Arc::clone(&store),
Arc::clone(&visibility_store),
Arc::clone(®istry),
);
handler
.on_deadline_elapsed(workflow_id.clone(), run_id.clone())
.await?;
handler
.on_deadline_elapsed(workflow_id.clone(), run_id.clone())
.await?;
let history = store.read_history(&workflow_id).await?;
let cancels = history
.iter()
.filter(
|event| matches!(event, Event::TimerCancelled { timer_id, .. } if timer_id == &deadline_id),
)
.count();
assert_eq!(
cancels, 1,
"registry-free teardown retires the deadline exactly once: {history:#?}"
);
assert_eq!(
crate::time::outstanding_deadline_timer(&history, &run_id),
None,
"the deadline is retired"
);
assert_eq!(
count_timed_out(&history),
1,
"no second terminal is recorded"
);
runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn deadline_fire_retires_the_deadline_behind_a_competing_terminal() -> TestResult {
let run = timed_run().await?;
let workflow_id = run.handle.workflow_id().clone();
let run_id = run.handle.run_id().clone();
arm_deadline(&run).await?;
{
let recorder = run.handle.recorder();
let mut recorder = recorder.lock().await;
recorder
.record_workflow_completed(chrono::Utc::now(), Payload::from_json(&json!("done"))?)
.await?;
}
assert!(
crate::time::outstanding_deadline_timer(
&run.store.read_history(&workflow_id).await?,
&run_id
)
.is_some(),
"the deadline is outstanding before the repairing fire"
);
run.handler
.on_deadline_elapsed(workflow_id.clone(), run_id.clone())
.await?;
let history = run.store.read_history(&workflow_id).await?;
assert_eq!(
count_timed_out(&history),
0,
"the losing deadline records no timeout terminal: {history:#?}"
);
assert_eq!(
crate::time::outstanding_deadline_timer(&history, &run_id),
None,
"the fire retires the deadline left by the interrupted completion: {history:#?}"
);
run.runtime.shutdown()?;
Ok(())
}
struct FlakyVisibility {
inner: Arc<dyn VisibilityStore>,
fail: std::sync::atomic::AtomicBool,
}
impl FlakyVisibility {
fn armed(inner: Arc<dyn VisibilityStore>) -> Self {
Self {
inner,
fail: std::sync::atomic::AtomicBool::new(true),
}
}
fn disarm(&self) {
self.fail.store(false, std::sync::atomic::Ordering::SeqCst);
}
}
#[async_trait::async_trait]
impl VisibilityStore for FlakyVisibility {
async fn record_visibility(
&self,
record: aion_store::visibility::VisibilityRecord,
) -> Result<(), aion_store::StoreError> {
if self.fail.load(std::sync::atomic::Ordering::SeqCst) {
return Err(aion_store::StoreError::Backend(
"forced visibility failure during deadline teardown".to_owned(),
));
}
self.inner.record_visibility(record).await
}
async fn list_workflows(
&self,
filter: aion_store::visibility::ListWorkflowsFilter,
) -> Result<Vec<aion_store::visibility::WorkflowSummary>, aion_store::StoreError> {
self.inner.list_workflows(filter).await
}
async fn count_workflows(
&self,
filter: aion_store::visibility::ListWorkflowsFilter,
) -> Result<u64, aion_store::StoreError> {
self.inner.count_workflows(filter).await
}
}
#[tokio::test(flavor = "multi_thread")]
async fn teardown_failure_preserves_retry_anchors_then_resumes() -> TestResult {
let backing = Arc::new(InMemoryStore::default());
let store: Arc<dyn EventStore> = Arc::clone(&backing) as Arc<dyn EventStore>;
let visibility = Arc::new(FlakyVisibility::armed(backing));
let visibility_store: Arc<dyn VisibilityStore> =
Arc::clone(&visibility) as Arc<dyn VisibilityStore>;
let runtime = Arc::new(RuntimeHandle::new(RuntimeConfig::new(Some(1)))?);
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: "sleeper".to_owned(),
input: Payload::from_json(&json!({}))?,
run_id: run_id.clone(),
parent_run_id: None,
package_version: aion_core::PackageVersion::new("a".repeat(64)),
},
)
.await?;
let deadline_id = crate::time::deadline_timer_id(&run_id)?;
recorder
.record_timer_started(chrono::Utc::now(), deadline_id.clone(), chrono::Utc::now())
.await?;
let pid = runtime.spawn_test_process_with_trap_exit(true)?;
let handle = WorkflowHandle::new(WorkflowHandleParts {
workflow_id: workflow_id.clone(),
run_id: run_id.clone(),
pid,
workflow_type: "sleeper".to_owned(),
namespace: String::from("default"),
loaded_version: ContentHash::from_bytes([7; 32]),
cached_status: WorkflowStatus::Running,
residency: HandleResidency::Resident,
recorder,
completion: CompletionNotifier::new(),
});
registry.insert((workflow_id.clone(), run_id.clone()), handle.clone())?;
let handler = WorkflowDeadlineHandler::new(
Arc::downgrade(&runtime),
Arc::clone(&store),
Arc::clone(&visibility_store),
Arc::clone(®istry),
);
let first = handler
.on_deadline_elapsed(workflow_id.clone(), run_id.clone())
.await;
assert!(
first.is_err(),
"an incomplete teardown must be propagated, not swallowed"
);
let history = store.read_history(&workflow_id).await?;
assert_eq!(
count_timed_out(&history),
1,
"the terminal is recorded once"
);
assert!(
crate::time::outstanding_deadline_timer(&history, &run_id).is_some(),
"the deadline stays live as the resume anchor: {history:#?}"
);
assert!(
registry.get(&workflow_id, &run_id)?.is_some(),
"the run stays registered as the second resume anchor"
);
visibility.disarm();
handler
.on_deadline_elapsed(workflow_id.clone(), run_id.clone())
.await?;
let history = store.read_history(&workflow_id).await?;
assert_eq!(
count_timed_out(&history),
1,
"resuming records no second terminal: {history:#?}"
);
assert_eq!(
crate::time::outstanding_deadline_timer(&history, &run_id),
None,
"the resumed teardown finally retires the deadline"
);
assert_eq!(
registry.get(&workflow_id, &run_id)?,
None,
"the resumed teardown deregisters the run"
);
runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn timeout_teardown_retires_the_runs_active_timers() -> TestResult {
let run = timed_run().await?;
let workflow_id = run.handle.workflow_id().clone();
let run_id = run.handle.run_id().clone();
arm_deadline(&run).await?;
let sleep_id = aion_core::TimerId::named("nap")?;
{
let recorder = run.handle.recorder();
let mut recorder = recorder.lock().await;
recorder
.record_timer_started(chrono::Utc::now(), sleep_id.clone(), chrono::Utc::now())
.await?;
}
run.handler
.on_deadline_elapsed(workflow_id.clone(), run_id.clone())
.await?;
let history = run.store.read_history(&workflow_id).await?;
assert_eq!(count_timed_out(&history), 1);
let deadline_id = crate::time::deadline_timer_id(&run_id)?;
assert!(
crate::time::timer_service::live_timers_in_active_segment(&history).is_empty(),
"teardown retires every active-run timer: {history:#?}"
);
let cancelled: Vec<&aion_core::TimerId> = history
.iter()
.filter_map(|event| match event {
Event::TimerCancelled { timer_id, .. } => Some(timer_id),
_ => None,
})
.collect();
assert!(cancelled.contains(&&deadline_id), "the deadline is retired");
assert!(
cancelled.contains(&&sleep_id),
"the parked sleep is retired"
);
assert_eq!(run.registry.get(&workflow_id, &run_id)?, None);
run.runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn retired_deadline_loses_before_any_workflow_terminal() -> TestResult {
let run = timed_run().await?;
let workflow_id = run.handle.workflow_id().clone();
let run_id = run.handle.run_id().clone();
arm_deadline(&run).await?;
let deadline_id = crate::time::deadline_timer_id(&run_id)?;
{
let recorder = run.handle.recorder();
let mut recorder = recorder.lock().await;
recorder
.record_timer_cancelled(
chrono::Utc::now(),
deadline_id,
aion_core::TimerCancelCause::WorkflowIntent,
)
.await?;
}
run.handler
.on_deadline_elapsed(workflow_id.clone(), run_id.clone())
.await?;
let history = run.store.read_history(&workflow_id).await?;
assert_eq!(
count_timed_out(&history),
0,
"a retired deadline never times the run out: {history:#?}"
);
run.runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn re_fire_after_recorded_timeout_resumes_teardown_without_a_second_terminal() -> TestResult {
let run = timed_run().await?;
let workflow_id = run.handle.workflow_id().clone();
let run_id = run.handle.run_id().clone();
arm_deadline(&run).await?;
{
let recorder = run.handle.recorder();
let mut recorder = recorder.lock().await;
recorder
.record_workflow_timed_out(chrono::Utc::now(), String::from("workflow"))
.await?;
}
assert!(run.registry.get(&workflow_id, &run_id)?.is_some());
run.handler
.on_deadline_elapsed(workflow_id.clone(), run_id.clone())
.await?;
let history = run.store.read_history(&workflow_id).await?;
assert_eq!(
count_timed_out(&history),
1,
"resuming teardown records no second WorkflowTimedOut: {history:#?}"
);
assert_eq!(
run.registry.get(&workflow_id, &run_id)?,
None,
"resumed teardown deregisters the run"
);
run.runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn deadline_and_cancel_race_records_exactly_one_terminal() -> TestResult {
for _ in 0..40 {
let run = timed_run().await?;
let workflow_id = run.handle.workflow_id().clone();
let run_id = run.handle.run_id().clone();
arm_deadline(&run).await?;
let handler_wf = workflow_id.clone();
let handler_run = run_id.clone();
let deadline = async {
run.handler
.on_deadline_elapsed(handler_wf, handler_run)
.await
};
let cancel = crate::lifecycle::terminate::cancel(
crate::lifecycle::terminate::TerminateWorkflowContext {
runtime: run.runtime.as_ref(),
store: Arc::clone(&run.store),
visibility_store: Arc::clone(&run.visibility_store),
registry: run.registry.as_ref(),
},
&workflow_id,
&run_id,
"operator cancel",
);
let (deadline_result, cancel_result) = tokio::join!(deadline, cancel);
deadline_result?;
if let Err(error) = cancel_result {
assert!(
matches!(error, crate::EngineError::WorkflowNotFound { .. }),
"cancel lost the race cleanly, got {error:?}"
);
}
let history = run.store.read_history(&workflow_id).await?;
let terminals = history
.iter()
.filter(|event| {
matches!(
event,
Event::WorkflowTimedOut { .. } | Event::WorkflowCancelled { .. }
)
})
.count();
assert_eq!(terminals, 1, "exactly one terminal wins: {history:#?}");
run.runtime.shutdown()?;
}
Ok(())
}