use std::sync::Arc;
use crate::activity::bridge::{ActivityDispatch, ActivityDispatcher};
use crate::durability::Recorder;
pub(super) fn spawn_completion_task(
tokio_handle: &tokio::runtime::Handle,
runtime: Arc<crate::RuntimeHandle>,
dispatcher: Arc<dyn ActivityDispatcher>,
seam: RetryRecorderSeam,
workflow_pid: u64,
correlation_id: String,
request: ActivityDispatch,
) {
let future = async move {
let outcome = dispatch_with_retries(&dispatcher, &seam, &request).await;
let attempt = outcome.attempt;
match outcome.terminal {
RetryLoopTerminal::Completed(payload) => {
if let Err(error) = runtime.deliver_activity_completion_message_with_attempt(
workflow_pid,
&correlation_id,
payload,
Some(attempt),
) {
tracing::warn!(%error, workflow_pid, correlation_id, "activity completion delivery failed");
}
}
RetryLoopTerminal::Failed(reason) => {
if let Err(error) = runtime.deliver_activity_failure_message_with_attempt(
workflow_pid,
&correlation_id,
reason,
Some(attempt),
) {
tracing::warn!(%error, workflow_pid, correlation_id, "activity failure delivery failed");
}
}
RetryLoopTerminal::SettledElsewhere => {
tracing::debug!(
workflow_id = %request.workflow_id,
activity_id = %request.activity_id,
attempt,
"activity retry loop stopped: the activity settled through another path"
);
}
RetryLoopTerminal::Parked => {
tracing::debug!(
workflow_id = %request.workflow_id,
activity_id = %request.activity_id,
attempt,
"activity dispatch parked for restart recovery; retry loop stood down"
);
}
}
};
tokio_handle.spawn(future);
}
pub(super) struct RetryRecorderSeam {
pub(super) recorder: Arc<tokio::sync::Mutex<Recorder>>,
pub(super) run_id: aion_core::RunId,
pub(super) engine_tasks: Arc<super::engine_tasks::EngineTaskRuntime>,
}
#[derive(Debug)]
pub(super) struct RetryLoopOutcome {
pub(super) attempt: u32,
pub(super) terminal: RetryLoopTerminal,
}
#[derive(Debug)]
pub(super) enum RetryLoopTerminal {
Completed(String),
Failed(String),
SettledElsewhere,
Parked,
}
fn stood_down_on_closed_epoch(request: &ActivityDispatch, attempt: u32) -> RetryLoopOutcome {
tracing::info!(
workflow_id = %request.workflow_id,
activity_id = %request.activity_id,
attempt,
"engine task epoch closed mid-retry; standing down rather than writing for a run this \
process no longer owns"
);
RetryLoopOutcome {
attempt,
terminal: RetryLoopTerminal::SettledElsewhere,
}
}
fn failure_stand_down(
policy: Option<&super::nif_activity_retry::RetryPolicy>,
reason: &str,
same_provider_attempt: u32,
expired: bool,
policy_refused: bool,
) -> Option<RetryLoopTerminal> {
use super::nif_activity_retry::{is_parked_reason, is_retryable_reason};
if is_parked_reason(reason) {
return Some(RetryLoopTerminal::Parked);
}
let retry_eligible = expired || policy_refused || is_retryable_reason(reason);
match policy {
Some(policy) if retry_eligible && same_provider_attempt < policy.max_attempts => None,
Some(policy) if retry_eligible && policy_refused => {
Some(RetryLoopTerminal::Failed(format!(
"policy_refused: spent {same_provider_attempt} of {} configured attempts; last \
refusal: {reason}",
policy.max_attempts
)))
}
Some(policy) if retry_eligible => Some(RetryLoopTerminal::Failed(format!(
"exhausted: spent {same_provider_attempt} of {} attempts; last refusal: {reason}",
policy.max_attempts
))),
_ => Some(RetryLoopTerminal::Failed(reason.to_owned())),
}
}
const fn dispatch_completed(attempt: u32, payload: String) -> RetryLoopOutcome {
RetryLoopOutcome {
attempt,
terminal: RetryLoopTerminal::Completed(payload),
}
}
async fn deliver_one_attempt(
dispatcher: &Arc<dyn ActivityDispatcher>,
delivery: ActivityDispatch,
bound: Option<std::time::Duration>,
) -> Result<String, (String, bool)> {
use super::nif_activity_retry::activity_timeout_reason;
let dispatch = Arc::clone(dispatcher).dispatch_async(delivery);
match bound {
None => dispatch.await.map_err(|reason| (reason, false)),
Some(bound) => match tokio::time::timeout(bound, dispatch).await {
Ok(result) => result.map_err(|reason| (reason, false)),
Err(_elapsed) => Err((activity_timeout_reason(bound), true)),
},
}
}
pub(super) async fn dispatch_with_retries(
dispatcher: &Arc<dyn ActivityDispatcher>,
seam: &RetryRecorderSeam,
request: &ActivityDispatch,
) -> RetryLoopOutcome {
let history = retry_history(seam).await.unwrap_or_default();
let mut state = RetryLoopState::new(request, &history);
if let Some((refused_attempt, reason)) =
super::nif_activity_fallback::trailing_policy_refusal(&history, &request.activity_id)
{
state.attempt = refused_attempt;
state.same_provider_attempt = refused_attempt
.saturating_sub(state.recorded_hop_count)
.max(1);
if let Some(outcome) = handle_policy_refusal(seam, request, &mut state, &reason, true).await
{
return outcome;
}
}
loop {
let mut delivery = request.clone();
delivery.attempt = state.attempt;
delivery.task_queue = state.current_queue.clone();
let (reason, expired) = match deliver_one_attempt(dispatcher, delivery, state.bound).await {
Ok(payload) => return dispatch_completed(state.attempt, payload),
Err(failure) => failure,
};
if super::nif_activity_retry::is_worker_lost_reason(&reason) {
match worker_loss_stand_down(seam, request, &reason, state.attempt).await {
Some(terminal) => {
return RetryLoopOutcome {
attempt: state.attempt,
terminal,
};
}
None => continue,
}
}
if super::nif_activity_retry::is_policy_refused_reason(&reason) {
if let Some(outcome) =
handle_policy_refusal(seam, request, &mut state, &reason, false).await
{
return outcome;
}
continue;
}
if let Some(terminal) = failure_stand_down(
state.policy.as_ref(),
&reason,
state.same_provider_attempt,
expired,
false,
) {
if matches!(terminal, RetryLoopTerminal::Failed(_)) {
record_advisory_exhaustion(seam, request, &reason, state.attempt).await;
}
return RetryLoopOutcome {
attempt: state.attempt,
terminal,
};
}
let Some(policy) = state.policy.as_ref() else {
return RetryLoopOutcome {
attempt: state.attempt,
terminal: RetryLoopTerminal::Failed(reason),
};
};
if let Err(outcome) = record_required(
seam,
request,
RetryRecord::AttemptFailed {
attempt: state.attempt,
reason: reason.clone(),
kind: aion_core::ActivityErrorKind::Retryable,
},
state.attempt,
&reason,
"retryable activity failure",
)
.await
{
return outcome;
}
announce_and_back_off(policy, request, &reason, state.same_provider_attempt).await;
state.same_provider_attempt = state.same_provider_attempt.saturating_add(1);
state.attempt = state.attempt.saturating_add(1);
if let Err(outcome) = record_required(
seam,
request,
RetryRecord::AttemptStarted {
attempt: state.attempt,
},
state.attempt,
&reason,
"retry attempt start",
)
.await
{
return outcome;
}
}
}
struct RetryLoopState {
policy: Option<super::nif_activity_retry::RetryPolicy>,
fallback_chain: Vec<String>,
initial_queue: String,
current_queue: String,
consumed_fallback: usize,
refused_queues: Vec<String>,
bound: Option<std::time::Duration>,
attempt: u32,
same_provider_attempt: u32,
recorded_hop_count: u32,
}
impl RetryLoopState {
fn new(request: &ActivityDispatch, history: &[aion_core::Event]) -> Self {
use super::nif_activity_retry::{activity_timeout_from_config, retry_policy_from_config};
let initial_queue = request.task_queue.clone();
let recorded_hops =
super::nif_activity_fallback::recorded_hops(history, &request.activity_id);
let recorded_hop_count = u32::try_from(recorded_hops.len()).unwrap_or(u32::MAX);
let current_queue =
super::nif_activity_fallback::recorded_hop_queue(history, &request.activity_id)
.unwrap_or_else(|| initial_queue.clone());
Self {
policy: retry_policy_from_config(&request.config),
fallback_chain: super::nif_activity_fallback::fallback_chain_from_config(
&request.config,
),
initial_queue: initial_queue.clone(),
current_queue,
consumed_fallback: super::nif_activity_fallback::consumed_fallback_position(
history,
&request.activity_id,
),
refused_queues: super::nif_activity_fallback::refused_queue_sequence(
history,
&request.activity_id,
&initial_queue,
),
bound: activity_timeout_from_config(&request.config),
attempt: request.attempt,
same_provider_attempt: request.attempt.saturating_sub(recorded_hop_count).max(1),
recorded_hop_count,
}
}
}
async fn handle_policy_refusal(
seam: &RetryRecorderSeam,
request: &ActivityDispatch,
state: &mut RetryLoopState,
reason: &str,
refusal_recorded: bool,
) -> Option<RetryLoopOutcome> {
if !refusal_recorded
&& let Err(outcome) = record_required(
seam,
request,
RetryRecord::AttemptFailed {
attempt: state.attempt,
reason: reason.to_owned(),
kind: aion_core::ActivityErrorKind::PolicyRefused,
},
state.attempt,
reason,
"policy refusal",
)
.await
{
return Some(outcome);
}
if !state.fallback_chain.is_empty() {
return route_declared_fallback(seam, request, state, reason).await;
}
if let Some(terminal) = failure_stand_down(
state.policy.as_ref(),
reason,
state.same_provider_attempt,
false,
true,
) {
record_advisory_exhaustion(seam, request, reason, state.attempt).await;
return Some(RetryLoopOutcome {
attempt: state.attempt,
terminal,
});
}
let policy = state.policy.as_ref()?;
announce_and_back_off(policy, request, reason, state.same_provider_attempt).await;
state.same_provider_attempt = state.same_provider_attempt.saturating_add(1);
state.attempt = state.attempt.saturating_add(1);
record_required(
seam,
request,
RetryRecord::AttemptStarted {
attempt: state.attempt,
},
state.attempt,
reason,
"policy-refusal retry start",
)
.await
.err()
}
async fn route_declared_fallback(
seam: &RetryRecorderSeam,
request: &ActivityDispatch,
state: &mut RetryLoopState,
reason: &str,
) -> Option<RetryLoopOutcome> {
let Some((fallback_index, next_queue)) = super::nif_activity_fallback::next_hop(
&state.fallback_chain,
&state.initial_queue,
&state.current_queue,
state.consumed_fallback,
) else {
let terminal = RetryLoopTerminal::Failed(format!(
"policy_refused: declared fallback queues refused in recorded order: {}",
state.refused_queues.join(", ")
));
record_advisory_exhaustion(seam, request, reason, state.attempt).await;
return Some(RetryLoopOutcome {
attempt: state.attempt,
terminal,
});
};
if let Err(outcome) = record_required(
seam,
request,
RetryRecord::FallbackRouted {
attempt: state.attempt,
from_task_queue: state.current_queue.clone(),
to_task_queue: next_queue.clone(),
fallback_index: u32::try_from(fallback_index).unwrap_or(u32::MAX),
},
state.attempt,
reason,
"fallback hop",
)
.await
{
return Some(outcome);
}
state.consumed_fallback = fallback_index.saturating_add(1);
state.current_queue = next_queue;
state.refused_queues.push(state.current_queue.clone());
state.attempt = state.attempt.saturating_add(1);
record_required(
seam,
request,
RetryRecord::AttemptStarted {
attempt: state.attempt,
},
state.attempt,
reason,
"fallback attempt start",
)
.await
.err()
}
async fn record_required(
seam: &RetryRecorderSeam,
request: &ActivityDispatch,
record: RetryRecord,
attempt: u32,
reason: &str,
operation: &'static str,
) -> Result<(), RetryLoopOutcome> {
match record_retry_event(seam, request, record).await {
RetryRecordOutcome::Recorded => Ok(()),
RetryRecordOutcome::Settled => Err(RetryLoopOutcome {
attempt,
terminal: RetryLoopTerminal::SettledElsewhere,
}),
RetryRecordOutcome::EpochClosed => Err(stood_down_on_closed_epoch(request, attempt)),
RetryRecordOutcome::RecordFailed(error) => {
tracing::warn!(
workflow_id = %request.workflow_id,
activity_id = %request.activity_id,
attempt,
%error,
operation,
"required retry-loop record failed; failing instead of continuing unrecorded"
);
Err(RetryLoopOutcome {
attempt,
terminal: RetryLoopTerminal::Failed(reason.to_owned()),
})
}
}
}
async fn announce_and_back_off(
policy: &super::nif_activity_retry::RetryPolicy,
request: &ActivityDispatch,
reason: &str,
attempt: u32,
) {
let delay = policy.backoff.delay_after(attempt);
tracing::warn!(
workflow_id = %request.workflow_id,
activity_id = %request.activity_id,
activity_type = %request.name,
attempt,
max_attempts = policy.max_attempts,
retry_in_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
reason = %reason,
"activity attempt failed with a retryable error; re-dispatching"
);
tokio::time::sleep(delay).await;
}
async fn worker_loss_stand_down(
seam: &RetryRecorderSeam,
request: &ActivityDispatch,
reason: &str,
attempt: u32,
) -> Option<RetryLoopTerminal> {
if activity_settled_elsewhere(seam, request).await {
return Some(RetryLoopTerminal::SettledElsewhere);
}
tracing::warn!(
workflow_id = %request.workflow_id,
activity_id = %request.activity_id,
activity_type = %request.name,
attempt,
reason = %reason,
"activity's worker was lost before it reported a result; re-dispatching the same \
attempt (transport loss consumes no authored retry budget)"
);
None
}
async fn activity_settled_elsewhere(seam: &RetryRecorderSeam, request: &ActivityDispatch) -> bool {
let recorder = seam.recorder.lock().await;
let Ok(history) = recorder.read_history().await else {
return false;
};
let Ok(history) = crate::durability::current_run_segment(history, &seam.run_id) else {
return false;
};
super::nif_activity_retry::activity_settled(&history, &request.activity_id)
}
async fn retry_history(seam: &RetryRecorderSeam) -> Option<Vec<aion_core::Event>> {
let recorder = seam.recorder.lock().await;
let history = recorder.read_history().await.map_err(|error| {
tracing::warn!(%error, "could not read retry history; using the dispatch's recorded queue");
}).ok()?;
crate::durability::current_run_segment(history, &seam.run_id)
.map_err(|error| {
tracing::warn!(%error, "could not scope retry history to the run; using the dispatch's recorded queue");
})
.ok()
}
async fn record_advisory_exhaustion(
seam: &RetryRecorderSeam,
request: &ActivityDispatch,
reason: &str,
attempt: u32,
) {
if !request.advisory {
return;
}
match record_retry_event(
seam,
request,
RetryRecord::AdvisoryExhausted {
attempt,
reason: reason.to_owned(),
},
)
.await
{
RetryRecordOutcome::Recorded | RetryRecordOutcome::Settled => {}
RetryRecordOutcome::EpochClosed => {
tracing::info!(
workflow_id = %request.workflow_id,
activity_id = %request.activity_id,
attempt,
"engine task epoch closed before the advisory exhaustion note could be recorded"
);
}
RetryRecordOutcome::RecordFailed(record_error) => {
tracing::warn!(
workflow_id = %request.workflow_id,
activity_id = %request.activity_id,
attempt,
error = %record_error,
"failed to record the advisory-exhaustion warning; the activity's terminal \
failure still stands"
);
}
}
}
enum RetryRecord {
AttemptFailed {
attempt: u32,
reason: String,
kind: aion_core::ActivityErrorKind,
},
AttemptStarted { attempt: u32 },
FallbackRouted {
attempt: u32,
from_task_queue: String,
to_task_queue: String,
fallback_index: u32,
},
AdvisoryExhausted { attempt: u32, reason: String },
}
enum RetryRecordOutcome {
Recorded,
Settled,
EpochClosed,
RecordFailed(crate::durability::DurabilityError),
}
async fn record_retry_event(
seam: &RetryRecorderSeam,
request: &ActivityDispatch,
record: RetryRecord,
) -> RetryRecordOutcome {
let mut recorder = seam.recorder.lock().await;
if !seam.engine_tasks.is_epoch_open() {
return RetryRecordOutcome::EpochClosed;
}
let history = match recorder.read_history().await {
Ok(history) => history,
Err(error) => return RetryRecordOutcome::RecordFailed(error),
};
let history = match crate::durability::current_run_segment(history, &seam.run_id) {
Ok(history) => history,
Err(error) => return RetryRecordOutcome::RecordFailed(error),
};
if super::nif_activity_retry::activity_settled(&history, &request.activity_id) {
return RetryRecordOutcome::Settled;
}
if let RetryRecord::FallbackRouted { attempt, .. } = &record
&& history.iter().any(|event| {
matches!(
event,
aion_core::Event::ActivityFallbackRouted {
activity_id,
attempt: recorded_attempt,
..
} if activity_id == &request.activity_id && recorded_attempt == attempt
)
})
{
return RetryRecordOutcome::Settled;
}
let append_result = match record {
RetryRecord::AttemptFailed {
attempt,
reason,
kind,
} => {
recorder
.record_activity_failed(
chrono::Utc::now(),
request.activity_id.clone(),
aion_core::ActivityError {
kind,
message: reason,
details: None,
},
attempt,
)
.await
}
RetryRecord::AttemptStarted { attempt } => {
recorder
.record_activity_started(chrono::Utc::now(), request.activity_id.clone(), attempt)
.await
}
RetryRecord::FallbackRouted {
attempt,
from_task_queue,
to_task_queue,
fallback_index,
} => {
recorder
.record_activity_fallback_routed(
chrono::Utc::now(),
request.activity_id.clone(),
attempt,
from_task_queue,
to_task_queue,
fallback_index,
)
.await
}
RetryRecord::AdvisoryExhausted { attempt, reason } => {
recorder
.record_activity_advisory_exhausted(
chrono::Utc::now(),
request.activity_id.clone(),
request.name.clone(),
reason,
attempt,
)
.await
}
};
match append_result {
Ok(()) => RetryRecordOutcome::Recorded,
Err(error) => RetryRecordOutcome::RecordFailed(error),
}
}