use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use aion_core::{
Event, EventEnvelope, PackageVersion, Payload, RunId, TimerId, WorkflowFilter, WorkflowId,
WorkflowSummary,
};
use aion_store::{
EventStore, InMemoryStore, PackageRecord, PackageRouteRecord, ReadableEventStore, RunSummary,
StoreError, TimerEntry, WritableEventStore, WriteToken,
};
use chrono::{DateTime, Utc};
use super::{
fire_wheel_timer, install_timer_nif_bridge, installed_timer_service, register_deadline_handler,
};
use crate::durability::{Recorder, WorkflowStartRecord};
use crate::registry::Registry;
use crate::runtime::{RuntimeConfig, RuntimeHandle};
use crate::time::{DeadlineHandler, DeadlineHandlerError};
type TestResult = Result<(), Box<dyn std::error::Error>>;
struct FaultableStore {
inner: Arc<InMemoryStore>,
fail_reads: AtomicUsize,
refuse_appends: AtomicUsize,
lose_append_acks: AtomicUsize,
}
impl FaultableStore {
fn new(inner: Arc<InMemoryStore>) -> Self {
Self {
inner,
fail_reads: AtomicUsize::new(0),
refuse_appends: AtomicUsize::new(0),
lose_append_acks: AtomicUsize::new(0),
}
}
fn fail_next_reads(&self, count: usize) {
self.fail_reads.store(count, Ordering::SeqCst);
}
fn refuse_next_appends(&self, count: usize) {
self.refuse_appends.store(count, Ordering::SeqCst);
}
fn lose_next_append_acks(&self, count: usize) {
self.lose_append_acks.store(count, Ordering::SeqCst);
}
fn take_fault(counter: &AtomicUsize) -> bool {
let mut current = counter.load(Ordering::SeqCst);
loop {
if current == 0 {
return false;
}
match counter.compare_exchange(current, current - 1, Ordering::SeqCst, Ordering::SeqCst)
{
Ok(_) => return true,
Err(actual) => current = actual,
}
}
}
}
#[async_trait::async_trait]
impl ReadableEventStore for FaultableStore {
async fn read_history(&self, workflow_id: &WorkflowId) -> Result<Vec<Event>, StoreError> {
if Self::take_fault(&self.fail_reads) {
return Err(StoreError::Backend(
"simulated store outage during read_history".to_owned(),
));
}
self.inner.read_history(workflow_id).await
}
async fn read_history_from(
&self,
workflow_id: &WorkflowId,
from_seq: u64,
) -> Result<Vec<Event>, StoreError> {
self.inner.read_history_from(workflow_id, from_seq).await
}
async fn read_run_chain(
&self,
workflow_id: &WorkflowId,
) -> Result<Vec<RunSummary>, StoreError> {
self.inner.read_run_chain(workflow_id).await
}
async fn list_workflow_ids(&self) -> Result<Vec<WorkflowId>, StoreError> {
self.inner.list_workflow_ids().await
}
async fn list_active(&self) -> Result<Vec<WorkflowId>, StoreError> {
self.inner.list_active().await
}
async fn list_paused(&self) -> Result<Vec<WorkflowId>, StoreError> {
self.inner.list_paused().await
}
async fn stream_heads(&self) -> Result<Vec<aion_store::visibility::StreamHead>, StoreError> {
self.inner.stream_heads().await
}
async fn query(&self, filter: &WorkflowFilter) -> Result<Vec<WorkflowSummary>, StoreError> {
self.inner.query(filter).await
}
async fn schedule_timer(
&self,
workflow_id: &WorkflowId,
timer_id: &TimerId,
fire_at: DateTime<Utc>,
armed_seq: u64,
) -> Result<(), StoreError> {
self.inner
.schedule_timer(workflow_id, timer_id, fire_at, armed_seq)
.await
}
async fn retire_timer(
&self,
workflow_id: &WorkflowId,
timer_id: &TimerId,
fire_at: DateTime<Utc>,
armed_seq: u64,
) -> Result<aion_store::TimerRetirement, StoreError> {
self.inner
.retire_timer(workflow_id, timer_id, fire_at, armed_seq)
.await
}
async fn expired_timers(&self, as_of: DateTime<Utc>) -> Result<Vec<TimerEntry>, StoreError> {
self.inner.expired_timers(as_of).await
}
}
#[async_trait::async_trait]
impl WritableEventStore for FaultableStore {
async fn append(
&self,
token: WriteToken,
workflow_id: &WorkflowId,
events: &[Event],
expected_seq: u64,
) -> Result<(), StoreError> {
if Self::take_fault(&self.refuse_appends) {
return Err(StoreError::Backend(
"simulated store outage: append refused before any write landed".to_owned(),
));
}
if Self::take_fault(&self.lose_append_acks) {
self.inner
.append(token, workflow_id, events, expected_seq)
.await?;
return Err(StoreError::Backend(
"simulated acknowledgement loss: the append landed but the caller's ack timed out"
.to_owned(),
));
}
self.inner
.append(token, workflow_id, events, expected_seq)
.await
}
}
#[async_trait::async_trait]
impl aion_store::PackageStore for FaultableStore {
async fn put_package(&self, record: PackageRecord) -> Result<(), StoreError> {
self.inner.put_package(record).await
}
async fn put_package_with_routes(
&self,
record: PackageRecord,
route_workflow_types: &[String],
) -> Result<(), StoreError> {
self.inner
.put_package_with_routes(record, route_workflow_types)
.await
}
async fn list_packages(&self) -> Result<Vec<PackageRecord>, StoreError> {
self.inner.list_packages().await
}
async fn delete_package(
&self,
workflow_type: &str,
content_hash: &str,
) -> Result<(), StoreError> {
self.inner.delete_package(workflow_type, content_hash).await
}
async fn put_package_route(
&self,
workflow_type: &str,
content_hash: &str,
) -> Result<(), StoreError> {
self.inner
.put_package_route(workflow_type, content_hash)
.await
}
async fn list_package_routes(&self) -> Result<Vec<PackageRouteRecord>, StoreError> {
self.inner.list_package_routes().await
}
}
#[derive(Default)]
struct RecordingDeadlineHandler {
calls: AtomicUsize,
}
impl RecordingDeadlineHandler {
fn call_count(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
#[async_trait::async_trait]
impl DeadlineHandler for RecordingDeadlineHandler {
async fn on_deadline_elapsed(
&self,
_workflow_id: WorkflowId,
_run_id: RunId,
) -> Result<(), DeadlineHandlerError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
#[tokio::test(flavor = "multi_thread")]
async fn deadline_fire_retries_through_a_store_outage_spanning_both_reads() -> TestResult {
let runtime = Arc::new(RuntimeHandle::new(RuntimeConfig::new(
Some(1),
crate::runtime::config::TEST_STOP_DRAIN_TIMEOUT,
))?);
let inner = Arc::new(InMemoryStore::default());
let workflow_id = WorkflowId::new_v4();
let run_id = RunId::new_v4();
let deadline_id = crate::time::deadline_timer_id(&run_id)?;
let event_store: Arc<dyn EventStore> = Arc::clone(&inner) as Arc<dyn EventStore>;
let mut recorder = Recorder::new(workflow_id.clone(), event_store);
recorder
.record_workflow_started(
Utc::now(),
WorkflowStartRecord {
workflow_type: "sleeper".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.clone(), Utc::now())
.await?;
let flaky = Arc::new(FaultableStore::new(inner));
let registry = Arc::new(Registry::default());
install_timer_nif_bridge(
runtime.nif_state(),
Arc::clone(®istry),
Arc::clone(&flaky) as Arc<dyn EventStore>,
tokio::runtime::Handle::current(),
runtime.signal_delivery(),
);
let handler = Arc::new(RecordingDeadlineHandler::default());
register_deadline_handler(runtime.nif_state(), |_| {
Arc::clone(&handler) as Arc<dyn DeadlineHandler>
})
.map_err(|error| format!("failed to register deadline handler: {error}"))?;
flaky.fail_next_reads(2);
fire_wheel_timer(
&Arc::downgrade(runtime.nif_state()),
&workflow_id,
&deadline_id,
Utc::now(),
)
.await;
assert!(
handler.call_count() >= 1,
"a later retry attempt drove the deadline fire to the handler after the double-read outage"
);
runtime.shutdown()?;
Ok(())
}
struct OrdinaryFireFixture {
runtime: Arc<crate::runtime::RuntimeHandle>,
inner: Arc<InMemoryStore>,
faulted: Arc<FaultableStore>,
registry: Arc<Registry>,
workflow_id: WorkflowId,
run_id: RunId,
timer_id: TimerId,
}
impl OrdinaryFireFixture {
async fn new() -> Result<Self, Box<dyn std::error::Error>> {
let runtime = Arc::new(RuntimeHandle::new(RuntimeConfig::new(
Some(1),
crate::runtime::config::TEST_STOP_DRAIN_TIMEOUT,
))?);
let inner = Arc::new(InMemoryStore::default());
let faulted = Arc::new(FaultableStore::new(Arc::clone(&inner)));
let registry = Arc::new(Registry::default());
let pid = runtime.spawn_test_process()?;
let store: Arc<dyn EventStore> = Arc::clone(&faulted) as Arc<dyn EventStore>;
let (workflow_id, run_id) = seed_resident_workflow(®istry, &store, pid).await?;
install_timer_nif_bridge(
runtime.nif_state(),
Arc::clone(®istry),
Arc::clone(&faulted) as Arc<dyn EventStore>,
tokio::runtime::Handle::current(),
runtime.signal_delivery(),
);
crate::runtime::install_nif_runtime_context(
runtime.nif_state(),
Arc::clone(®istry),
Arc::clone(&runtime),
tokio::runtime::Handle::current(),
);
let timer_id = TimerId::named("ordinary-loop")?;
let handle = registry
.get(&workflow_id, &run_id)?
.ok_or("the seeded workflow must be registered")?;
let recorder = handle.recorder();
recorder
.lock()
.await
.record_timer_started(Utc::now(), timer_id.clone(), Utc::now())
.await?;
Ok(Self {
runtime,
inner,
faulted,
registry,
workflow_id,
run_id,
timer_id,
})
}
async fn fire_through_ladder(&self) {
fire_wheel_timer(
&Arc::downgrade(self.runtime.nif_state()),
&self.workflow_id,
&self.timer_id,
Utc::now(),
)
.await;
}
async fn fired_count(&self) -> Result<usize, Box<dyn std::error::Error>> {
Ok(self
.inner
.read_history(&self.workflow_id)
.await?
.iter()
.filter(|event| {
matches!(event, Event::TimerFired { timer_id, .. } if timer_id == &self.timer_id)
})
.count())
}
async fn assert_recorder_healthy_at(
&self,
expected_head: u64,
) -> Result<(), Box<dyn std::error::Error>> {
let handle = self
.registry
.get(&self.workflow_id, &self.run_id)?
.ok_or("the workflow handle must still be registered")?;
let recorder = handle.recorder();
let mut recorder = recorder.lock().await;
assert_eq!(
recorder.current_head(),
expected_head,
"the recorder's tracked head must match the durable head"
);
recorder
.record_signal_received(
Utc::now(),
"after-the-incident".to_owned(),
Payload::from_json(&serde_json::json!({}))?,
)
.await
.map_err(|error| {
format!(
"the workflow's next append through the same recorder must succeed, got: {error}"
)
})?;
let history = self.inner.read_history(&self.workflow_id).await?;
let head = history
.last()
.ok_or("the history cannot be empty after a successful append")?;
assert!(
matches!(head, Event::SignalReceived { .. }),
"the next append must be the event just recorded: {head:?}"
);
assert_eq!(
head.seq(),
expected_head + 1,
"the next append lands at the correct sequence"
);
Ok(())
}
}
#[tokio::test(flavor = "multi_thread")]
async fn an_ack_lost_ordinary_fire_is_retried_reconciled_and_the_run_keeps_appending() -> TestResult
{
let fixture = OrdinaryFireFixture::new().await?;
fixture.faulted.lose_next_append_acks(1);
fixture.fire_through_ladder().await;
assert_eq!(
fixture.fired_count().await?,
1,
"exactly one durable TimerFired: the landed-unacknowledged append, never a duplicate"
);
let history = fixture.inner.read_history(&fixture.workflow_id).await?;
assert_eq!(
history.len(),
3,
"WorkflowStarted, TimerStarted, TimerFired and nothing else: {history:#?}"
);
fixture.assert_recorder_healthy_at(3).await?;
fixture.runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn a_transiently_refused_ordinary_fire_is_retried_to_success() -> TestResult {
let fixture = OrdinaryFireFixture::new().await?;
fixture.faulted.refuse_next_appends(1);
fixture.fire_through_ladder().await;
assert_eq!(
fixture.fired_count().await?,
1,
"the retry must record the fire exactly once after the refused first attempt"
);
fixture.assert_recorder_healthy_at(3).await?;
fixture.runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn a_bridge_redelivery_for_a_recorded_fire_reconciles_and_appends_nothing() -> TestResult {
let fixture = OrdinaryFireFixture::new().await?;
let fire_at = Utc::now();
let fired = Event::TimerFired {
envelope: EventEnvelope {
seq: 3,
recorded_at: Utc::now(),
workflow_id: fixture.workflow_id.clone(),
},
timer_id: fixture.timer_id.clone(),
};
fixture
.inner
.append(WriteToken::recorder(), &fixture.workflow_id, &[fired], 2)
.await?;
fixture
.inner
.schedule_timer(&fixture.workflow_id, &fixture.timer_id, fire_at, 2)
.await?;
let service = installed_timer_service(fixture.runtime.nif_state())?;
let (wake_delivered, _row) = service
.redeliver_owed_wake(
fixture.workflow_id.clone(),
fixture.timer_id.clone(),
fire_at,
2,
)
.await?;
assert!(wake_delivered, "the recorded fire still owes its live wake");
assert_eq!(
fixture.fired_count().await?,
1,
"a redelivery appends nothing — the landed fire stays the only record"
);
assert!(
fixture
.inner
.expired_timers(fire_at + chrono::Duration::seconds(1))
.await?
.is_empty(),
"the redelivered arming's row retires behind the wake"
);
fixture.assert_recorder_healthy_at(3).await?;
fixture.runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn a_bridge_redelivery_for_a_rearmed_timer_is_not_owed_and_mints_no_fire() -> TestResult {
let fixture = OrdinaryFireFixture::new().await?;
let consumed_fire_at = Utc::now();
let rearmed_fire_at = consumed_fire_at + chrono::Duration::seconds(300);
fixture.fire_through_ladder().await;
fixture.assert_recorder_healthy_at(3).await?;
let rearmed_seq = {
let handle = fixture
.registry
.get(&fixture.workflow_id, &fixture.run_id)?
.ok_or("the workflow handle must still be registered")?;
let recorder = handle.recorder();
recorder
.lock()
.await
.record_timer_started(Utc::now(), fixture.timer_id.clone(), rearmed_fire_at)
.await?
};
fixture
.inner
.schedule_timer(
&fixture.workflow_id,
&fixture.timer_id,
rearmed_fire_at,
rearmed_seq,
)
.await?;
let service = installed_timer_service(fixture.runtime.nif_state())?;
let (wake_delivered, _row) = service
.redeliver_owed_wake(
fixture.workflow_id.clone(),
fixture.timer_id.clone(),
consumed_fire_at,
2,
)
.await?;
assert!(
!wake_delivered,
"a re-armed timer's stale redelivery owes nothing"
);
assert_eq!(
fixture.fired_count().await?,
1,
"the redelivery must NOT mint a premature TimerFired for the re-armed timer"
);
let surviving = fixture
.inner
.expired_timers(rearmed_fire_at + chrono::Duration::seconds(1))
.await?;
assert_eq!(
surviving,
vec![aion_store::TimerEntry {
workflow_id: fixture.workflow_id.clone(),
timer_id: fixture.timer_id.clone(),
fire_at: rearmed_fire_at,
armed_seq: rearmed_seq,
}],
"the NEW arming's row survives; only the consumed arming's row retired"
);
fixture.runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn a_rogue_head_event_does_not_reconcile_and_the_conflict_surfaces() -> TestResult {
use crate::engine_seam::EngineHandle;
let fixture = OrdinaryFireFixture::new().await?;
let rogue_id = TimerId::named("rogue-timer")?;
fixture
.inner
.append(
WriteToken::recorder(),
&fixture.workflow_id,
&[Event::TimerFired {
envelope: aion_core::EventEnvelope {
seq: 3,
recorded_at: Utc::now(),
workflow_id: fixture.workflow_id.clone(),
},
timer_id: rogue_id,
}],
2,
)
.await?;
let bridge = super::timer_bridge(fixture.runtime.nif_state())
.map_err(|error| format!("the timer bridge must be installed: {error}"))?;
let refused = bridge.record_workflow_event(
&fixture.workflow_id,
Event::TimerFired {
envelope: aion_core::EventEnvelope {
seq: 3,
recorded_at: Utc::now(),
workflow_id: fixture.workflow_id.clone(),
},
timer_id: fixture.timer_id.clone(),
},
);
let Err(error) = refused else {
return Err(
"a rogue head event must NOT be taken for our own landed append: the fire for OUR \
timer must surface the sequence conflict, not reconcile over a second writer"
.into(),
);
};
assert!(
error.to_string().contains("sequence conflict"),
"the double-writer indicator must surface as itself: {error}"
);
let handle = fixture
.registry
.get(&fixture.workflow_id, &fixture.run_id)?
.ok_or("the workflow handle must still be registered")?;
let recorder = handle.recorder();
assert_eq!(
recorder.lock().await.current_head(),
2,
"the conflict must leave the recorder's tracked head un-resynced"
);
assert_eq!(
fixture
.inner
.read_history(&fixture.workflow_id)
.await?
.len(),
3,
"the refused append must not have grown the history"
);
fixture.runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn a_wheel_teardown_during_arming_leaves_no_armed_timer() -> TestResult {
use crate::engine_seam::{EngineHandle, TimerWheelEntry};
let runtime = Arc::new(RuntimeHandle::new(RuntimeConfig::new(
Some(1),
crate::runtime::config::TEST_STOP_DRAIN_TIMEOUT,
))?);
let store = Arc::new(InMemoryStore::default());
let registry = Arc::new(Registry::default());
let pid = 4242;
let workflow_id = seed_running_workflow(®istry, &store, pid).await?;
install_timer_nif_bridge(
runtime.nif_state(),
Arc::clone(®istry),
Arc::clone(&store) as Arc<dyn EventStore>,
tokio::runtime::Handle::current(),
runtime.signal_delivery(),
);
let bridge = super::timer_bridge(runtime.nif_state())
.map_err(|error| format!("the timer bridge must be installed: {error}"))?;
let process = crate::engine_seam::WorkflowProcessHandle::new(pid);
let entry = |name: &str| -> Result<TimerWheelEntry, Box<dyn std::error::Error>> {
Ok(TimerWheelEntry {
process,
timer_id: TimerId::named(name)?,
fire_at: Utc::now() + chrono::Duration::hours(1),
})
};
bridge
.arm_timer(entry("control-arm")?)
.map_err(|error| format!("control: the fixture must be able to arm at all: {error}"))?;
assert_eq!(
bridge.armed_wheel_timers(),
1,
"control: the fixture must reach the real arming path"
);
bridge.disarm_timer(process, &TimerId::named("control-arm")?)?;
assert_eq!(bridge.armed_wheel_timers(), 0, "control: disarm cleaned up");
let torn = Arc::clone(&bridge);
bridge.set_arm_interleave(Arc::new(move || torn.shutdown_timer_wheel()));
let refused = bridge.arm_timer(entry("armed-into-a-teardown")?);
let Err(error) = refused else {
return Err(
"a wheel teardown that lands while `arm_timer` is between its gate and its \
insert must make the arm REFUSE: the task it spawned appends a durable \
`TimerFired` for a run this process may no longer own, which is a second \
writer for one workflow"
.into(),
);
};
assert!(
error.to_string().contains("torn down"),
"the refusal must name the cause, or an operator is sent looking for a missing workflow \
or a bad timer id: {error}"
);
assert_eq!(
bridge.armed_wheel_timers(),
0,
"the retracted arm must leave NOTHING in the wheel: an entry inserted into an \
already-drained map is a live durable writer with no owner left to abort it"
);
assert_eq!(
store.read_history(&workflow_id).await?.len(),
1,
"a refused arm must not have recorded anything"
);
runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn a_torn_down_wheel_refuses_to_append() -> TestResult {
use crate::engine_seam::EngineHandle;
let runtime = Arc::new(RuntimeHandle::new(RuntimeConfig::new(
Some(1),
crate::runtime::config::TEST_STOP_DRAIN_TIMEOUT,
))?);
let store = Arc::new(InMemoryStore::default());
let registry = Arc::new(Registry::default());
let workflow_id = seed_running_workflow(®istry, &store, 7373).await?;
install_timer_nif_bridge(
runtime.nif_state(),
Arc::clone(®istry),
Arc::clone(&store) as Arc<dyn EventStore>,
tokio::runtime::Handle::current(),
runtime.signal_delivery(),
);
let bridge = super::timer_bridge(runtime.nif_state())
.map_err(|error| format!("the timer bridge must be installed: {error}"))?;
let fired = |seq: u64| -> Result<Event, Box<dyn std::error::Error>> {
Ok(Event::TimerFired {
envelope: aion_core::EventEnvelope {
seq,
recorded_at: Utc::now(),
workflow_id: workflow_id.clone(),
},
timer_id: TimerId::named("late-fire")?,
})
};
let outcome = bridge.record_workflow_event(&workflow_id, fired(2)?)?;
assert!(
matches!(outcome, crate::engine_seam::RecordOutcome::Recorded),
"control: the bridge must be able to record at all, or the refusal below measures nothing"
);
let after_control = store.read_history(&workflow_id).await?.len();
assert_eq!(after_control, 2, "control: the fire landed in history");
bridge.shutdown_timer_wheel();
let refused = bridge.record_workflow_event(&workflow_id, fired(3)?);
let Err(error) = refused else {
return Err(
"a torn-down wheel must REFUSE its append: `abort` cannot stop a task already \
inside a poll, so without a boundary check a released engine still writes"
.into(),
);
};
assert!(
error.to_string().contains("torn down"),
"the refusal must name the cause: {error}"
);
assert!(
!error.to_string().contains("was not armed"),
"the append refusal must not report an arming failure for a timer that fired: {error}"
);
assert!(
error
.to_string()
.contains("fired, but its append was refused"),
"the append refusal must say what actually happened: {error}"
);
assert_eq!(
store.read_history(&workflow_id).await?.len(),
after_control,
"the refused append must not have grown the history"
);
runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn a_refused_cancel_is_not_reported_as_a_refused_fire() -> TestResult {
use aion_core::TimerCancelCause;
use crate::engine_seam::EngineHandle;
let runtime = Arc::new(RuntimeHandle::new(RuntimeConfig::new(
Some(1),
crate::runtime::config::TEST_STOP_DRAIN_TIMEOUT,
))?);
let store = Arc::new(InMemoryStore::default());
let registry = Arc::new(Registry::default());
let workflow_id = seed_running_workflow(®istry, &store, 7575).await?;
install_timer_nif_bridge(
runtime.nif_state(),
Arc::clone(®istry),
Arc::clone(&store) as Arc<dyn EventStore>,
tokio::runtime::Handle::current(),
runtime.signal_delivery(),
);
let bridge = super::timer_bridge(runtime.nif_state())
.map_err(|error| format!("the timer bridge must be installed: {error}"))?;
let cancelled = |seq: u64| -> Result<Event, Box<dyn std::error::Error>> {
Ok(Event::TimerCancelled {
envelope: aion_core::EventEnvelope {
seq,
recorded_at: Utc::now(),
workflow_id: workflow_id.clone(),
},
timer_id: TimerId::named("late-cancel")?,
cause: TimerCancelCause::WorkflowIntent,
})
};
let outcome = bridge.record_workflow_event(&workflow_id, cancelled(2)?)?;
assert!(
matches!(outcome, crate::engine_seam::RecordOutcome::Recorded),
"control: the bridge must be able to record a cancellation at all, or the refusal \
below measures nothing"
);
bridge.shutdown_timer_wheel();
let refused = bridge.record_workflow_event(&workflow_id, cancelled(3)?);
let Err(error) = refused else {
return Err("a torn-down wheel must REFUSE a cancellation append too".into());
};
let message = error.to_string();
assert!(
message.contains("torn down"),
"the refusal must name the cause: {error}"
);
assert!(
!message.contains("fired"),
"a refused cancellation must not be reported as a refused fire: {error}"
);
assert!(
message.contains("cancellation of timer"),
"the refusal must say that a CANCELLATION was what went unrecorded: {error}"
);
assert!(
message.contains("reissues it there"),
"the refusal must say how the run's intent is actually restored, rather than \
ending on the re-arm as though it were good news: {error}"
);
runtime.shutdown()?;
Ok(())
}
#[tokio::test]
async fn a_refused_teardown_cancel_does_not_promise_a_reissue() -> TestResult {
use aion_core::TimerCancelCause;
use crate::engine_seam::EngineHandle;
let runtime = Arc::new(RuntimeHandle::new(RuntimeConfig::new(
Some(1),
crate::runtime::config::TEST_STOP_DRAIN_TIMEOUT,
))?);
let store = Arc::new(InMemoryStore::default());
let registry = Arc::new(Registry::default());
let workflow_id = seed_running_workflow(®istry, &store, 7576).await?;
install_timer_nif_bridge(
runtime.nif_state(),
Arc::clone(®istry),
Arc::clone(&store) as Arc<dyn EventStore>,
tokio::runtime::Handle::current(),
runtime.signal_delivery(),
);
let bridge = super::timer_bridge(runtime.nif_state())
.map_err(|error| format!("the timer bridge must be installed: {error}"))?;
let teardown_cancelled = |seq: u64| -> Result<Event, Box<dyn std::error::Error>> {
Ok(Event::TimerCancelled {
envelope: aion_core::EventEnvelope {
seq,
recorded_at: Utc::now(),
workflow_id: workflow_id.clone(),
},
timer_id: TimerId::named("teardown-cancel")?,
cause: TimerCancelCause::CancelTeardown,
})
};
let outcome = bridge.record_workflow_event(&workflow_id, teardown_cancelled(2)?)?;
assert!(
matches!(outcome, crate::engine_seam::RecordOutcome::Recorded),
"control: the bridge must be able to record a teardown cancellation at all, or the \
refusal below measures nothing"
);
bridge.shutdown_timer_wheel();
let refused = bridge.record_workflow_event(&workflow_id, teardown_cancelled(3)?);
let Err(error) = refused else {
return Err("a torn-down wheel must REFUSE a teardown cancellation append".into());
};
let message = error.to_string();
assert!(
message.contains("torn down"),
"the refusal must name the cause: {error}"
);
assert!(
!message.contains("fired"),
"a refused cancellation must not be reported as a refused fire: {error}"
);
assert!(
!message.contains("reissues it there"),
"a teardown cancel's run is terminal and never re-executes, so the refusal must not \
tell the operator to wait for the run to reissue it: {error}"
);
assert!(
message.contains("once that run's terminal lands"),
"the refusal must state the terminal as the condition it depends on rather than as a \
fact already in hand: {error}"
);
assert!(
message.contains("post-terminal"),
"the refusal must say what actually becomes of the uncancelled timer — the owning \
engine's fire is refused as post-terminal, recording nothing: {error}"
);
assert!(
message.contains("check the run's status"),
"the refusal must tell the operator what to do when the cancel that issued it also \
failed, because then the run is still live and this timer will fire: {error}"
);
runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn stand_down_is_not_a_fault() -> TestResult {
use crate::runtime::nif_timer_fire::is_wheel_teardown;
use crate::time::TimerServiceError;
let runtime = Arc::new(RuntimeHandle::new(RuntimeConfig::new(
Some(1),
crate::runtime::config::TEST_STOP_DRAIN_TIMEOUT,
))?);
let store = Arc::new(InMemoryStore::default());
let registry = Arc::new(Registry::default());
let workflow_id = seed_running_workflow(®istry, &store, 7474).await?;
install_timer_nif_bridge(
runtime.nif_state(),
Arc::clone(®istry),
Arc::clone(&store) as Arc<dyn EventStore>,
tokio::runtime::Handle::current(),
runtime.signal_delivery(),
);
let bridge = super::timer_bridge(runtime.nif_state())
.map_err(|error| format!("the timer bridge must be installed: {error}"))?;
let timer_id = TimerId::named("stand-down")?;
let fire_at = Utc::now();
store
.append(
WriteToken::recorder(),
&workflow_id,
&[Event::TimerStarted {
envelope: aion_core::EventEnvelope {
seq: 2,
recorded_at: fire_at,
workflow_id: workflow_id.clone(),
},
timer_id: timer_id.clone(),
fire_at,
}],
1,
)
.await?;
bridge.shutdown_timer_wheel();
let refused = bridge
.service()
.fire_timer(workflow_id.clone(), timer_id.clone(), fire_at)
.await;
let Err(error) = refused else {
return Err("a torn-down wheel must refuse the fire, or this measures nothing".into());
};
assert!(
is_wheel_teardown(&error),
"a wheel teardown must be recognised as a stand-down, or the deadline ladder \
retries it six times and then logs an error for an orderly shutdown: {error}"
);
let recorder_failure =
TimerServiceError::Engine(crate::engine_seam::EngineSeamError::Recorder {
reason: String::from("the store refused the append"),
});
assert!(
!is_wheel_teardown(&recorder_failure),
"a recorder failure is a fault worth retrying and must not be read as a stand-down"
);
runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn a_torn_down_wheel_does_not_time_a_run_out() -> TestResult {
let runtime = Arc::new(RuntimeHandle::new(RuntimeConfig::new(
Some(1),
crate::runtime::config::TEST_STOP_DRAIN_TIMEOUT,
))?);
let backing = Arc::new(InMemoryStore::default());
let store: Arc<dyn EventStore> = Arc::clone(&backing) as Arc<dyn EventStore>;
let registry = Arc::new(Registry::default());
install_timer_nif_bridge(
runtime.nif_state(),
Arc::clone(®istry),
Arc::clone(&store),
tokio::runtime::Handle::current(),
runtime.signal_delivery(),
);
register_deadline_handler(runtime.nif_state(), |stand_down| {
Arc::new(crate::lifecycle::deadline::WorkflowDeadlineHandler::new(
Arc::downgrade(&runtime),
Arc::clone(&store),
Arc::clone(&backing) as Arc<dyn aion_store::visibility::VisibilityStore>,
Arc::clone(®istry),
stand_down,
)) as Arc<dyn DeadlineHandler>
})
.map_err(|error| format!("failed to register the deadline handler: {error}"))?;
let bridge = super::timer_bridge(runtime.nif_state())
.map_err(|error| format!("the timer bridge must be installed: {error}"))?;
let (control_id, control_deadline) =
seed_armed_deadline(®istry, &backing, 8181, Utc::now()).await?;
bridge
.service()
.fire_timer(control_id.clone(), control_deadline, Utc::now())
.await?;
assert!(
timed_out(&store.read_history(&control_id).await?),
"control: an armed deadline on a live wheel must record WorkflowTimedOut, or the \
treatment below proves nothing"
);
let (timed_id, timed_deadline) =
seed_armed_deadline(®istry, &backing, 8282, Utc::now()).await?;
let before = store.read_history(&timed_id).await?.len();
bridge.shutdown_timer_wheel();
bridge
.service()
.fire_timer(timed_id.clone(), timed_deadline, Utc::now())
.await?;
let history = store.read_history(&timed_id).await?;
assert!(
!timed_out(&history),
"a torn-down wheel must NOT record WorkflowTimedOut: the run belongs to whichever \
engine owns it now, and a second writer for one workflow is the #119 breach: \
{history:#?}"
);
assert_eq!(
history.len(),
before,
"the stood-down deadline must append nothing at all — not the terminal, not the \
ordinary-timer retirements, not the deadline's own cancellation"
);
runtime.shutdown()?;
Ok(())
}
#[tokio::test(flavor = "multi_thread")]
async fn a_torn_down_wheel_does_not_finalize_an_unregistered_timeout() -> TestResult {
let runtime = Arc::new(RuntimeHandle::new(RuntimeConfig::new(
Some(1),
crate::runtime::config::TEST_STOP_DRAIN_TIMEOUT,
))?);
let backing = Arc::new(InMemoryStore::default());
let store: Arc<dyn EventStore> = Arc::clone(&backing) as Arc<dyn EventStore>;
let registry = Arc::new(Registry::default());
install_timer_nif_bridge(
runtime.nif_state(),
Arc::clone(®istry),
Arc::clone(&store),
tokio::runtime::Handle::current(),
runtime.signal_delivery(),
);
register_deadline_handler(runtime.nif_state(), |stand_down| {
Arc::new(crate::lifecycle::deadline::WorkflowDeadlineHandler::new(
Arc::downgrade(&runtime),
Arc::clone(&store),
Arc::clone(&backing) as Arc<dyn aion_store::visibility::VisibilityStore>,
Arc::clone(®istry),
stand_down,
)) as Arc<dyn DeadlineHandler>
})
.map_err(|error| format!("failed to register the deadline handler: {error}"))?;
let bridge = super::timer_bridge(runtime.nif_state())
.map_err(|error| format!("the timer bridge must be installed: {error}"))?;
let (control_id, control_deadline) = seed_unfinished_timeout(&backing).await?;
bridge
.service()
.fire_timer(control_id.clone(), control_deadline.clone(), Utc::now())
.await?;
assert!(
cancelled(&store.read_history(&control_id).await?, &control_deadline),
"control: the registry-free finalizer must retire the outstanding deadline, or the \
treatment below measures a path that never ran"
);
let (stale_id, stale_deadline) = seed_unfinished_timeout(&backing).await?;
let before = store.read_history(&stale_id).await?.len();
bridge.shutdown_timer_wheel();
bridge
.service()
.fire_timer(stale_id.clone(), stale_deadline.clone(), Utc::now())
.await?;
let history = store.read_history(&stale_id).await?;
assert!(
!cancelled(&history, &stale_deadline),
"a torn-down wheel must not finalize an unregistered timeout: every append here is \
durable and the run may already be owned elsewhere: {history:#?}"
);
assert_eq!(
history.len(),
before,
"the stood-down finalizer must append nothing at all"
);
runtime.shutdown()?;
Ok(())
}
async fn seed_unfinished_timeout(
store: &Arc<InMemoryStore>,
) -> Result<(WorkflowId, TimerId), 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.clone(), Arc::clone(store) as _);
recorder
.record_workflow_started(
Utc::now(),
WorkflowStartRecord {
workflow_type: "sleeper".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.clone(), Utc::now())
.await?;
recorder
.record_workflow_timed_out(Utc::now(), String::from("workflow"))
.await?;
Ok((workflow_id, deadline_id))
}
fn cancelled(history: &[Event], timer_id: &TimerId) -> bool {
history
.iter()
.any(|event| matches!(event, Event::TimerCancelled { timer_id: id, .. } if id == timer_id))
}
fn timed_out(history: &[Event]) -> bool {
history
.iter()
.any(|event| matches!(event, Event::WorkflowTimedOut { .. }))
}
async fn seed_armed_deadline(
registry: &Registry,
store: &Arc<InMemoryStore>,
pid: u64,
fire_at: DateTime<Utc>,
) -> Result<(WorkflowId, TimerId), Box<dyn std::error::Error>> {
let workflow_id = seed_running_workflow(registry, store, pid).await?;
let history = store.read_history(&workflow_id).await?;
let run_id = history
.iter()
.find_map(|event| match event {
Event::WorkflowStarted { run_id, .. } => Some(run_id.clone()),
_ => None,
})
.ok_or("the seeded workflow must have a WorkflowStarted")?;
let deadline_id = crate::time::deadline_timer_id(&run_id)?;
let handle = registry
.get(&workflow_id, &run_id)?
.ok_or("the seeded workflow must be registered")?;
let recorder = handle.recorder();
let mut recorder = recorder.lock().await;
recorder
.record_timer_started(fire_at, deadline_id.clone(), fire_at)
.await?;
Ok((workflow_id, deadline_id))
}
async fn seed_running_workflow(
registry: &Registry,
store: &Arc<InMemoryStore>,
pid: u64,
) -> Result<WorkflowId, Box<dyn std::error::Error>> {
let store: Arc<dyn EventStore> = Arc::clone(store) as Arc<dyn EventStore>;
seed_resident_workflow(registry, &store, pid)
.await
.map(|(workflow_id, _)| workflow_id)
}
async fn seed_resident_workflow(
registry: &Registry,
store: &Arc<dyn EventStore>,
pid: u64,
) -> Result<(WorkflowId, RunId), Box<dyn std::error::Error>> {
use crate::registry::{
CompletionNotifier, HandleResidency, WorkflowHandle, WorkflowHandleParts,
};
use aion_core::WorkflowStatus;
let workflow_id = WorkflowId::new_v4();
let run_id = RunId::new_v4();
let started = Event::WorkflowStarted {
envelope: aion_core::EventEnvelope {
seq: 1,
recorded_at: Utc::now(),
workflow_id: workflow_id.clone(),
},
workflow_type: "sleeper".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)),
};
store
.append(WriteToken::recorder(), &workflow_id, &[started], 0)
.await?;
let recorder = Recorder::resume_at(workflow_id.clone(), Arc::clone(store), 1);
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: aion_package::ContentHash::from_bytes([9; 32]),
cached_status: WorkflowStatus::Running,
residency: HandleResidency::Resident,
recorder,
completion: CompletionNotifier::new(),
});
registry.insert((workflow_id.clone(), run_id.clone()), handle)?;
Ok((workflow_id, run_id))
}