use std::collections::HashMap;
use std::sync::{Arc, Mutex, MutexGuard};
use aion_core::{Event, RunId, WorkflowId, status_from_events};
use aion_store::EventStore;
use crate::EngineError;
use super::handle::{Residency, WorkflowHandle};
use super::terminal_writer::TerminalWriterReservation;
use super::unrecoverable::UnrecoverableRuns;
type RegistryKey = (WorkflowId, RunId);
type HandleMap = HashMap<RegistryKey, WorkflowHandle>;
#[derive(Debug, Default)]
struct Slots {
handles: HandleMap,
terminal_writers: HashMap<WorkflowId, RunId>,
}
impl Slots {
fn any_handle_for(&self, workflow_id: &WorkflowId) -> Option<RunId> {
self.handles
.keys()
.find(|(id, _)| id == workflow_id)
.map(|(_, run)| run.clone())
}
}
type LivePidIndex = HashMap<WorkflowId, (RunId, u64)>;
#[derive(Debug, Default)]
pub struct Registry {
slots: Mutex<Slots>,
index: Mutex<LivePidIndex>,
unrecoverable: UnrecoverableRuns,
}
impl Registry {
pub fn insert(
&self,
key: (WorkflowId, RunId),
handle: WorkflowHandle,
) -> Result<Option<WorkflowHandle>, EngineError> {
let mut slots = self.slots()?;
refuse_if_terminal_writer_held(&slots, &key)?;
let pid = handle.pid();
let previous = slots.handles.insert(key.clone(), handle);
self.index()?.insert(key.0, (key.1, pid));
Ok(previous)
}
pub fn insert_if_absent(
&self,
key: (WorkflowId, RunId),
handle: WorkflowHandle,
) -> Result<WorkflowHandle, EngineError> {
let mut slots = self.slots()?;
refuse_if_terminal_writer_held(&slots, &key)?;
if let Some(existing) = slots.handles.get(&key) {
return Ok(existing.clone());
}
let pid = handle.pid();
slots.handles.insert(key.clone(), handle.clone());
self.index()?.insert(key.0, (key.1, pid));
Ok(handle)
}
pub fn get(&self, id: &WorkflowId, run: &RunId) -> Result<Option<WorkflowHandle>, EngineError> {
let slots = self.slots()?;
Ok(slots.handles.get(&(id.clone(), run.clone())).cloned())
}
pub fn remove(
&self,
id: &WorkflowId,
run: &RunId,
) -> Result<Option<WorkflowHandle>, EngineError> {
let mut slots = self.slots()?;
let removed = slots.handles.remove(&(id.clone(), run.clone()));
let mut index = self.index()?;
if let std::collections::hash_map::Entry::Occupied(entry) = index.entry(id.clone()) {
if entry.get().0 == *run {
entry.remove();
}
}
Ok(removed)
}
pub fn list(&self) -> Result<Vec<WorkflowHandle>, EngineError> {
let slots = self.slots()?;
Ok(slots.handles.values().cloned().collect())
}
pub fn replace_residency(
&self,
id: &WorkflowId,
run: &RunId,
residency: Residency,
) -> Result<Option<WorkflowHandle>, EngineError> {
let mut slots = self.slots()?;
let Some(handle) = slots.handles.get_mut(&(id.clone(), run.clone())) else {
return Ok(None);
};
handle.replace_residency(residency);
Ok(Some(handle.clone()))
}
pub fn reconcile(
&self,
id: &WorkflowId,
run: &RunId,
events: &[Event],
) -> Result<Option<WorkflowHandle>, EngineError> {
let projected = status_from_events(events);
let mut slots = self.slots()?;
let Some(handle) = slots.handles.get_mut(&(id.clone(), run.clone())) else {
return Ok(None);
};
handle.replace_projected_status(projected);
Ok(Some(handle.clone()))
}
pub fn live_pid(&self, workflow_id: &WorkflowId) -> Result<Option<u64>, EngineError> {
let index = self.index()?;
Ok(index.get(workflow_id).map(|(_, pid)| *pid))
}
pub fn live_run_pid(
&self,
workflow_id: &WorkflowId,
) -> Result<Option<(RunId, u64)>, EngineError> {
let index = self.index()?;
Ok(index.get(workflow_id).cloned())
}
pub fn unrecoverable(&self) -> &UnrecoverableRuns {
&self.unrecoverable
}
pub fn reserve_terminal_writer(
&self,
id: &WorkflowId,
run: &RunId,
store: Arc<dyn EventStore>,
) -> Result<TerminalWriterReservation<'_>, EngineError> {
let mut slots = self.slots()?;
if let Some(resident_run) = slots.any_handle_for(id) {
return Err(EngineError::TerminalWriterUnavailable {
workflow_id: id.to_string(),
run_id: run.to_string(),
holder: format!("run {resident_run} holds a live handle for this workflow"),
});
}
if let Some(held_run) = slots.terminal_writers.get(id) {
return Err(EngineError::TerminalWriterUnavailable {
workflow_id: id.to_string(),
run_id: run.to_string(),
holder: format!("run {held_run} already holds a terminal-writer reservation"),
});
}
slots.terminal_writers.insert(id.clone(), run.clone());
drop(slots);
Ok(TerminalWriterReservation::new(
self,
id.clone(),
run.clone(),
store,
))
}
pub(super) fn release_terminal_writer(
&self,
id: &WorkflowId,
run: &RunId,
) -> Result<bool, EngineError> {
let mut slots = self.slots()?;
if let std::collections::hash_map::Entry::Occupied(entry) =
slots.terminal_writers.entry(id.clone())
{
if entry.get() == run {
entry.remove();
return Ok(true);
}
}
Ok(false)
}
pub fn terminal_writer_run(&self, id: &WorkflowId) -> Result<Option<RunId>, EngineError> {
Ok(self.slots()?.terminal_writers.get(id).cloned())
}
fn slots(&self) -> Result<MutexGuard<'_, Slots>, EngineError> {
self.slots.lock().map_err(|_| EngineError::RegistryPoisoned)
}
fn index(&self) -> Result<MutexGuard<'_, LivePidIndex>, EngineError> {
self.index.lock().map_err(|_| EngineError::RegistryPoisoned)
}
}
fn refuse_if_terminal_writer_held(slots: &Slots, key: &RegistryKey) -> Result<(), EngineError> {
if let Some(held_run) = slots.terminal_writers.get(&key.0) {
return Err(EngineError::TerminalWriterHeld {
workflow_id: key.0.to_string(),
run_id: held_run.to_string(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use aion_core::{Event, EventEnvelope, Payload, PayloadError, WorkflowId, WorkflowStatus};
use aion_package::ContentHash;
use chrono::Utc;
use serde_json::json;
use crate::EngineError;
use crate::registry::handle::{
CompletionNotifier, HandleResidency, WorkflowHandle, WorkflowHandleParts,
};
use super::Registry;
type TestResult = Result<(), TestError>;
#[derive(Debug)]
enum TestError {
Engine(EngineError),
Payload(PayloadError),
}
impl std::fmt::Display for TestError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Engine(error) => write!(formatter, "{error}"),
Self::Payload(error) => write!(formatter, "{error}"),
}
}
}
impl std::error::Error for TestError {}
impl From<EngineError> for TestError {
fn from(error: EngineError) -> Self {
Self::Engine(error)
}
}
impl From<PayloadError> for TestError {
fn from(error: PayloadError) -> Self {
Self::Payload(error)
}
}
fn assert_send_sync<T: Send + Sync>() {}
fn hash(byte: u8) -> ContentHash {
ContentHash::from_bytes([byte; 32])
}
fn handle(pid: u64, version_byte: u8, status: WorkflowStatus) -> WorkflowHandle {
let workflow_id = WorkflowId::new_v4();
let run_id = aion_core::RunId::new_v4();
let store = Arc::new(aion_store::InMemoryStore::default());
let recorder = crate::durability::Recorder::new(workflow_id.clone(), store);
WorkflowHandle::new(WorkflowHandleParts {
workflow_id,
run_id,
pid,
workflow_type: "checkout".to_owned(),
namespace: String::from("default"),
loaded_version: hash(version_byte),
cached_status: status,
residency: HandleResidency::Resident,
recorder,
completion: CompletionNotifier::new(),
})
}
fn envelope(workflow_id: &aion_core::WorkflowId, seq: u64) -> EventEnvelope {
EventEnvelope {
seq,
recorded_at: Utc::now(),
workflow_id: workflow_id.clone(),
}
}
fn payload(label: &str) -> Result<Payload, aion_core::PayloadError> {
Payload::from_json(&json!({ "label": label }))
}
fn started(workflow_id: &aion_core::WorkflowId) -> Result<Event, aion_core::PayloadError> {
Ok(Event::WorkflowStarted {
envelope: envelope(workflow_id, 1),
workflow_type: String::from("checkout"),
input: payload("input")?,
run_id: aion_core::RunId::new(uuid::Uuid::from_u128(1)),
parent_run_id: None,
parent_workflow_id: None,
package_version: aion_core::PackageVersion::new("a".repeat(64)),
})
}
fn completed(workflow_id: &aion_core::WorkflowId) -> Result<Event, aion_core::PayloadError> {
Ok(Event::WorkflowCompleted {
envelope: envelope(workflow_id, 2),
result: payload("result")?,
})
}
fn cancelled(workflow_id: &aion_core::WorkflowId) -> Event {
Event::WorkflowCancelled {
envelope: envelope(workflow_id, 2),
reason: String::from("caller requested cancellation"),
}
}
#[test]
fn registry_is_send_sync() {
assert_send_sync::<Registry>();
}
#[test]
fn stores_two_runs_for_the_same_workflow_without_shadowing() -> Result<(), EngineError> {
let registry = Registry::default();
let workflow_id = aion_core::WorkflowId::new_v4();
let first_run = aion_core::RunId::new_v4();
let second_run = aion_core::RunId::new_v4();
let first = handle(1, 1, WorkflowStatus::Running);
let second = handle(2, 2, WorkflowStatus::Completed);
assert!(
registry
.insert((workflow_id.clone(), first_run.clone()), first.clone())?
.is_none()
);
assert!(
registry
.insert((workflow_id.clone(), second_run.clone()), second.clone())?
.is_none()
);
assert_eq!(registry.get(&workflow_id, &first_run)?, Some(first));
assert_eq!(registry.get(&workflow_id, &second_run)?, Some(second));
let stale_run = aion_core::RunId::new_v4();
assert_eq!(registry.get(&workflow_id, &stale_run)?, None);
Ok(())
}
#[test]
fn remove_deletes_only_the_requested_run() -> Result<(), EngineError> {
let registry = Registry::default();
let workflow_id = aion_core::WorkflowId::new_v4();
let first_run = aion_core::RunId::new_v4();
let second_run = aion_core::RunId::new_v4();
let first = handle(1, 1, WorkflowStatus::Running);
let second = handle(2, 2, WorkflowStatus::Running);
registry.insert((workflow_id.clone(), first_run.clone()), first.clone())?;
registry.insert((workflow_id.clone(), second_run.clone()), second.clone())?;
assert_eq!(registry.remove(&workflow_id, &first_run)?, Some(first));
assert_eq!(registry.get(&workflow_id, &first_run)?, None);
assert_eq!(registry.get(&workflow_id, &second_run)?, Some(second));
Ok(())
}
#[test]
fn live_pid_tracks_newest_run_across_continue_as_new() -> Result<(), EngineError> {
let registry = Registry::default();
let workflow_id = aion_core::WorkflowId::new_v4();
let first_run = aion_core::RunId::new_v4();
let second_run = aion_core::RunId::new_v4();
assert_eq!(registry.live_pid(&workflow_id)?, None);
registry.insert(
(workflow_id.clone(), first_run.clone()),
handle(1, 1, WorkflowStatus::Running),
)?;
assert_eq!(registry.live_pid(&workflow_id)?, Some(1));
registry.insert(
(workflow_id.clone(), second_run.clone()),
handle(2, 2, WorkflowStatus::Running),
)?;
assert_eq!(registry.live_pid(&workflow_id)?, Some(2));
registry.remove(&workflow_id, &first_run)?;
assert_eq!(registry.live_pid(&workflow_id)?, Some(2));
registry.remove(&workflow_id, &second_run)?;
assert_eq!(registry.live_pid(&workflow_id)?, None);
Ok(())
}
#[test]
fn insert_if_absent_wins_the_slot_and_rejects_the_racer() -> Result<(), EngineError> {
let registry = Registry::default();
let workflow_id = aion_core::WorkflowId::new_v4();
let run = aion_core::RunId::new_v4();
let first = handle(1, 1, WorkflowStatus::Running);
let second = handle(2, 2, WorkflowStatus::Running);
let registered =
registry.insert_if_absent((workflow_id.clone(), run.clone()), first.clone())?;
assert_eq!(registered.pid(), 1);
assert_eq!(registry.live_pid(&workflow_id)?, Some(1));
let observed = registry.insert_if_absent((workflow_id.clone(), run.clone()), second)?;
assert_eq!(
observed.pid(),
1,
"the second insert must return the winner's handle, not overwrite it"
);
assert_eq!(registry.get(&workflow_id, &run)?, Some(first));
Ok(())
}
#[test]
fn list_returns_snapshot_handles() -> Result<(), EngineError> {
let registry = Registry::default();
let workflow_id = aion_core::WorkflowId::new_v4();
let first_run = aion_core::RunId::new_v4();
let second_run = aion_core::RunId::new_v4();
registry.insert(
(workflow_id.clone(), first_run),
handle(1, 1, WorkflowStatus::Running),
)?;
registry.insert(
(workflow_id, second_run),
handle(2, 2, WorkflowStatus::Running),
)?;
let mut pids = registry
.list()?
.into_iter()
.map(|handle| handle.pid())
.collect::<Vec<_>>();
pids.sort_unstable();
assert_eq!(pids, vec![1, 2]);
Ok(())
}
#[test]
fn poisoned_lock_returns_typed_registry_error() {
let registry = Arc::new(Registry::default());
let poisoner_registry = Arc::clone(®istry);
let poisoner = std::thread::spawn(move || {
let guard = poisoner_registry.slots.lock();
assert!(guard.is_ok());
std::panic::resume_unwind(Box::new("poison registry lock"));
});
assert!(poisoner.join().is_err());
assert!(matches!(
registry.list(),
Err(EngineError::RegistryPoisoned)
));
}
#[test]
fn reconcile_updates_completed_projection() -> TestResult {
let registry = Registry::default();
let workflow_id = aion_core::WorkflowId::new_v4();
let run_id = aion_core::RunId::new_v4();
registry.insert(
(workflow_id.clone(), run_id.clone()),
handle(1, 1, WorkflowStatus::Running),
)?;
let events = vec![started(&workflow_id)?, completed(&workflow_id)?];
let reconciled = registry.reconcile(&workflow_id, &run_id, &events)?;
assert_eq!(
reconciled.map(|handle| handle.cached_status()),
Some(WorkflowStatus::Completed)
);
assert_eq!(
registry
.get(&workflow_id, &run_id)?
.map(|handle| handle.cached_status()),
Some(WorkflowStatus::Completed)
);
Ok(())
}
#[test]
fn reconcile_updates_cancelled_projection() -> TestResult {
let registry = Registry::default();
let workflow_id = aion_core::WorkflowId::new_v4();
let run_id = aion_core::RunId::new_v4();
registry.insert(
(workflow_id.clone(), run_id.clone()),
handle(1, 1, WorkflowStatus::Running),
)?;
let events = vec![started(&workflow_id)?, cancelled(&workflow_id)];
let reconciled = registry.reconcile(&workflow_id, &run_id, &events)?;
assert_eq!(
reconciled.map(|handle| handle.cached_status()),
Some(WorkflowStatus::Cancelled)
);
Ok(())
}
#[test]
fn reconcile_projection_wins_over_disagreeing_cache() -> TestResult {
let registry = Registry::default();
let workflow_id = aion_core::WorkflowId::new_v4();
let run_id = aion_core::RunId::new_v4();
registry.insert(
(workflow_id.clone(), run_id.clone()),
handle(1, 1, WorkflowStatus::Failed),
)?;
let events = vec![started(&workflow_id)?];
let reconciled = registry.reconcile(&workflow_id, &run_id, &events)?;
assert_eq!(
reconciled.map(|handle| handle.cached_status()),
Some(WorkflowStatus::Running)
);
Ok(())
}
fn store() -> Arc<dyn aion_store::EventStore> {
Arc::new(aion_store::InMemoryStore::default())
}
#[test]
fn a_reservation_and_a_handle_are_mutually_exclusive() -> Result<(), EngineError> {
let registry = Registry::default();
let workflow_id = aion_core::WorkflowId::new_v4();
let run = aion_core::RunId::new_v4();
let reservation = registry.reserve_terminal_writer(&workflow_id, &run, store())?;
assert!(
matches!(
registry.insert(
(workflow_id.clone(), run.clone()),
handle(1, 1, WorkflowStatus::Running)
),
Err(EngineError::TerminalWriterHeld { .. })
),
"a handle must not be registrable for a workflow whose writer is reserved"
);
assert!(
matches!(
registry.insert_if_absent(
(workflow_id.clone(), run.clone()),
handle(1, 1, WorkflowStatus::Running)
),
Err(EngineError::TerminalWriterHeld { .. })
),
"insert_if_absent is the other door into the same map and must refuse too"
);
drop(reservation);
registry.insert(
(workflow_id.clone(), run.clone()),
handle(1, 1, WorkflowStatus::Running),
)?;
assert!(
matches!(
registry.reserve_terminal_writer(&workflow_id, &run, store()),
Err(EngineError::TerminalWriterUnavailable { .. })
),
"a resident run has a writer already and must take the ordinary cancel path"
);
Ok(())
}
#[test]
fn a_handle_on_another_run_of_the_workflow_blocks_the_reservation() -> Result<(), EngineError> {
let registry = Registry::default();
let workflow_id = aion_core::WorkflowId::new_v4();
let live_run = aion_core::RunId::new_v4();
let dead_run = aion_core::RunId::new_v4();
registry.insert(
(workflow_id.clone(), live_run),
handle(1, 1, WorkflowStatus::Running),
)?;
assert!(
matches!(
registry.reserve_terminal_writer(&workflow_id, &dead_run, store()),
Err(EngineError::TerminalWriterUnavailable { .. })
),
"another run's live handle is still a writer for this workflow's stream"
);
let other_workflow = aion_core::WorkflowId::new_v4();
assert!(
registry
.reserve_terminal_writer(&other_workflow, &dead_run, store())
.is_ok(),
"the refusal must be scoped to the workflow that has a writer, not global"
);
Ok(())
}
#[test]
fn a_second_reservation_for_the_same_workflow_is_refused() -> Result<(), EngineError> {
let registry = Registry::default();
let workflow_id = aion_core::WorkflowId::new_v4();
let first = aion_core::RunId::new_v4();
let second = aion_core::RunId::new_v4();
let held = registry.reserve_terminal_writer(&workflow_id, &first, store())?;
assert!(matches!(
registry.reserve_terminal_writer(&workflow_id, &second, store()),
Err(EngineError::TerminalWriterUnavailable { .. })
));
drop(held);
Ok(())
}
#[test]
fn dropping_a_reservation_releases_the_slot() -> Result<(), EngineError> {
let registry = Registry::default();
let workflow_id = aion_core::WorkflowId::new_v4();
let run = aion_core::RunId::new_v4();
{
let _reservation = registry.reserve_terminal_writer(&workflow_id, &run, store())?;
assert_eq!(
registry.terminal_writer_run(&workflow_id)?,
Some(run.clone()),
"the slot must be held while the guard is alive, or the test below proves nothing"
);
}
assert_eq!(
registry.terminal_writer_run(&workflow_id)?,
None,
"the slot must be free once the guard goes out of scope"
);
registry.insert(
(workflow_id.clone(), run.clone()),
handle(1, 1, WorkflowStatus::Running),
)?;
assert!(
registry.get(&workflow_id, &run)?.is_some(),
"and a handle must be registrable again afterwards"
);
Ok(())
}
#[test]
fn a_reservation_is_invisible_to_every_live_process_lookup() -> Result<(), EngineError> {
let registry = Registry::default();
let workflow_id = aion_core::WorkflowId::new_v4();
let run = aion_core::RunId::new_v4();
let _reservation = registry.reserve_terminal_writer(&workflow_id, &run, store())?;
assert_eq!(registry.live_pid(&workflow_id)?, None);
assert_eq!(registry.live_run_pid(&workflow_id)?, None);
assert_eq!(registry.get(&workflow_id, &run)?, None);
assert!(registry.list()?.is_empty());
Ok(())
}
#[test]
fn reconcile_missing_handle_is_noop() -> TestResult {
let registry = Registry::default();
let workflow_id = aion_core::WorkflowId::new_v4();
let run_id = aion_core::RunId::new_v4();
let events = vec![started(&workflow_id)?];
assert_eq!(registry.reconcile(&workflow_id, &run_id, &events)?, None);
Ok(())
}
}