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,
attempt: u32,
expired: 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 || is_retryable_reason(reason);
match policy {
Some(policy) if retry_eligible && attempt < policy.max_attempts => None,
_ => 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 {
use super::nif_activity_retry::{activity_timeout_from_config, retry_policy_from_config};
let policy = retry_policy_from_config(&request.config);
let bound = activity_timeout_from_config(&request.config);
let mut attempt = request.attempt;
loop {
let mut delivery = request.clone();
delivery.attempt = attempt;
let (reason, expired) = match deliver_one_attempt(dispatcher, delivery, bound).await {
Ok(payload) => return dispatch_completed(attempt, payload),
Err(failure) => failure,
};
if super::nif_activity_retry::is_worker_lost_reason(&reason) {
match worker_loss_stand_down(seam, request, &reason, attempt).await {
Some(terminal) => return RetryLoopOutcome { attempt, terminal },
None => continue,
}
}
if let Some(terminal) = failure_stand_down(policy.as_ref(), &reason, attempt, expired) {
if matches!(terminal, RetryLoopTerminal::Failed(_)) {
record_advisory_exhaustion(seam, request, &reason, attempt).await;
}
return RetryLoopOutcome { attempt, terminal };
}
let Some(policy) = policy.as_ref() else {
return RetryLoopOutcome {
attempt,
terminal: RetryLoopTerminal::Failed(reason),
};
};
match record_retry_event(
seam,
request,
RetryRecord::AttemptFailed {
attempt,
reason: reason.clone(),
},
)
.await
{
RetryRecordOutcome::Recorded => {}
RetryRecordOutcome::Settled => {
return RetryLoopOutcome {
attempt,
terminal: RetryLoopTerminal::SettledElsewhere,
};
}
RetryRecordOutcome::EpochClosed => {
return stood_down_on_closed_epoch(request, attempt);
}
RetryRecordOutcome::RecordFailed(record_error) => {
tracing::warn!(
workflow_id = %request.workflow_id,
activity_id = %request.activity_id,
attempt,
error = %record_error,
"failed to record a retryable activity failure; failing the activity instead \
of retrying unrecorded"
);
return RetryLoopOutcome {
attempt,
terminal: RetryLoopTerminal::Failed(reason),
};
}
}
announce_and_back_off(policy, request, &reason, attempt).await;
attempt += 1;
match record_retry_event(seam, request, RetryRecord::AttemptStarted { attempt }).await {
RetryRecordOutcome::Recorded => {}
RetryRecordOutcome::Settled => {
return RetryLoopOutcome {
attempt,
terminal: RetryLoopTerminal::SettledElsewhere,
};
}
RetryRecordOutcome::EpochClosed => {
return stood_down_on_closed_epoch(request, attempt);
}
RetryRecordOutcome::RecordFailed(record_error) => {
tracing::warn!(
workflow_id = %request.workflow_id,
activity_id = %request.activity_id,
attempt,
error = %record_error,
"failed to record a retry attempt start; failing the activity instead of \
dispatching unrecorded"
);
return RetryLoopOutcome {
attempt,
terminal: RetryLoopTerminal::Failed(reason),
};
}
}
}
}
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 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 },
AttemptStarted { attempt: 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;
}
let append_result = match record {
RetryRecord::AttemptFailed { attempt, reason } => {
recorder
.record_activity_failed(
chrono::Utc::now(),
request.activity_id.clone(),
aion_core::ActivityError {
kind: aion_core::ActivityErrorKind::Retryable,
message: reason,
details: None,
},
attempt,
)
.await
}
RetryRecord::AttemptStarted { attempt } => {
recorder
.record_activity_started(chrono::Utc::now(), request.activity_id.clone(), attempt)
.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),
}
}