use std::fmt;
use std::future::Future;
use std::time::Duration;
use super::maybe_send::{MaybeSend, MaybeSync};
use super::typed::{ChunkGet, ChunkHas, ChunkPut};
use crate::chunk::{AnyChunk, ChunkAddress};
pub trait Sleeper: MaybeSend + MaybeSync {
fn sleep(&self, dur: Duration) -> impl Future<Output = ()> + MaybeSend;
}
#[derive(Clone, Copy, Debug)]
pub struct RetryConfig {
pub max_attempts: u32,
pub base_backoff: Duration,
pub backoff_cap: Duration,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_attempts: 8,
base_backoff: Duration::from_millis(150),
backoff_cap: Duration::from_secs(8),
}
}
}
impl RetryConfig {
fn backoff_for(&self, attempt: u32, address: &ChunkAddress) -> Duration {
let shift = attempt.saturating_sub(1).min(16);
let scaled = self.base_backoff.saturating_mul(1u32 << shift);
let capped = scaled.min(self.backoff_cap);
let jitter = capped
.mul_f64(0.5 * jitter_unit(address))
.min(self.backoff_cap);
capped.saturating_add(jitter)
}
}
fn jitter_unit(address: &ChunkAddress) -> f64 {
use web_time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.subsec_nanos());
let addr_mix = address
.as_bytes()
.iter()
.take(4)
.fold(0u32, |acc, &b| (acc << 8) | u32::from(b));
f64::from(nanos ^ addr_mix) / (f64::from(u32::MAX) + 1.0)
}
#[derive(Clone)]
pub struct RetryingChunkGet<G, S> {
inner: G,
sleeper: S,
config: RetryConfig,
}
impl<G, S> fmt::Debug for RetryingChunkGet<G, S> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RetryingChunkGet")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl<G, S> RetryingChunkGet<G, S> {
pub const fn new(inner: G, sleeper: S, config: RetryConfig) -> Self {
Self {
inner,
sleeper,
config,
}
}
pub fn with_default(inner: G, sleeper: S) -> Self {
Self::new(inner, sleeper, RetryConfig::default())
}
}
impl<const BS: usize, G: ChunkGet<BS>, S: Sleeper> ChunkGet<BS> for RetryingChunkGet<G, S> {
type Error = G::Error;
async fn get(&self, address: &ChunkAddress) -> Result<AnyChunk<BS>, Self::Error> {
let mut attempt = 1;
loop {
match self.inner.get(address).await {
Ok(chunk) => return Ok(chunk),
Err(e) => {
if attempt >= self.config.max_attempts {
return Err(e);
}
self.sleeper
.sleep(self.config.backoff_for(attempt, address))
.await;
attempt += 1;
}
}
}
}
}
impl<const BS: usize, G: ChunkPut<BS>, S: MaybeSend + MaybeSync> ChunkPut<BS>
for RetryingChunkGet<G, S>
{
type Error = G::Error;
async fn put(&self, chunk: AnyChunk<BS>) -> Result<(), Self::Error> {
self.inner.put(chunk).await
}
}
impl<const BS: usize, G: ChunkHas<BS>, S: MaybeSend + MaybeSync> ChunkHas<BS>
for RetryingChunkGet<G, S>
{
async fn has(&self, address: &ChunkAddress) -> bool {
self.inner.has(address).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use futures::executor::block_on;
use crate::DefaultContentChunk;
use crate::bmt::DEFAULT_BODY_SIZE;
struct NoSleep;
impl Sleeper for NoSleep {
async fn sleep(&self, _dur: Duration) {}
}
#[derive(Debug, thiserror::Error)]
#[error("transient")]
struct Transient;
struct FlakyStore {
chunk: AnyChunk<DEFAULT_BODY_SIZE>,
remaining_failures: Mutex<u32>,
get_calls: AtomicU32,
put_calls: AtomicU32,
has_calls: AtomicU32,
}
impl FlakyStore {
fn new(remaining_failures: u32) -> Self {
let chunk = DefaultContentChunk::new("retry probe")
.expect("build content chunk")
.into();
Self {
chunk,
remaining_failures: Mutex::new(remaining_failures),
get_calls: AtomicU32::new(0),
put_calls: AtomicU32::new(0),
has_calls: AtomicU32::new(0),
}
}
}
impl ChunkGet<DEFAULT_BODY_SIZE> for FlakyStore {
type Error = Transient;
async fn get(
&self,
_address: &ChunkAddress,
) -> Result<AnyChunk<DEFAULT_BODY_SIZE>, Self::Error> {
self.get_calls.fetch_add(1, Ordering::SeqCst);
let mut left = self.remaining_failures.lock().expect("lock");
if *left > 0 {
*left -= 1;
return Err(Transient);
}
Ok(self.chunk.clone())
}
}
impl ChunkPut<DEFAULT_BODY_SIZE> for FlakyStore {
type Error = Transient;
async fn put(&self, _chunk: AnyChunk<DEFAULT_BODY_SIZE>) -> Result<(), Self::Error> {
self.put_calls.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
impl ChunkHas<DEFAULT_BODY_SIZE> for FlakyStore {
async fn has(&self, _address: &ChunkAddress) -> bool {
self.has_calls.fetch_add(1, Ordering::SeqCst);
true
}
}
#[test]
fn recovers_when_failures_below_budget() {
let store = RetryingChunkGet::with_default(FlakyStore::new(7), NoSleep);
let address = *store.inner.chunk.address();
let got = block_on(store.get(&address)).expect("recovered within budget");
assert_eq!(got.address(), &address);
assert_eq!(store.inner.get_calls.load(Ordering::SeqCst), 8);
}
#[test]
fn propagates_after_exactly_max_attempts() {
let store = RetryingChunkGet::with_default(FlakyStore::new(u32::MAX), NoSleep);
let address = *store.inner.chunk.address();
let err = block_on(store.get(&address));
assert!(err.is_err(), "budget exhausted, error must propagate");
assert_eq!(
store.inner.get_calls.load(Ordering::SeqCst),
RetryConfig::default().max_attempts
);
}
#[test]
fn put_and_has_are_not_retried() {
let store = RetryingChunkGet::with_default(FlakyStore::new(u32::MAX), NoSleep);
let address = *store.inner.chunk.address();
assert!(block_on(store.has(&address)));
block_on(store.put(store.inner.chunk.clone())).expect("put delegates");
assert_eq!(store.inner.has_calls.load(Ordering::SeqCst), 1);
assert_eq!(store.inner.put_calls.load(Ordering::SeqCst), 1);
}
}