use crate::{FalkorDBError, FalkorResult};
use std::time::Duration;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum RetryScope {
Disabled,
ReadOnly,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Backoff {
base: Duration,
factor: f64,
max_delay: Duration,
jitter: bool,
}
impl Backoff {
#[must_use]
pub fn fixed(delay: Duration) -> Self {
Self {
base: delay,
factor: 1.0,
max_delay: delay,
jitter: false,
}
}
#[must_use]
pub fn exponential(base: Duration) -> Self {
Self {
base,
factor: 2.0,
max_delay: Duration::from_secs(1),
jitter: true,
}
}
#[must_use]
pub fn factor(
mut self,
factor: f64,
) -> Self {
self.factor = factor.max(1.0);
self
}
#[must_use]
pub fn max_delay(
mut self,
max_delay: Duration,
) -> Self {
self.max_delay = max_delay;
self
}
#[must_use]
pub fn jitter(
mut self,
jitter: bool,
) -> Self {
self.jitter = jitter;
self
}
}
impl Default for Backoff {
fn default() -> Self {
Self::exponential(Duration::from_millis(50))
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RetryPolicy {
scope: RetryScope,
max_attempts: u32,
backoff: Backoff,
}
impl RetryPolicy {
#[must_use]
pub fn disabled() -> Self {
Self {
scope: RetryScope::Disabled,
max_attempts: 1,
backoff: Backoff::default(),
}
}
#[must_use]
pub fn read_only() -> Self {
Self {
scope: RetryScope::ReadOnly,
max_attempts: 3,
backoff: Backoff::default(),
}
}
#[must_use]
pub fn max_attempts(
mut self,
attempts: u32,
) -> Self {
self.max_attempts = attempts.max(1);
self
}
#[must_use]
pub fn backoff(
mut self,
backoff: Backoff,
) -> Self {
self.backoff = backoff;
self
}
#[must_use]
pub fn scope(&self) -> RetryScope {
self.scope
}
}
impl Default for RetryPolicy {
fn default() -> Self {
Self::disabled()
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum OpKind {
ReadOnly,
Write,
}
impl RetryPolicy {
pub(crate) fn is_disabled(&self) -> bool {
self.scope == RetryScope::Disabled || self.max_attempts <= 1
}
fn is_retryable_error(err: &FalkorDBError) -> bool {
matches!(
err,
FalkorDBError::ConnectionDown
| FalkorDBError::NoConnection
| FalkorDBError::EmptyConnection
| FalkorDBError::SentinelConnection(_)
)
}
pub(crate) fn should_retry(
&self,
kind: OpKind,
err: &FalkorDBError,
) -> bool {
match self.scope {
RetryScope::Disabled => false,
RetryScope::ReadOnly => kind == OpKind::ReadOnly && Self::is_retryable_error(err),
}
}
pub(crate) fn backon_builder(&self) -> FalkorBackoffBuilder {
FalkorBackoffBuilder {
base: self.backoff.base,
factor: self.backoff.factor,
max_delay: self.backoff.max_delay,
max_attempts: self.max_attempts,
jitter: self.backoff.jitter,
}
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct FalkorBackoffBuilder {
base: Duration,
factor: f64,
max_delay: Duration,
max_attempts: u32,
jitter: bool,
}
impl backon::BackoffBuilder for FalkorBackoffBuilder {
type Backoff = FalkorBackoff;
fn build(self) -> Self::Backoff {
FalkorBackoff {
next_base: self.base.as_secs_f64(),
factor: self.factor,
max_delay: self.max_delay.as_secs_f64(),
remaining: self.max_attempts.saturating_sub(1),
jitter: self.jitter,
rng: if self.jitter { jitter_seed() } else { 0 },
}
}
}
#[derive(Debug)]
pub(crate) struct FalkorBackoff {
next_base: f64,
factor: f64,
max_delay: f64,
remaining: u32,
jitter: bool,
rng: u64,
}
impl Iterator for FalkorBackoff {
type Item = Duration;
fn next(&mut self) -> Option<Duration> {
if self.remaining == 0 {
return None;
}
self.remaining -= 1;
let capped = self.next_base.min(self.max_delay);
self.next_base = (self.next_base * self.factor).min(self.max_delay);
let delay = if self.jitter {
capped * next_unit_f64(&mut self.rng)
} else {
capped
};
Some(Duration::from_secs_f64(delay))
}
}
fn jitter_seed() -> u64 {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
(nanos ^ counter.wrapping_mul(0x9E37_79B9_7F4A_7C15)) | 1
}
fn next_unit_f64(state: &mut u64) -> f64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
(z >> 11) as f64 / (1u64 << 53) as f64
}
pub(crate) fn op_kind_for_command(command: &str) -> OpKind {
match command {
"GRAPH.RO_QUERY" | "GRAPH.EXPLAIN" => OpKind::ReadOnly,
_ => OpKind::Write,
}
}
pub(crate) fn run_with_retry_blocking<T>(
policy: &RetryPolicy,
kind: OpKind,
mut attempt: impl FnMut() -> FalkorResult<T>,
) -> FalkorResult<T> {
if policy.is_disabled() {
return attempt();
}
use backon::BlockingRetryable as _;
attempt
.retry(policy.backon_builder())
.when(|err: &FalkorDBError| policy.should_retry(kind, err))
.notify(|err: &FalkorDBError, _delay: Duration| {
#[cfg(any(feature = "tracing", feature = "metrics"))]
crate::observability::record_retry(matches!(kind, OpKind::ReadOnly), err);
#[cfg(not(any(feature = "tracing", feature = "metrics")))]
let _ = err;
})
.call()
}
#[cfg(feature = "tokio")]
pub(crate) async fn run_with_retry_async<T, Fut>(
policy: &RetryPolicy,
kind: OpKind,
mut attempt: impl FnMut() -> Fut,
) -> FalkorResult<T>
where
Fut: std::future::Future<Output = FalkorResult<T>>,
{
if policy.is_disabled() {
return attempt().await;
}
use backon::Retryable as _;
attempt
.retry(policy.backon_builder())
.when(|err: &FalkorDBError| policy.should_retry(kind, err))
.notify(|err: &FalkorDBError, _delay: Duration| {
#[cfg(any(feature = "tracing", feature = "metrics"))]
crate::observability::record_retry(matches!(kind, OpKind::ReadOnly), err);
#[cfg(not(any(feature = "tracing", feature = "metrics")))]
let _ = err;
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_policy_is_disabled() {
assert_eq!(RetryPolicy::default(), RetryPolicy::disabled());
assert_eq!(RetryPolicy::default().scope(), RetryScope::Disabled);
assert!(RetryPolicy::default().is_disabled());
}
#[test]
fn read_only_preset_is_enabled() {
let policy = RetryPolicy::read_only();
assert_eq!(policy.scope(), RetryScope::ReadOnly);
assert!(!policy.is_disabled());
}
#[test]
fn max_attempts_one_disables_retry() {
let policy = RetryPolicy::read_only().max_attempts(1);
assert!(policy.is_disabled());
}
#[test]
fn max_attempts_is_clamped_to_at_least_one() {
assert!(RetryPolicy::read_only().max_attempts(0).is_disabled());
}
#[test]
fn disabled_scope_never_retries_any_error() {
let policy = RetryPolicy::disabled();
for err in retryable_errors() {
assert!(!policy.should_retry(OpKind::ReadOnly, &err));
assert!(!policy.should_retry(OpKind::Write, &err));
}
}
#[test]
fn read_only_retries_read_ops_on_transient_errors() {
let policy = RetryPolicy::read_only();
for err in retryable_errors() {
assert!(
policy.should_retry(OpKind::ReadOnly, &err),
"expected retry for read op on {err:?}"
);
}
}
#[test]
fn read_only_never_retries_writes() {
let policy = RetryPolicy::read_only();
for err in retryable_errors() {
assert!(
!policy.should_retry(OpKind::Write, &err),
"writes must never be retried, even on {err:?}"
);
}
}
#[test]
fn non_transient_errors_are_not_retried() {
let policy = RetryPolicy::read_only();
for err in non_retryable_errors() {
assert!(
!policy.should_retry(OpKind::ReadOnly, &err),
"non-transient error must not be retried: {err:?}"
);
}
}
#[test]
fn connection_down_healed_into_no_connection_is_still_retryable_for_reads() {
let policy = RetryPolicy::read_only();
assert!(policy.should_retry(OpKind::ReadOnly, &FalkorDBError::NoConnection));
assert!(!policy.should_retry(OpKind::Write, &FalkorDBError::NoConnection));
}
#[test]
fn command_classification_table() {
assert_eq!(op_kind_for_command("GRAPH.RO_QUERY"), OpKind::ReadOnly);
assert_eq!(op_kind_for_command("GRAPH.EXPLAIN"), OpKind::ReadOnly);
assert_eq!(op_kind_for_command("GRAPH.QUERY"), OpKind::Write);
assert_eq!(op_kind_for_command("GRAPH.PROFILE"), OpKind::Write);
assert_eq!(op_kind_for_command("SOMETHING.NEW"), OpKind::Write);
}
#[test]
fn backoff_yields_max_attempts_minus_one_delays() {
use backon::BackoffBuilder as _;
let delays: Vec<Duration> = RetryPolicy::read_only()
.max_attempts(5)
.backon_builder()
.build()
.collect();
assert_eq!(
delays.len(),
4,
"N total attempts means N-1 inter-attempt delays"
);
}
#[test]
fn backoff_exponential_without_jitter_grows_and_caps() {
use backon::BackoffBuilder as _;
let delays: Vec<Duration> = RetryPolicy::read_only()
.max_attempts(6)
.backoff(
Backoff::exponential(Duration::from_millis(50))
.max_delay(Duration::from_millis(180))
.jitter(false),
)
.backon_builder()
.build()
.collect();
let cap = Duration::from_millis(180);
for pair in delays.windows(2) {
assert!(
pair[0] <= pair[1],
"delays must be non-decreasing: {delays:?}"
);
}
assert!(
delays.iter().all(|d| *d <= cap),
"no delay may exceed max_delay: {delays:?}"
);
assert!(
delays[2] == cap && delays[delays.len() - 1] == cap,
"tail must sit at the cap"
);
}
#[test]
fn backoff_full_jitter_never_exceeds_cap() {
use backon::BackoffBuilder as _;
let cap = Duration::from_millis(40);
let delays: Vec<Duration> = RetryPolicy::read_only()
.max_attempts(64)
.backoff(
Backoff::exponential(Duration::from_millis(10))
.max_delay(cap)
.jitter(true),
)
.backon_builder()
.build()
.collect();
assert_eq!(delays.len(), 63);
assert!(
delays.iter().all(|d| *d <= cap),
"full jitter must never exceed max_delay: {delays:?}"
);
}
#[test]
fn backoff_fixed_is_constant() {
use backon::BackoffBuilder as _;
let delays: Vec<Duration> = RetryPolicy::read_only()
.max_attempts(4)
.backoff(Backoff::fixed(Duration::from_millis(25)))
.backon_builder()
.build()
.collect();
assert_eq!(delays, vec![Duration::from_millis(25); 3]);
}
fn retryable_errors() -> Vec<FalkorDBError> {
vec![
FalkorDBError::ConnectionDown,
FalkorDBError::NoConnection,
FalkorDBError::EmptyConnection,
FalkorDBError::SentinelConnection("sentinel down".into()),
]
}
fn non_retryable_errors() -> Vec<FalkorDBError> {
vec![
FalkorDBError::RedisError("ERR syntax error".into()),
FalkorDBError::ParsingError("bad reply".into()),
FalkorDBError::InvalidDataReceived,
FalkorDBError::SentinelMastersCount,
FalkorDBError::Timeout {
operation: crate::WaitOperation::IndexCreation,
timeout: Duration::from_secs(5),
},
]
}
}
#[cfg(test)]
mod blocking_runner_tests {
use super::*;
use std::cell::Cell;
fn fast_read_only(max_attempts: u32) -> RetryPolicy {
RetryPolicy::read_only()
.max_attempts(max_attempts)
.backoff(Backoff::fixed(Duration::ZERO))
}
#[test]
fn disabled_attempts_exactly_once_even_on_transient_error() {
let calls = Cell::new(0);
let result: FalkorResult<()> =
run_with_retry_blocking(&RetryPolicy::disabled(), OpKind::ReadOnly, || {
calls.set(calls.get() + 1);
Err(FalkorDBError::ConnectionDown)
});
assert!(matches!(result, Err(FalkorDBError::ConnectionDown)));
assert_eq!(calls.get(), 1, "disabled policy must attempt exactly once");
}
#[test]
fn read_only_retries_until_success() {
let calls = Cell::new(0);
let result = run_with_retry_blocking(&fast_read_only(3), OpKind::ReadOnly, || {
calls.set(calls.get() + 1);
if calls.get() < 3 {
Err(FalkorDBError::ConnectionDown)
} else {
Ok(42)
}
});
assert_eq!(result.expect("should eventually succeed"), 42);
assert_eq!(calls.get(), 3);
}
#[test]
fn write_is_never_retried() {
let calls = Cell::new(0);
let result: FalkorResult<()> =
run_with_retry_blocking(&fast_read_only(5), OpKind::Write, || {
calls.set(calls.get() + 1);
Err(FalkorDBError::ConnectionDown)
});
assert!(matches!(result, Err(FalkorDBError::ConnectionDown)));
assert_eq!(
calls.get(),
1,
"writes must never be retried under ReadOnly scope"
);
}
#[test]
fn exhausts_max_attempts_then_returns_last_error() {
let calls = Cell::new(0);
let result: FalkorResult<()> =
run_with_retry_blocking(&fast_read_only(4), OpKind::ReadOnly, || {
calls.set(calls.get() + 1);
Err(FalkorDBError::ConnectionDown)
});
assert!(matches!(result, Err(FalkorDBError::ConnectionDown)));
assert_eq!(calls.get(), 4, "should attempt exactly max_attempts times");
}
#[test]
fn non_transient_error_is_not_retried() {
let calls = Cell::new(0);
let result: FalkorResult<()> =
run_with_retry_blocking(&fast_read_only(5), OpKind::ReadOnly, || {
calls.set(calls.get() + 1);
Err(FalkorDBError::RedisError("ERR syntax".into()))
});
assert!(matches!(result, Err(FalkorDBError::RedisError(_))));
assert_eq!(calls.get(), 1);
}
#[test]
fn connection_down_healed_into_no_connection_is_still_retried_for_reads() {
let calls = Cell::new(0);
let result = run_with_retry_blocking(&fast_read_only(3), OpKind::ReadOnly, || {
calls.set(calls.get() + 1);
match calls.get() {
1 => Err(FalkorDBError::ConnectionDown),
2 => Err(FalkorDBError::NoConnection),
_ => Ok("ok"),
}
});
assert_eq!(result.expect("should succeed after healing"), "ok");
assert_eq!(calls.get(), 3);
}
}
#[cfg(all(test, feature = "tokio"))]
mod async_runner_tests {
use super::*;
use std::cell::Cell;
fn fast_read_only(max_attempts: u32) -> RetryPolicy {
RetryPolicy::read_only()
.max_attempts(max_attempts)
.backoff(Backoff::fixed(Duration::ZERO))
}
#[tokio::test(flavor = "multi_thread")]
async fn disabled_attempts_exactly_once() {
let calls = Cell::new(0);
let result: FalkorResult<()> =
run_with_retry_async(&RetryPolicy::disabled(), OpKind::ReadOnly, || {
calls.set(calls.get() + 1);
async { Err(FalkorDBError::ConnectionDown) }
})
.await;
assert!(matches!(result, Err(FalkorDBError::ConnectionDown)));
assert_eq!(calls.get(), 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn read_only_retries_until_success() {
let calls = Cell::new(0);
let result = run_with_retry_async(&fast_read_only(3), OpKind::ReadOnly, || {
calls.set(calls.get() + 1);
let attempt = calls.get();
async move {
if attempt < 3 {
Err(FalkorDBError::ConnectionDown)
} else {
Ok(7)
}
}
})
.await;
assert_eq!(result.expect("should eventually succeed"), 7);
assert_eq!(calls.get(), 3);
}
#[tokio::test(flavor = "multi_thread")]
async fn write_is_never_retried() {
let calls = Cell::new(0);
let result: FalkorResult<()> =
run_with_retry_async(&fast_read_only(5), OpKind::Write, || {
calls.set(calls.get() + 1);
async { Err(FalkorDBError::ConnectionDown) }
})
.await;
assert!(matches!(result, Err(FalkorDBError::ConnectionDown)));
assert_eq!(calls.get(), 1, "writes must never be retried");
}
#[tokio::test(flavor = "multi_thread")]
async fn exhausts_max_attempts_then_returns_last_error() {
let calls = Cell::new(0);
let result: FalkorResult<()> =
run_with_retry_async(&fast_read_only(4), OpKind::ReadOnly, || {
calls.set(calls.get() + 1);
async { Err(FalkorDBError::ConnectionDown) }
})
.await;
assert!(matches!(result, Err(FalkorDBError::ConnectionDown)));
assert_eq!(calls.get(), 4);
}
}