use std::future::Future;
use std::time::Duration;
use super::{BackendErrorKind, Error};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RetryPolicy {
pub initial_backoff: Duration,
pub max_backoff: Duration,
pub retry_connection_errors: bool,
pub retry_conflicts: bool,
pub conflict_initial_backoff: Duration,
pub conflict_max_backoff: Duration,
}
impl Default for RetryPolicy {
fn default() -> Self {
Self {
initial_backoff: Duration::from_secs(1),
max_backoff: Duration::from_secs(60),
retry_connection_errors: true,
retry_conflicts: true,
conflict_initial_backoff: Duration::from_millis(50),
conflict_max_backoff: Duration::from_secs(2),
}
}
}
impl RetryPolicy {
pub(crate) fn without_conflict_replay(&self) -> Self {
Self {
retry_conflicts: false,
..*self
}
}
}
pub(crate) async fn with_retry<T, F, Fut>(
policy: &RetryPolicy,
operation: &str,
mut work: F,
) -> Result<T, Error>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, Error>> + Send,
{
let mut backoff = policy.initial_backoff;
let mut conflict_backoff = policy.conflict_initial_backoff;
let mut attempt: u32 = 0;
loop {
let error = match work().await {
Ok(value) => return Ok(value),
Err(error) => error,
};
attempt += 1;
let kind = match &error {
Error::Backend(backend) => backend.kind,
_ => return Err(error),
};
let (delay, loud) = match kind {
BackendErrorKind::Permanent => return Err(error),
BackendErrorKind::Connection if !policy.retry_connection_errors => return Err(error),
BackendErrorKind::Connection => {
let delay = jitter(backoff);
backoff = (backoff * 2).min(policy.max_backoff);
(delay, true)
}
BackendErrorKind::Conflict if !policy.retry_conflicts => return Err(error),
BackendErrorKind::Conflict => {
let loud = conflict_backoff >= policy.conflict_max_backoff;
let delay = jitter(conflict_backoff);
conflict_backoff = (conflict_backoff * 2).min(policy.conflict_max_backoff);
(delay, loud)
}
};
let delay_ms = delay.as_millis() as u64;
if loud {
tracing::warn!(
operation,
attempt,
delay_ms,
error = %error,
"system database operation failed; retrying"
);
} else {
tracing::debug!(
operation,
attempt,
delay_ms,
error = %error,
"system database operation lost a transaction conflict; replaying"
);
}
crate::rt::sleep(delay).await;
}
}
fn jitter(backoff: Duration) -> Duration {
let bits = uuid::Uuid::new_v4().as_u128() as u32;
let factor = 0.5 + f64::from(bits) / f64::from(u32::MAX).next_up();
backoff.mul_f64(factor)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sysdb::BackendError;
use std::sync::atomic::{AtomicU32, Ordering};
fn backend(kind: BackendErrorKind) -> Error {
Error::Backend(BackendError {
message: "boom".to_owned(),
sqlstate: None,
kind,
})
}
fn fast() -> RetryPolicy {
RetryPolicy {
initial_backoff: Duration::from_millis(1),
max_backoff: Duration::from_millis(4),
conflict_initial_backoff: Duration::from_millis(1),
conflict_max_backoff: Duration::from_millis(4),
..RetryPolicy::default()
}
}
#[tokio::test]
async fn a_conflict_is_replayed_until_it_succeeds() {
let calls = AtomicU32::new(0);
let calls = &calls;
let result = with_retry(&fast(), "test", move || async move {
let seen = calls.fetch_add(1, Ordering::Relaxed) + 1;
if seen < 3 {
Err(backend(BackendErrorKind::Conflict))
} else {
Ok(seen)
}
})
.await;
assert_eq!(result.unwrap(), 3);
assert_eq!(
calls.load(Ordering::Relaxed),
3,
"should have failed twice before succeeding"
);
}
#[tokio::test]
async fn a_permanent_failure_is_returned_at_once() {
let calls = AtomicU32::new(0);
let calls = &calls;
let result: Result<(), _> = with_retry(&fast(), "test", move || async move {
calls.fetch_add(1, Ordering::Relaxed);
Err(backend(BackendErrorKind::Permanent))
})
.await;
assert!(result.is_err());
assert_eq!(
calls.load(Ordering::Relaxed),
1,
"a rejected statement must not be repeated"
);
}
#[tokio::test]
async fn a_semantic_error_is_not_retried() {
let calls = AtomicU32::new(0);
let calls = &calls;
let result: Result<(), _> = with_retry(&fast(), "test", move || async move {
calls.fetch_add(1, Ordering::Relaxed);
Err(Error::ConflictingWorkflow {
workflow_id: "wf-1".to_owned(),
detail: "different function".to_owned(),
})
})
.await;
assert!(matches!(result, Err(Error::ConflictingWorkflow { .. })));
assert_eq!(calls.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn opting_out_covers_connection_errors_but_not_conflicts() {
let policy = RetryPolicy {
retry_connection_errors: false,
..fast()
};
let calls = AtomicU32::new(0);
let calls = &calls;
let result: Result<(), _> = with_retry(&policy, "test", move || async move {
calls.fetch_add(1, Ordering::Relaxed);
Err(backend(BackendErrorKind::Connection))
})
.await;
assert!(result.is_err());
assert_eq!(
calls.load(Ordering::Relaxed),
1,
"the opt-out should have applied"
);
let calls = AtomicU32::new(0);
let calls = &calls;
let result = with_retry(&policy, "test", move || async move {
let seen = calls.fetch_add(1, Ordering::Relaxed) + 1;
if seen < 2 {
Err(backend(BackendErrorKind::Conflict))
} else {
Ok(())
}
})
.await;
assert!(result.is_ok());
assert_eq!(
calls.load(Ordering::Relaxed),
2,
"conflicts ignore the opt-out"
);
}
#[tokio::test]
async fn without_conflict_replay_returns_the_first_conflict() {
let policy = fast().without_conflict_replay();
let calls = AtomicU32::new(0);
let calls = &calls;
let result: Result<(), _> = with_retry(&policy, "test", move || async move {
calls.fetch_add(1, Ordering::Relaxed);
Err(backend(BackendErrorKind::Conflict))
})
.await;
assert!(result.is_err());
assert_eq!(
calls.load(Ordering::Relaxed),
1,
"the conflict was replayed"
);
let calls = AtomicU32::new(0);
let calls = &calls;
let result = with_retry(&policy, "test", move || async move {
let seen = calls.fetch_add(1, Ordering::Relaxed) + 1;
if seen < 3 {
Err(backend(BackendErrorKind::Connection))
} else {
Ok(())
}
})
.await;
assert!(result.is_ok());
assert_eq!(
calls.load(Ordering::Relaxed),
3,
"connection retries stay on"
);
}
#[test]
fn jitter_stays_within_half_and_one_and_a_half() {
for _ in 0..1_000 {
let d = jitter(Duration::from_millis(1_000));
assert!(
(500..1_500).contains(&d.as_millis()),
"jittered delay {d:?} left the [0.5, 1.5) band",
);
}
}
}