use std::time::Duration;
use aion_core::{ActivityId, Event};
use crate::runtime::nif_activity_dispatch::FIRST_DELIVERY_ATTEMPT;
pub(super) const RETRYABLE_REASON_PREFIX: &str = "retryable:";
const PARKED_REASON_PREFIX: &str = "parked:";
pub const PARKED_ACTIVITY_REASON: &str = "parked:server-draining";
#[must_use]
pub fn is_parked_reason(reason: &str) -> bool {
reason.starts_with(PARKED_REASON_PREFIX)
}
pub(super) fn is_retryable_reason(reason: &str) -> bool {
reason.starts_with(RETRYABLE_REASON_PREFIX)
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct RetryPolicy {
pub(super) max_attempts: u32,
pub(super) backoff: Backoff,
}
#[derive(Clone, Debug, PartialEq)]
pub(super) enum Backoff {
Exponential {
initial: Duration,
multiplier: f64,
max: Duration,
},
Linear {
initial: Duration,
increment: Duration,
max: Duration,
},
Fixed {
delay: Duration,
},
}
impl Backoff {
pub(super) fn delay_after(&self, failed_attempt: u32) -> Duration {
let step = failed_attempt.saturating_sub(1);
match self {
Self::Fixed { delay } => *delay,
Self::Linear {
initial,
increment,
max,
} => initial
.saturating_add(increment.saturating_mul(step))
.min(*max),
Self::Exponential {
initial,
multiplier,
max,
} => {
let factor = multiplier.powi(i32::try_from(step).unwrap_or(i32::MAX));
let initial_ms = u64::try_from(initial.as_millis()).unwrap_or(u64::MAX);
let scaled = precision_safe_mul(initial_ms, factor);
Duration::from_millis(scaled).min(*max)
}
}
}
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn precision_safe_mul(base: u64, factor: f64) -> u64 {
if !factor.is_finite() || factor <= 0.0 {
return if factor <= 0.0 { base } else { u64::MAX };
}
let product = (base as f64) * factor;
if product >= u64::MAX as f64 {
u64::MAX
} else {
product as u64
}
}
pub(super) fn retry_policy_from_config(config: &str) -> Option<RetryPolicy> {
let value: serde_json::Value = serde_json::from_str(config).ok()?;
let retry = value.get("retry")?;
if retry.is_null() {
return None;
}
let policy = decode_policy(retry);
if policy.is_none() {
tracing::warn!(
retry = %retry,
"malformed SDK retry policy in dispatch config; treating the activity as \
single-attempt (no retries)"
);
}
policy
}
fn decode_policy(retry: &serde_json::Value) -> Option<RetryPolicy> {
let max_attempts = u32::try_from(retry.get("max_attempts")?.as_u64()?).ok()?;
if max_attempts == 0 {
return None;
}
let backoff = retry.get("backoff")?;
let kind = backoff.get("kind")?.as_str()?;
let backoff = match kind {
"exponential" => Backoff::Exponential {
initial: millis_field(backoff, "initial_ms")?,
multiplier: backoff.get("multiplier")?.as_f64()?,
max: millis_field(backoff, "max_ms")?,
},
"linear" => Backoff::Linear {
initial: millis_field(backoff, "initial_ms")?,
increment: millis_field(backoff, "increment_ms")?,
max: millis_field(backoff, "max_ms")?,
},
"fixed" => Backoff::Fixed {
delay: millis_field(backoff, "delay_ms")?,
},
_ => return None,
};
Some(RetryPolicy {
max_attempts,
backoff,
})
}
fn millis_field(value: &serde_json::Value, field: &str) -> Option<Duration> {
Some(Duration::from_millis(value.get(field)?.as_u64()?))
}
pub(super) fn activity_settled(history: &[Event], activity_id: &ActivityId) -> bool {
for event in history.iter().rev() {
match event {
Event::ActivityCompleted {
activity_id: id, ..
}
| Event::ActivityCancelled {
activity_id: id, ..
} if id == activity_id => return true,
Event::ActivityFailed {
activity_id: id,
error,
..
} if id == activity_id && !error.is_retryable() => return true,
Event::WorkflowReopened { reopened, .. } if reopened.contains(activity_id) => {
return false;
}
Event::WorkflowCompleted { .. }
| Event::WorkflowFailed { .. }
| Event::WorkflowCancelled { .. }
| Event::WorkflowTimedOut { .. }
| Event::WorkflowContinuedAsNew { .. } => return true,
_ => {}
}
}
false
}
pub(super) fn next_delivery_attempt(history: &[Event], activity_id: &ActivityId) -> u32 {
latest_recorded_attempt(history, activity_id).map_or(FIRST_DELIVERY_ATTEMPT, |attempt| {
attempt.saturating_add(1).max(FIRST_DELIVERY_ATTEMPT)
})
}
pub(super) fn latest_recorded_attempt(history: &[Event], activity_id: &ActivityId) -> Option<u32> {
history
.iter()
.filter_map(|event| match event {
Event::ActivityStarted {
activity_id: id,
attempt,
..
}
| Event::ActivityFailed {
activity_id: id,
attempt,
..
}
| Event::ActivityCompleted {
activity_id: id,
attempt,
..
} if id == activity_id => Some(*attempt),
_ => None,
})
.max()
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use aion_core::{
ActivityError, ActivityErrorKind, ActivityId, ContentType, Event, EventEnvelope, Payload,
WorkflowId,
};
use super::{
Backoff, PARKED_ACTIVITY_REASON, RetryPolicy, activity_settled, is_parked_reason,
is_retryable_reason, latest_recorded_attempt, next_delivery_attempt,
retry_policy_from_config,
};
fn config_with(retry: &str) -> String {
format!(r#"{{"retry":{retry},"timeout_ms":null,"labels":{{}}}}"#)
}
#[test]
fn absent_null_and_malformed_policies_decode_to_no_retries() {
assert_eq!(retry_policy_from_config("{}"), None);
assert_eq!(retry_policy_from_config(&config_with("null")), None);
assert_eq!(retry_policy_from_config("not json"), None);
assert_eq!(
retry_policy_from_config(&config_with(r#"{"max_attempts":3}"#)),
None
);
assert_eq!(
retry_policy_from_config(&config_with(
r#"{"max_attempts":3,"backoff":{"kind":"warp","delay_ms":5}}"#
)),
None
);
assert_eq!(
retry_policy_from_config(&config_with(
r#"{"max_attempts":0,"backoff":{"kind":"fixed","delay_ms":5}}"#
)),
None
);
}
#[test]
fn sdk_shaped_policies_decode_exactly() {
assert_eq!(
retry_policy_from_config(&config_with(
r#"{"max_attempts":3,"backoff":{"kind":"fixed","delay_ms":50}}"#
)),
Some(RetryPolicy {
max_attempts: 3,
backoff: Backoff::Fixed {
delay: Duration::from_millis(50)
},
})
);
assert_eq!(
retry_policy_from_config(&config_with(
r#"{"max_attempts":5,"backoff":{"kind":"exponential","initial_ms":100,"multiplier":2.0,"max_ms":1000}}"#
)),
Some(RetryPolicy {
max_attempts: 5,
backoff: Backoff::Exponential {
initial: Duration::from_millis(100),
multiplier: 2.0,
max: Duration::from_secs(1),
},
})
);
assert_eq!(
retry_policy_from_config(&config_with(
r#"{"max_attempts":4,"backoff":{"kind":"linear","initial_ms":10,"increment_ms":20,"max_ms":45}}"#
)),
Some(RetryPolicy {
max_attempts: 4,
backoff: Backoff::Linear {
initial: Duration::from_millis(10),
increment: Duration::from_millis(20),
max: Duration::from_millis(45),
},
})
);
}
#[test]
fn backoff_delays_grow_and_cap() {
let exponential = Backoff::Exponential {
initial: Duration::from_millis(100),
multiplier: 2.0,
max: Duration::from_millis(350),
};
assert_eq!(exponential.delay_after(1), Duration::from_millis(100));
assert_eq!(exponential.delay_after(2), Duration::from_millis(200));
assert_eq!(exponential.delay_after(3), Duration::from_millis(350));
let linear = Backoff::Linear {
initial: Duration::from_millis(10),
increment: Duration::from_millis(20),
max: Duration::from_millis(45),
};
assert_eq!(linear.delay_after(1), Duration::from_millis(10));
assert_eq!(linear.delay_after(2), Duration::from_millis(30));
assert_eq!(linear.delay_after(3), Duration::from_millis(45));
let fixed = Backoff::Fixed {
delay: Duration::from_millis(7),
};
assert_eq!(fixed.delay_after(1), Duration::from_millis(7));
assert_eq!(fixed.delay_after(9), Duration::from_millis(7));
}
#[test]
fn reason_classification_follows_the_wire_prefix_only() {
assert!(is_retryable_reason("retryable:boom"));
assert!(!is_retryable_reason("terminal:boom"));
assert!(!is_retryable_reason("timeout:deadline expired"));
assert!(!is_retryable_reason("cancelled:operator"));
assert!(!is_retryable_reason("unprefixed engine failure"));
}
#[test]
fn parked_classification_is_prefix_scoped_and_disjoint_from_retryable() {
assert!(is_parked_reason(PARKED_ACTIVITY_REASON));
assert!(is_parked_reason("parked:other-drain-vocabulary"));
assert!(!is_parked_reason("retryable:worker lost"));
assert!(!is_parked_reason("terminal:boom"));
assert!(!is_parked_reason("unprefixed engine failure"));
assert!(!is_retryable_reason(PARKED_ACTIVITY_REASON));
}
fn envelope(seq: u64) -> EventEnvelope {
EventEnvelope {
seq,
recorded_at: chrono::Utc::now(),
workflow_id: WorkflowId::new_v4(),
}
}
fn started(seq: u64, ordinal: u64, attempt: u32) -> Event {
Event::ActivityStarted {
envelope: envelope(seq),
activity_id: ActivityId::from_sequence_position(ordinal),
attempt,
}
}
fn failed(seq: u64, ordinal: u64, attempt: u32, kind: ActivityErrorKind) -> Event {
Event::ActivityFailed {
envelope: envelope(seq),
activity_id: ActivityId::from_sequence_position(ordinal),
error: ActivityError {
kind,
message: "boom".to_owned(),
details: None,
},
attempt,
}
}
fn completed(seq: u64, ordinal: u64, attempt: u32) -> Event {
Event::ActivityCompleted {
envelope: envelope(seq),
activity_id: ActivityId::from_sequence_position(ordinal),
result: Payload::new(ContentType::Json, br#""r""#.to_vec()),
attempt,
}
}
#[test]
fn settlement_tracks_terminal_outcomes_not_retryable_attempts() {
let target = ActivityId::from_sequence_position(0);
assert!(!activity_settled(
&[
started(1, 0, 1),
failed(2, 0, 1, ActivityErrorKind::Retryable)
],
&target
));
assert!(activity_settled(
&[failed(2, 0, 1, ActivityErrorKind::Terminal)],
&target
));
assert!(activity_settled(&[completed(3, 0, 2)], &target));
assert!(!activity_settled(
&[failed(2, 7, 1, ActivityErrorKind::Terminal)],
&target
));
assert!(activity_settled(
&[Event::WorkflowFailed {
envelope: envelope(4),
error: aion_core::WorkflowError {
message: "done".to_owned(),
details: None,
},
}],
&target
));
assert!(!activity_settled(
&[
failed(2, 0, 3, ActivityErrorKind::Terminal),
Event::WorkflowFailed {
envelope: envelope(3),
error: aion_core::WorkflowError {
message: "exhausted".to_owned(),
details: None,
},
},
Event::WorkflowReopened {
envelope: envelope(4),
run_id: aion_core::RunId::new_v4(),
reopened: vec![target.clone()],
},
],
&target
));
assert!(activity_settled(
&[
failed(2, 0, 3, ActivityErrorKind::Terminal),
Event::WorkflowReopened {
envelope: envelope(4),
run_id: aion_core::RunId::new_v4(),
reopened: vec![ActivityId::from_sequence_position(9)],
},
],
&target
));
}
#[test]
fn next_attempt_continues_the_recorded_trail() {
let target = ActivityId::from_sequence_position(0);
assert_eq!(next_delivery_attempt(&[], &target), 1);
assert_eq!(
next_delivery_attempt(
&[
started(1, 0, 1),
failed(2, 0, 1, ActivityErrorKind::Retryable),
started(3, 0, 2),
failed(4, 0, 2, ActivityErrorKind::Retryable),
],
&target
),
3
);
assert_eq!(next_delivery_attempt(&[started(1, 0, 0)], &target), 1);
assert_eq!(next_delivery_attempt(&[started(1, 9, 4)], &target), 1);
assert_eq!(
latest_recorded_attempt(&[started(1, 0, 2), completed(2, 0, 2)], &target),
Some(2)
);
}
}