use super::*;
pub(super) struct GatedRetryDispatcher {
gate: std::sync::Mutex<bool>,
opened: std::sync::Condvar,
attempts: std::sync::Mutex<Vec<u32>>,
later: std::sync::Mutex<Result<String, String>>,
}
impl GatedRetryDispatcher {
fn new(later: Result<String, String>) -> Arc<Self> {
Arc::new(Self {
gate: std::sync::Mutex::new(false),
opened: std::sync::Condvar::new(),
attempts: std::sync::Mutex::new(Vec::new()),
later: std::sync::Mutex::new(later),
})
}
fn open(&self) -> Result<(), String> {
let mut gate = self
.gate
.lock()
.map_err(|_| "gate lock poisoned".to_owned())?;
*gate = true;
self.opened.notify_all();
Ok(())
}
fn seen_attempts(&self) -> Vec<u32> {
self.attempts
.lock()
.map(|attempts| attempts.clone())
.unwrap_or_default()
}
}
impl ActivityDispatcher for GatedRetryDispatcher {
fn dispatch(&self, request: ActivityDispatch) -> Result<String, String> {
let first = {
let mut attempts = self
.attempts
.lock()
.map_err(|_| "attempts lock poisoned".to_owned())?;
attempts.push(request.attempt);
attempts.len() == 1
};
if first {
let mut gate = self
.gate
.lock()
.map_err(|_| "gate lock poisoned".to_owned())?;
while !*gate {
gate = self
.opened
.wait(gate)
.map_err(|_| "gate wait poisoned".to_owned())?;
}
return Ok(r#""released too late to matter""#.to_owned());
}
self.later
.lock()
.map_err(|_| "later lock poisoned".to_owned())?
.clone()
}
}
const BOUNDED_RETRY_CONFIG: &str =
r#"{"retry":{"max_attempts":2,"backoff":{"kind":"fixed","delay_ms":2}},"timeout_ms":20}"#;
const BOUNDED_NO_RETRY_CONFIG: &str = r#"{"retry":null,"timeout_ms":20}"#;
const UNBOUNDED_NO_RETRY_CONFIG: &str = r#"{"retry":null,"timeout_ms":null}"#;
#[tokio::test]
async fn an_expired_attempt_is_retried_while_budget_remains() -> TestResult {
let harness = RetryLoopHarness::seeded(BOUNDED_RETRY_CONFIG).await?;
let dispatcher = GatedRetryDispatcher::new(Ok(r#""done""#.to_owned()));
let outcome = super::dispatch_with_retries(
&(Arc::clone(&dispatcher) as Arc<dyn ActivityDispatcher>),
&harness.seam,
&harness.request,
)
.await;
dispatcher.open()?;
assert!(
matches!(
&outcome.terminal,
RetryLoopTerminal::Completed(payload) if payload == r#""done""#
),
"the retry after the expiry must be the delivered outcome: {:?}",
outcome.terminal
);
assert_eq!(outcome.attempt, 2, "the completing attempt is attempt 2");
assert_eq!(
dispatcher.seen_attempts(),
vec![1, 2],
"the expired attempt must be followed by an incremented re-delivery"
);
let history = harness.history().await?;
assert!(
matches!(
history.get(3),
Some(Event::ActivityFailed { error, attempt: 1, .. })
if error.kind == aion_core::ActivityErrorKind::Retryable
&& error.message.starts_with("timeout:")
&& error.message.contains("20ms")
),
"the expiry must be recorded as a non-terminal failure naming the bound: {history:#?}"
);
Ok(())
}
#[tokio::test]
async fn an_expired_attempt_without_a_policy_fails_naming_the_bound() -> TestResult {
let harness = RetryLoopHarness::seeded(BOUNDED_NO_RETRY_CONFIG).await?;
let dispatcher = GatedRetryDispatcher::new(Ok(r#""never reached""#.to_owned()));
let outcome = super::dispatch_with_retries(
&(Arc::clone(&dispatcher) as Arc<dyn ActivityDispatcher>),
&harness.seam,
&harness.request,
)
.await;
dispatcher.open()?;
assert!(
matches!(
&outcome.terminal,
RetryLoopTerminal::Failed(reason)
if reason.starts_with("timeout:") && reason.contains("20ms")
),
"an expiry with no retry budget must fail the activity, and the reason must carry the \
`timeout:` prefix the SDK types `error.ActivityTimedOut` from plus the bound that \
fired: {:?}",
outcome.terminal
);
assert_eq!(
dispatcher.seen_attempts(),
vec![1],
"no policy means no re-delivery"
);
Ok(())
}
#[tokio::test]
async fn an_unbounded_dispatch_is_never_ended_by_the_engine() -> TestResult {
let harness = RetryLoopHarness::seeded(UNBOUNDED_NO_RETRY_CONFIG).await?;
let dispatcher = GatedRetryDispatcher::new(Ok(r#""never reached""#.to_owned()));
let erased = Arc::clone(&dispatcher) as Arc<dyn ActivityDispatcher>;
let loop_future = super::dispatch_with_retries(&erased, &harness.seam, &harness.request);
let still_waiting =
tokio::time::timeout(std::time::Duration::from_millis(200), loop_future).await;
dispatcher.open()?;
assert!(
still_waiting.is_err(),
"an activity whose document declares no timeout must still be waiting: {still_waiting:?}"
);
assert_eq!(
dispatcher.seen_attempts(),
vec![1],
"the single parked attempt was never abandoned"
);
Ok(())
}