use std::sync::{Arc, Weak};
use std::time::Duration;
use aion_core::{ContentType, Payload, RunId, WorkflowId, WorkflowStatus};
use aion_package::ContentHash;
use aion_store::InMemoryStore;
use super::*;
use crate::durability::Recorder;
use crate::engine_seam::{
EngineHandle, EngineSeamError, WorkflowMailboxMessage, WorkflowProcessHandle, WorkflowResidency,
};
use crate::query::QueryError;
use crate::registry::{
CompletionNotifier, HandleResidency, Registry, WorkflowHandle, WorkflowHandleParts,
};
use crate::runtime::nif_query::{is_query_registered, register_query_impl};
use crate::runtime::{RuntimeConfig, RuntimeHandle, UndeliveredWake};
type TestResult = Result<(), Box<dyn std::error::Error>>;
const PREDECESSOR_PID: u64 = 4_001;
const SUCCESSOR_PID: u64 = 4_002;
fn handle_for(
store: &Arc<InMemoryStore>,
workflow_id: &WorkflowId,
run_id: &RunId,
pid: u64,
cached_status: WorkflowStatus,
) -> WorkflowHandle {
WorkflowHandle::new(WorkflowHandleParts {
workflow_id: workflow_id.clone(),
run_id: run_id.clone(),
pid,
workflow_type: "checkout".to_owned(),
namespace: String::from("default"),
loaded_version: ContentHash::from_bytes([9; 32]),
cached_status,
residency: HandleResidency::Resident,
recorder: Recorder::resume_at(workflow_id.clone(), Arc::clone(store) as _, 0),
completion: CompletionNotifier::new(),
})
}
fn seam(registry: &Arc<Registry>) -> QueryMailboxEngine {
QueryMailboxEngine::new(Arc::clone(registry), Weak::new(), Weak::new())
}
fn live_seam() -> Result<(Arc<RuntimeHandle>, QueryMailboxEngine), crate::EngineError> {
let runtime = Arc::new(RuntimeHandle::new(RuntimeConfig::new(
Some(1),
crate::runtime::config::TEST_STOP_DRAIN_TIMEOUT,
))?);
let mailbox = QueryMailboxEngine::new(
Arc::new(Registry::default()),
Arc::downgrade(runtime.nif_state()),
Arc::downgrade(&runtime),
);
Ok((runtime, mailbox))
}
#[test]
fn a_continued_workflow_resolves_to_its_current_run() -> TestResult {
let registry = Arc::new(Registry::default());
let store = Arc::new(InMemoryStore::default());
let workflow_id = WorkflowId::new_v4();
let predecessor_run = RunId::new_v4();
let successor_run = RunId::new_v4();
registry.insert(
(workflow_id.clone(), predecessor_run.clone()),
handle_for(
&store,
&workflow_id,
&predecessor_run,
PREDECESSOR_PID,
WorkflowStatus::ContinuedAsNew,
),
)?;
registry.insert(
(workflow_id.clone(), successor_run.clone()),
handle_for(
&store,
&workflow_id,
&successor_run,
SUCCESSOR_PID,
WorkflowStatus::Running,
),
)?;
assert_eq!(
seam(®istry).resolve_workflow(&workflow_id)?,
WorkflowResidency::Resident(WorkflowProcessHandle::new(SUCCESSOR_PID)),
"the seam must name the current run, not whichever handle a HashMap scan yielded"
);
Ok(())
}
#[test]
fn a_stale_predecessor_handle_without_an_index_entry_is_unknown() -> TestResult {
let registry = Arc::new(Registry::default());
let store = Arc::new(InMemoryStore::default());
let workflow_id = WorkflowId::new_v4();
let predecessor_run = RunId::new_v4();
let successor_run = RunId::new_v4();
registry.insert(
(workflow_id.clone(), predecessor_run.clone()),
handle_for(
&store,
&workflow_id,
&predecessor_run,
PREDECESSOR_PID,
WorkflowStatus::ContinuedAsNew,
),
)?;
registry.insert(
(workflow_id.clone(), successor_run.clone()),
handle_for(
&store,
&workflow_id,
&successor_run,
SUCCESSOR_PID,
WorkflowStatus::Running,
),
)?;
registry.remove(&workflow_id, &successor_run)?;
assert!(
registry.get(&workflow_id, &predecessor_run)?.is_some(),
"premise: the predecessor's handle outlives the successor's removal"
);
assert_eq!(
seam(®istry).resolve_workflow(&workflow_id)?,
WorkflowResidency::Unknown,
"no current run means Unknown, not the stale predecessor's Terminal"
);
Ok(())
}
#[test]
fn the_live_run_index_follows_registration_order() -> TestResult {
let registry = Arc::new(Registry::default());
let store = Arc::new(InMemoryStore::default());
let workflow_id = WorkflowId::new_v4();
let predecessor_run = RunId::new_v4();
let successor_run = RunId::new_v4();
registry.insert(
(workflow_id.clone(), successor_run.clone()),
handle_for(
&store,
&workflow_id,
&successor_run,
SUCCESSOR_PID,
WorkflowStatus::Running,
),
)?;
registry.insert(
(workflow_id.clone(), predecessor_run.clone()),
handle_for(
&store,
&workflow_id,
&predecessor_run,
PREDECESSOR_PID,
WorkflowStatus::ContinuedAsNew,
),
)?;
assert_eq!(
registry.live_run_pid(&workflow_id)?,
Some((predecessor_run, PREDECESSOR_PID)),
"the index names the LAST run registered, whatever its age"
);
assert_eq!(
seam(®istry).resolve_workflow(&workflow_id)?,
WorkflowResidency::Terminal,
"so a superseded run registered last would be the run this seam answers for"
);
Ok(())
}
#[test]
fn cleanup_started_while_pid_is_live_drops_the_query_reply() -> TestResult {
let (runtime, mailbox) = live_seam()?;
let pid = runtime.spawn_test_process()?;
let state = runtime.nif_state();
register_query_impl(state, "state", "{}", Some(pid))?;
assert!(
is_query_registered(state, pid, "state")?,
"fixture control: the query handler must be registered before cleanup"
);
state.cleanup_process(pid);
assert!(
runtime.is_live(pid),
"fixture control: cleanup must precede scheduler pid retirement"
);
assert!(
runtime.process_cleanup_started(pid),
"fixture control: cleanup must stamp the exit tombstone"
);
assert!(
!is_query_registered(state, pid, "state")?,
"fixture control: cleanup must remove the registered handler"
);
let (reply_to, reply_from) = tokio::sync::oneshot::channel();
mailbox.deliver_workflow_message(
WorkflowProcessHandle::new(pid),
WorkflowMailboxMessage::Query {
name: "state".to_owned(),
payload: Payload::new(ContentType::Json, b"{}".to_vec()),
reply_to,
},
)?;
assert_eq!(
reply_from.blocking_recv()?,
Err(QueryError::ReplyDropped),
"completion cleanup must yield ReplyDropped, never UnknownQuery"
);
runtime.shutdown()?;
Ok(())
}
#[test]
fn live_pid_without_cleanup_reports_unknown_query() -> TestResult {
let (runtime, mailbox) = live_seam()?;
let pid = runtime.spawn_test_process()?;
assert!(runtime.is_live(pid));
assert!(!runtime.process_cleanup_started(pid));
let (reply_to, reply_from) = tokio::sync::oneshot::channel();
mailbox.deliver_workflow_message(
WorkflowProcessHandle::new(pid),
WorkflowMailboxMessage::Query {
name: "missing".to_owned(),
payload: Payload::new(ContentType::Json, b"{}".to_vec()),
reply_to,
},
)?;
assert_eq!(
reply_from.blocking_recv()?,
Err(QueryError::UnknownQuery("missing".to_owned()))
);
runtime.shutdown()?;
Ok(())
}
const DRAINER_TIMEOUT: Duration = Duration::from_secs(10);
fn wait_for_recorded_terminal(runtime: &RuntimeHandle, pid: u64) -> TestResult {
let deadline = std::time::Instant::now() + DRAINER_TIMEOUT;
while std::time::Instant::now() < deadline {
if runtime.process_exits.has_terminal(pid)? {
return Ok(());
}
std::thread::sleep(Duration::from_millis(5));
}
Err(format!("the exit registry never recorded the terminal of pid {pid}").into())
}
fn query_message(
name: &str,
) -> (
WorkflowMailboxMessage,
tokio::sync::oneshot::Receiver<Result<Payload, QueryError>>,
) {
let (reply_to, reply_from) = tokio::sync::oneshot::channel();
(
WorkflowMailboxMessage::Query {
name: name.to_owned(),
payload: Payload::new(ContentType::Json, b"{}".to_vec()),
reply_to,
},
reply_from,
)
}
#[test]
fn recorded_terminal_before_cleanup_drops_the_query_reply() -> TestResult {
let (runtime, mailbox) = live_seam()?;
let pid = runtime.spawn_test_process()?;
register_query_impl(runtime.nif_state(), "state", "{}", Some(pid))?;
runtime.cancel_pid(pid)?;
wait_for_recorded_terminal(&runtime, pid)?;
assert!(
!runtime.process_cleanup_started(pid),
"fixture control: Aion cleanup must not have started without a monitor"
);
assert_eq!(
runtime.classify_undelivered_wake(pid)?,
UndeliveredWake::ProcessEnded
);
let (message, reply_from) = query_message("missing");
mailbox.deliver_workflow_message(WorkflowProcessHandle::new(pid), message)?;
assert_eq!(
reply_from.blocking_recv()?,
Err(QueryError::ReplyDropped),
"an unregistered name on a recorded ending must be ReplyDropped"
);
let (message, reply_from) = query_message("state");
mailbox.deliver_workflow_message(WorkflowProcessHandle::new(pid), message)?;
assert!(
reply_from.blocking_recv().is_err(),
"a wake refusal on a recorded ending must drop the parked reply sender, \
which the query service reads as ReplyDropped"
);
runtime.shutdown()?;
Ok(())
}
#[test]
fn absent_pid_without_a_recorded_ending_is_never_reply_dropped() -> TestResult {
let (runtime, mailbox) = live_seam()?;
runtime.process_exits.pause_for_test();
runtime
.process_exits
.wait_for_pause_for_test(DRAINER_TIMEOUT)?;
let pid = runtime.spawn_test_process()?;
register_query_impl(runtime.nif_state(), "state", "{}", Some(pid))?;
runtime.cancel_pid(pid)?;
let live = runtime.is_live(pid);
let cleanup_started = runtime.process_cleanup_started(pid);
let terminal_recorded = runtime.process_exits.has_terminal(pid);
let classified = runtime.classify_undelivered_wake(pid);
let (message, unregistered_reply) = query_message("missing");
let unregistered_delivery =
mailbox.deliver_workflow_message(WorkflowProcessHandle::new(pid), message);
let unregistered_reply = unregistered_reply.blocking_recv();
let (message, registered_reply) = query_message("state");
let registered_delivery =
mailbox.deliver_workflow_message(WorkflowProcessHandle::new(pid), message);
let registered_reply = registered_reply.blocking_recv();
runtime.process_exits.release_for_test();
assert!(
!live,
"fixture control: the pid must be absent from the table"
);
assert!(
!cleanup_started,
"fixture control: no cleanup tombstone may exist without a monitor"
);
assert!(
!terminal_recorded?,
"fixture control: the held drainer must not have recorded a terminal"
);
assert_eq!(
classified?,
UndeliveredWake::ExitInFlight,
"an exit that publishes nothing inside the readiness window is in flight, \
never a completion and never nothing at all"
);
unregistered_delivery?;
let unregistered_reason = match unregistered_reply? {
Err(QueryError::Engine(EngineSeamError::Delivery { reason })) => reason,
other => {
return Err(format!(
"an unregistered name over an exit in flight must be the engine fault, got: \
{other:?}"
)
.into());
}
};
assert!(
unregistered_reason.contains("exit still in flight")
&& unregistered_reason.contains("within the readiness window"),
"the engine fault must name both facts it rests on, got: {unregistered_reason}"
);
let refusal = match registered_delivery {
Ok(()) => {
return Err("a wake refusal with no recorded ending was swallowed as delivered".into());
}
Err(refusal) => refusal.to_string(),
};
assert!(
refusal.contains("query wake marker delivery failed")
&& refusal.contains("exit still in flight")
&& refusal.contains("within the readiness window"),
"the delivery refusal must be surfaced with its cause and both facts, got: {refusal}"
);
assert!(
registered_reply.is_err(),
"the rolled-back reply sender must be dropped, never left parked"
);
wait_for_recorded_terminal(&runtime, pid)?;
let (message, reply_from) = query_message("missing");
mailbox.deliver_workflow_message(WorkflowProcessHandle::new(pid), message)?;
assert_eq!(reply_from.blocking_recv()?, Err(QueryError::ReplyDropped));
runtime.shutdown()?;
Ok(())
}
#[test]
fn cleanup_started_while_pid_is_live_classifies_wake_failure_as_completion() -> TestResult {
let (runtime, _mailbox) = live_seam()?;
let pid = runtime.spawn_test_process()?;
runtime.nif_state().cleanup_process(pid);
assert!(
runtime.is_live(pid),
"fixture control: cleanup must precede scheduler pid retirement"
);
assert!(runtime.process_cleanup_started(pid));
assert_eq!(
runtime.classify_undelivered_wake(pid)?,
UndeliveredWake::ProcessEnded,
"wake-marker failure after cleanup starts must drop the reply, never report an engine fault"
);
runtime.shutdown()?;
Ok(())
}