use std::sync::Arc;
use aion_core::{Payload, WorkflowError};
use aion_store::EventStore;
use aion_store::visibility::VisibilityStore;
use chrono::{DateTime, Utc};
use tokio::runtime::Handle;
use crate::EngineError;
use crate::loader::WorkflowCatalog;
use crate::registry::{Registry, WorkflowHandle};
use crate::runtime::engine_tasks::{ArmOutcome, CompletionRetryKey};
use crate::runtime::{RuntimeHandle, WorkflowProcessOutcome};
use crate::supervision::SupervisionTree;
use super::completion::{ProcessExitContext, handle_process_exit_attempt};
#[derive(Clone, Debug)]
pub(super) struct TerminalIntent {
pub(super) outcome: TerminalIntentOutcome,
pub(super) exit_time: DateTime<Utc>,
}
#[derive(Clone, Debug)]
pub(super) enum TerminalIntentOutcome {
Completed(Payload),
Failed(WorkflowError),
}
impl TerminalIntent {
pub(super) fn from_outcome(outcome: Result<WorkflowProcessOutcome, EngineError>) -> Self {
let observed = match outcome {
Ok(WorkflowProcessOutcome::Completed(result)) => {
TerminalIntentOutcome::Completed(result)
}
Ok(WorkflowProcessOutcome::Failed(error)) => TerminalIntentOutcome::Failed(error),
Err(error) => TerminalIntentOutcome::Failed(WorkflowError {
message: format!("workflow process monitor failed: {error}"),
details: None,
}),
};
Self {
outcome: observed,
exit_time: Utc::now(),
}
}
}
#[derive(Debug)]
pub(super) enum CompletionFailure {
Retryable(EngineError, TerminalProgress),
Invariant(EngineError, TerminalProgress),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum TerminalProgress {
NotRecorded,
Recorded,
}
impl TerminalProgress {
fn operator_summary(self) -> &'static str {
match self {
Self::NotRecorded => "could not be recorded",
Self::Recorded => "was recorded, but the bookkeeping that follows it did not complete",
}
}
}
pub(super) struct CompletionRetryContext {
store: Arc<dyn EventStore>,
visibility_store: Arc<dyn VisibilityStore>,
registry: Arc<Registry>,
catalog: Arc<WorkflowCatalog>,
runtime: std::sync::Weak<RuntimeHandle>,
supervision: Arc<SupervisionTree>,
tokio_handle: Handle,
search_attribute_schema: Arc<aion_core::SearchAttributeSchema>,
}
impl CompletionRetryContext {
pub(super) fn downgrade(context: ProcessExitContext) -> Self {
let ProcessExitContext {
store,
visibility_store,
registry,
catalog,
runtime,
supervision,
tokio_handle,
search_attribute_schema,
} = context;
Self {
store,
visibility_store,
registry,
catalog,
runtime: Arc::downgrade(&runtime),
supervision,
tokio_handle,
search_attribute_schema,
}
}
fn upgrade(&self) -> Option<ProcessExitContext> {
Some(ProcessExitContext {
store: Arc::clone(&self.store),
visibility_store: Arc::clone(&self.visibility_store),
registry: Arc::clone(&self.registry),
catalog: Arc::clone(&self.catalog),
runtime: self.runtime.upgrade()?,
supervision: Arc::clone(&self.supervision),
tokio_handle: self.tokio_handle.clone(),
search_attribute_schema: Arc::clone(&self.search_attribute_schema),
})
}
}
pub(super) struct CompletionRetrySlot {
tasks: std::sync::Weak<crate::runtime::engine_tasks::EngineTaskRuntime>,
lease: CompletionRetryKey,
task: tokio::task::Id,
}
impl CompletionRetrySlot {
fn claim(
tasks: std::sync::Weak<crate::runtime::engine_tasks::EngineTaskRuntime>,
lease: CompletionRetryKey,
) -> Option<Self> {
let Some(task) = tokio::task::try_id() else {
tracing::error!(
workflow_id = %lease.workflow_id,
run_id = %lease.run_id,
monitor_pid = lease.monitor_pid,
"completion retry could not read its own task id; its registry entry will be \
reclaimed by the next arm for this lease or by the epoch close rather than on \
completion"
);
return None;
};
Some(Self { tasks, lease, task })
}
}
impl Drop for CompletionRetrySlot {
fn drop(&mut self) {
if let Some(tasks) = self.tasks.upgrade() {
tasks.remove_completion_retry(&self.lease, self.task);
}
}
}
pub(super) fn arm_completion_retry(
context: ProcessExitContext,
handle: WorkflowHandle,
intent: TerminalIntent,
cause: &EngineError,
progress: TerminalProgress,
) {
let run = CompletionRetryKey {
workflow_id: handle.workflow_id().clone(),
run_id: handle.run_id().clone(),
monitor_pid: handle.pid(),
};
let tasks = context.runtime.engine_tasks();
let backoff = context.runtime.completion_retry();
let armed = {
let weak_tasks = Arc::downgrade(&tasks);
let slot_run = run.clone();
let retry_context = CompletionRetryContext::downgrade(context);
tasks.arm_completion_retry(run.clone(), async move {
let _slot = CompletionRetrySlot::claim(weak_tasks, slot_run);
retry_process_exit(&retry_context, &handle, &intent, backoff, progress).await;
})
};
match armed {
ArmOutcome::Armed => {
tracing::warn!(
workflow_id = %run.workflow_id,
run_id = %run.run_id,
monitor_pid = run.monitor_pid,
error = %cause,
terminal_recorded = matches!(progress, TerminalProgress::Recorded),
"workflow completion {}; retrying durably in the background",
progress.operator_summary()
);
return;
}
ArmOutcome::AlreadyArmed => {
tracing::debug!(
workflow_id = %run.workflow_id,
run_id = %run.run_id,
monitor_pid = run.monitor_pid,
error = %cause,
terminal_recorded = matches!(progress, TerminalProgress::Recorded),
"workflow completion {}; a durable retry for this run is already in \
flight and owns the terminal, so this exit arms nothing",
progress.operator_summary()
);
return;
}
ArmOutcome::EpochClosed => {}
}
match progress {
TerminalProgress::NotRecorded => tracing::warn!(
workflow_id = %run.workflow_id,
run_id = %run.run_id,
monitor_pid = run.monitor_pid,
error = %cause,
terminal_recorded = false,
"workflow completion could not be recorded and no retry was armed; the run \
stays Running until a monitor is re-installed"
),
TerminalProgress::Recorded => tracing::warn!(
workflow_id = %run.workflow_id,
run_id = %run.run_id,
monitor_pid = run.monitor_pid,
error = %cause,
terminal_recorded = true,
"workflow completion was recorded and no retry was armed; the run is terminal \
and its status projection is correct, but the bookkeeping that follows the \
terminal — deadline retirement, visibility upsert, registry reconcile — did \
not complete, so a derived index may lag until recovery re-runs it"
),
}
}
pub(super) struct FaultReporter {
seen: Vec<&'static str>,
report_at_attempt: u64,
}
impl FaultReporter {
pub(super) fn new() -> Self {
Self {
seen: Vec::new(),
report_at_attempt: 1,
}
}
pub(super) fn should_report(&mut self, fault: &'static str, attempts: u64) -> bool {
let unseen = !self.seen.contains(&fault);
let escalated = attempts >= self.report_at_attempt;
if !unseen && !escalated {
return false;
}
if unseen {
self.seen.push(fault);
}
if escalated {
self.report_at_attempt = attempts.saturating_mul(2);
}
true
}
}
pub(super) async fn retry_process_exit(
context: &CompletionRetryContext,
handle: &WorkflowHandle,
intent: &TerminalIntent,
policy: crate::runtime::CompletionRetryConfig,
arming_progress: TerminalProgress,
) {
let mut backoff = policy.initial_backoff();
let mut attempts: u64 = 0;
let mut reporter = FaultReporter::new();
let started = std::time::Instant::now();
let mut progress = arming_progress;
loop {
crate::runtime::engine_tasks::sleep_backoff(&mut backoff, policy.max_backoff()).await;
attempts += 1;
let Some(attempt_context) = context.upgrade() else {
tracing::warn!(
workflow_id = %handle.workflow_id(),
run_id = %handle.run_id(),
monitor_pid = handle.pid(),
attempts,
"abandoning workflow completion retry: the engine was released before \
the terminal landed; the run stays Running until a monitor is re-installed"
);
return;
};
match complete_process_exit(&attempt_context, handle, intent, progress).await {
Ok(TerminalProgress::Recorded) => {
tracing::info!(
workflow_id = %handle.workflow_id(),
run_id = %handle.run_id(),
monitor_pid = handle.pid(),
attempts,
"workflow completion is durable after a transient durable failure: the \
terminal event is in history, so no further append is owed. The \
bookkeeping that follows a terminal — the visibility upsert and the \
registry reconcile — is NOT confirmed by this line; where the retry \
stood down on an already-durable terminal it did not run at all, and a \
visibility row may lag until the next reconciliation sweep"
);
return;
}
Ok(TerminalProgress::NotRecorded) => {
tracing::info!(
workflow_id = %handle.workflow_id(),
run_id = %handle.run_id(),
monitor_pid = handle.pid(),
attempts,
"workflow completion retry stood down without recording: this run's \
terminal is no longer this monitor's to write, so the retry is complete \
for this lease and the terminal is owed by whoever holds the run now"
);
return;
}
Err(CompletionFailure::Retryable(error, attempt_progress)) => {
progress = attempt_progress;
let current = failure_discriminant(&error);
let at_ceiling = backoff >= policy.max_backoff();
let first_sight = reporter.should_report(current, attempts);
if at_ceiling || first_sight {
tracing::warn!(
workflow_id = %handle.workflow_id(),
run_id = %handle.run_id(),
monitor_pid = handle.pid(),
attempts,
elapsed_seconds = started.elapsed().as_secs(),
at_ceiling,
error = %error,
fault = current,
terminal_recorded = matches!(progress, TerminalProgress::Recorded),
"workflow completion retry failed transiently; retrying with backoff"
);
}
}
Err(CompletionFailure::Invariant(error, progress)) => {
match progress {
TerminalProgress::NotRecorded => tracing::error!(
workflow_id = %handle.workflow_id(),
run_id = %handle.run_id(),
monitor_pid = handle.pid(),
attempts,
error = %error,
terminal_recorded = false,
"workflow completion hit an unretryable failure; the run stays \
Running until a monitor is re-installed"
),
TerminalProgress::Recorded => tracing::error!(
workflow_id = %handle.workflow_id(),
run_id = %handle.run_id(),
monitor_pid = handle.pid(),
attempts,
error = %error,
terminal_recorded = true,
"workflow completion hit an unretryable failure AFTER its terminal \
event was durably recorded; the run is terminal and its status \
projection is correct, but the bookkeeping that follows the \
terminal did not complete"
),
}
return;
}
}
}
}
pub(super) async fn complete_process_exit(
context: &ProcessExitContext,
handle: &WorkflowHandle,
intent: &TerminalIntent,
prior: TerminalProgress,
) -> Result<TerminalProgress, CompletionFailure> {
let mut progress = prior;
handle_process_exit_attempt(context, handle, intent, &mut progress)
.await
.map(|()| progress)
.map_err(|error| classify_completion_failure(error, progress))
}
pub(super) fn classify_completion_failure(
error: EngineError,
progress: TerminalProgress,
) -> CompletionFailure {
if completion_failure_is_transient(&error) {
CompletionFailure::Retryable(error, progress)
} else {
CompletionFailure::Invariant(error, progress)
}
}
pub(super) fn failure_discriminant(error: &EngineError) -> &'static str {
match error {
EngineError::Store(store) => store_discriminant(store),
EngineError::Durability(durability) => match durability {
crate::durability::DurabilityError::Store(store) => store_discriminant(store),
crate::durability::DurabilityError::NonDeterminism(_) => "durability/non-determinism",
crate::durability::DurabilityError::HistoryShape { .. } => "durability/history-shape",
crate::durability::DurabilityError::SearchAttribute(_) => "durability/search-attribute",
crate::durability::DurabilityError::EngineTaskEpochClosed { .. } => {
"durability/engine-task-epoch-closed"
}
},
EngineError::TerminalWriterHeld { .. } => "engine/terminal-writer-held",
_ => "engine/unretryable",
}
}
fn store_discriminant(error: &aion_store::StoreError) -> &'static str {
match error {
aion_store::StoreError::Backend(_) => "store/backend-unavailable",
aion_store::StoreError::NotOwner { .. } => "store/not-owner",
aion_store::StoreError::SequenceConflict { .. } => "store/sequence-conflict",
aion_store::StoreError::NotFound { .. } => "store/not-found",
aion_store::StoreError::AssistantSessionNotFound { .. } => {
"store/assistant-session-not-found"
}
aion_store::StoreError::Serialization(_) => "store/serialization",
aion_store::StoreError::InvalidQuery(_) => "store/invalid-query",
}
}
fn completion_failure_is_transient(error: &EngineError) -> bool {
match error {
EngineError::Store(store) => store_error_is_transient(store),
EngineError::Durability(durability) => match durability {
crate::durability::DurabilityError::Store(store) => store_error_is_transient(store),
crate::durability::DurabilityError::NonDeterminism(_)
| crate::durability::DurabilityError::HistoryShape { .. }
| crate::durability::DurabilityError::SearchAttribute(_)
| crate::durability::DurabilityError::EngineTaskEpochClosed { .. } => false,
},
EngineError::TerminalWriterHeld { .. } => true,
_ => false,
}
}
fn store_error_is_transient(error: &aion_store::StoreError) -> bool {
match error {
aion_store::StoreError::Backend(_) => true,
aion_store::StoreError::NotOwner { .. }
| aion_store::StoreError::SequenceConflict { .. }
| aion_store::StoreError::NotFound { .. }
| aion_store::StoreError::AssistantSessionNotFound { .. }
| aion_store::StoreError::Serialization(_)
| aion_store::StoreError::InvalidQuery(_) => false,
}
}