use std::sync::Arc;
use aion_core::{
ActivityId, Event, RunId, SearchAttributeSchema, TimerCancelCause, TimerId, WorkflowId,
current_lease_terminal, run_segment, status_from_events,
};
use aion_store::EventStore;
use aion_store::visibility::VisibilityStore;
use chrono::{DateTime, Utc};
use crate::EngineError;
use crate::durability::{
ActiveWorkflowRecovery, ActiveWorkflowRecoverySeam, ActiveWorkflowRecoverySeamImpl, Recorder,
};
use crate::engine::startup::{
RecoveredResident, StartupRecoveryContext, recover_active_workflow, register_recovered_resident,
};
use crate::loader::WorkflowCatalog;
use crate::registry::{Registry, WorkflowHandle};
use crate::runtime::RuntimeHandle;
use crate::supervision::SupervisionTree;
pub struct ReopenWorkflowContext<'a> {
pub store: Arc<dyn EventStore>,
pub visibility_store: Arc<dyn VisibilityStore>,
pub catalog: Arc<WorkflowCatalog>,
pub runtime: &'a Arc<RuntimeHandle>,
pub supervision: Arc<SupervisionTree>,
pub registry: &'a Arc<Registry>,
pub search_attribute_schema: Arc<SearchAttributeSchema>,
}
pub async fn reopen(
context: ReopenWorkflowContext<'_>,
id: &WorkflowId,
run: &RunId,
) -> Result<WorkflowHandle, EngineError> {
let history = context.store.read_history(id).await?;
if history.is_empty() {
return Err(crate::engine::api::workflow_not_found(id, run));
}
let segment = run_segment(&history, run);
let reopened = validate_and_compute_reopened(id, run, segment)?;
if let Some(existing) = context.registry.get(id, run)? {
if existing.cached_status().is_terminal() {
context.registry.remove(id, run)?;
} else {
return Err(EngineError::InvalidState {
reason: format!(
"workflow {id} run {run} was already reopened and is Running (concurrent reopen)"
),
});
}
}
let rearm = rearmable_timers(segment);
let history_head = history.last().map(Event::seq).unwrap_or_default();
let mut recorder = Recorder::resume_at(id.clone(), Arc::clone(&context.store), history_head)
.with_visibility(run.clone(), Arc::clone(&context.visibility_store));
recorder
.record_workflow_reopened(Utc::now(), run.clone(), reopened)
.await?;
for timer in rearm.iter().filter(|timer| timer.needs_restart_marker) {
recorder
.record_timer_started(Utc::now(), timer.timer_id.clone(), timer.fire_at)
.await?;
}
let history = context.store.read_history(id).await?;
let handle = respawn_and_register(&context, id, run, &history, recorder).await?;
rearm_reopened_timers(&context, id, handle.pid(), &rearm).await?;
Ok(handle)
}
pub(crate) struct RearmTimer {
pub(crate) timer_id: TimerId,
pub(crate) fire_at: DateTime<Utc>,
pub(crate) needs_restart_marker: bool,
}
pub(crate) fn rearmable_timers(segment: &[Event]) -> Vec<RearmTimer> {
use std::collections::HashMap;
struct TimerTrace {
fire_at: DateTime<Utc>,
last_was_teardown_cancel: Option<bool>,
}
let mut traces: HashMap<TimerId, TimerTrace> = HashMap::new();
let mut order: Vec<TimerId> = Vec::new();
for event in segment {
match event {
Event::TimerStarted {
timer_id, fire_at, ..
} => {
if !traces.contains_key(timer_id) {
order.push(timer_id.clone());
}
traces.insert(
timer_id.clone(),
TimerTrace {
fire_at: *fire_at,
last_was_teardown_cancel: None,
},
);
}
Event::TimerFired { timer_id, .. } => {
traces.remove(timer_id);
}
Event::TimerCancelled {
timer_id, cause, ..
} => {
if let Some(trace) = traces.get_mut(timer_id) {
match cause {
TimerCancelCause::CancelTeardown => {
trace.last_was_teardown_cancel = Some(true);
}
TimerCancelCause::WorkflowIntent => {
traces.remove(timer_id);
}
}
}
}
_ => {}
}
}
order
.into_iter()
.filter_map(|timer_id| {
traces.remove(&timer_id).map(|trace| RearmTimer {
timer_id,
fire_at: trace.fire_at,
needs_restart_marker: trace.last_was_teardown_cancel == Some(true),
})
})
.collect()
}
pub(crate) async fn rearm_reopened_timers(
context: &ReopenWorkflowContext<'_>,
id: &WorkflowId,
pid: crate::Pid,
rearm: &[RearmTimer],
) -> Result<(), EngineError> {
if rearm.is_empty() {
return Ok(());
}
context.runtime.wait_for_pending_await(pid).await?;
let timer_service =
crate::runtime::nif_timer_bridge::installed_timer_service(context.runtime.nif_state())
.map_err(|error| EngineError::Runtime {
reason: format!("timer service unavailable while reopening {id}: {error}"),
})?;
let now = Utc::now();
for timer in rearm {
if timer.fire_at > now {
timer_service
.schedule(id.clone(), timer.timer_id.clone(), timer.fire_at)
.await
.map_err(|error| EngineError::Runtime {
reason: format!(
"failed to re-arm timer {} for reopened workflow {id}: {error}",
timer.timer_id
),
})?;
} else {
context
.store
.schedule_timer(id, &timer.timer_id, timer.fire_at)
.await?;
timer_service
.fire_timer(id.clone(), timer.timer_id.clone(), timer.fire_at)
.await
.map_err(|error| EngineError::Runtime {
reason: format!(
"failed to fire past-due timer {} for reopened workflow {id}: {error}",
timer.timer_id
),
})?;
}
}
Ok(())
}
fn validate_and_compute_reopened(
id: &WorkflowId,
run: &RunId,
segment: &[Event],
) -> Result<Vec<ActivityId>, EngineError> {
match current_lease_terminal(segment) {
Some(Event::WorkflowFailed { .. }) => Ok(reopened_failed_activities(segment)),
Some(Event::WorkflowCancelled { .. }) => Ok(Vec::new()),
Some(other) => Err(EngineError::InvalidState {
reason: format!(
"workflow {id} run {run} is terminal for a non-reopenable reason ({}); only Failed and Cancelled are reopenable",
terminal_status_name(other)
),
}),
None => Err(EngineError::InvalidState {
reason: format!(
"workflow {id} run {run} is {:?}, not a reopenable terminal (Failed or Cancelled)",
status_from_events(segment)
),
}),
}
}
fn reopened_failed_activities(segment: &[Event]) -> Vec<ActivityId> {
use std::collections::HashSet;
let lease_start = segment
.iter()
.rposition(|event| {
matches!(
event,
Event::WorkflowStarted { .. } | Event::WorkflowReopened { .. }
)
})
.map_or(0, |index| index + 1);
let lease = &segment[lease_start..];
let mut failed: HashSet<ActivityId> = HashSet::new();
let mut succeeded: HashSet<ActivityId> = HashSet::new();
for event in lease {
match event {
Event::ActivityFailed { activity_id, .. } => {
failed.insert(activity_id.clone());
}
Event::ActivityCompleted { activity_id, .. }
| Event::ActivityCancelled { activity_id, .. } => {
succeeded.insert(activity_id.clone());
}
_ => {}
}
}
let mut reopened: Vec<ActivityId> = failed
.into_iter()
.filter(|activity_id| !succeeded.contains(activity_id))
.collect();
reopened.sort_by_key(ActivityId::sequence_position);
reopened
}
fn terminal_status_name(event: &Event) -> &'static str {
match event {
Event::WorkflowCompleted { .. } => "Completed",
Event::WorkflowTimedOut { .. } => "TimedOut",
Event::WorkflowContinuedAsNew { .. } => "ContinuedAsNew",
Event::WorkflowFailed { .. } => "Failed",
Event::WorkflowCancelled { .. } => "Cancelled",
_ => "non-terminal",
}
}
pub(crate) async fn respawn_and_register(
context: &ReopenWorkflowContext<'_>,
id: &WorkflowId,
run: &RunId,
history: &[Event],
recorder: Recorder,
) -> Result<WorkflowHandle, EngineError> {
let workflow_type = started_workflow_type(id, history)?;
context
.supervision
.ensure_type_supervisor(workflow_type.clone())?;
let seam: Arc<dyn ActiveWorkflowRecoverySeam> = Arc::new(ActiveWorkflowRecoverySeamImpl::new(
Arc::clone(context.runtime),
));
let recovered =
recover_active_workflow(seam.as_ref(), id, &workflow_type, history, &context.catalog)?;
let (run_id, loaded_version, pid) = match recovered {
ActiveWorkflowRecovery::Resident {
run_id,
loaded_version,
pid,
} => (run_id, loaded_version, pid),
ActiveWorkflowRecovery::ScheduleCoordinator { .. } => {
return Err(EngineError::InvalidState {
reason: format!(
"workflow {id} run {run} is the schedule coordinator, not reopenable"
),
});
}
};
let startup_context = StartupRecoveryContext {
store: Arc::clone(&context.store),
visibility_store: Arc::clone(&context.visibility_store),
runtime: Arc::clone(context.runtime),
catalog: Arc::clone(&context.catalog),
registry: Arc::clone(context.registry),
supervision: Arc::clone(&context.supervision),
recovery: Some(seam),
search_attribute_schema: Arc::clone(&context.search_attribute_schema),
bootstrap_schedule_coordinator: false,
};
let history_head = history.last().map(Event::seq).unwrap_or_default();
register_recovered_resident(
&startup_context,
RecoveredResident {
workflow_id: id,
workflow_type: &workflow_type,
history,
history_head,
projected_status: aion_core::WorkflowStatus::Running,
run_id: run_id.clone(),
loaded_version,
pid,
recorder: Some(recorder),
},
)
.await?;
context
.registry
.get(id, &run_id)?
.ok_or_else(|| EngineError::Runtime {
reason: format!("reopened workflow {id} run {run_id} was not registered"),
})
}
fn started_workflow_type(id: &WorkflowId, history: &[Event]) -> Result<String, EngineError> {
history
.iter()
.find_map(|event| match event {
Event::WorkflowStarted { workflow_type, .. } => Some(workflow_type.clone()),
_ => None,
})
.ok_or_else(|| EngineError::Load {
reason: format!("workflow {id} has no WorkflowStarted event to reopen from"),
})
}
#[cfg(test)]
#[path = "reopen_tests.rs"]
mod tests;