Skip to main content

krabka_client_core/
backoff.rs

1//! Kafka's exponential backoff with random jitter.
2
3use std::{
4    hash::{BuildHasher, RandomState},
5    sync::{
6        OnceLock,
7        atomic::{AtomicU64, Ordering},
8    },
9    time::Duration,
10};
11
12use krabka_units::{Time, convert::TimeExt as _};
13
14/// Kafka's `ExponentialBackoff`: `initial * multiplier^attempts`, times a
15/// random factor in `[1 - jitter, 1 + jitter)`, and never above `max`.
16///
17/// Kafka uses it with multiplier 2 and jitter 0.2 for `reconnect.backoff.ms`,
18/// `socket.connection.setup.timeout.ms` and `retry.backoff.ms`
19/// (`ClusterConnectionStates`, `CommonClientConfigs`).
20#[derive(Clone, Copy, Debug, PartialEq)]
21pub struct ExponentialBackoff {
22    initial_ms: f64,
23    multiplier: f64,
24    max_ms: f64,
25    jitter: f64,
26    exp_max: f64,
27}
28
29/// The jitter of Kafka's connection and retry backoffs.
30pub const KAFKA_BACKOFF_JITTER: f64 = 0.2;
31
32/// The multiplier of Kafka's connection and retry backoffs.
33pub const KAFKA_BACKOFF_MULTIPLIER: u32 = 2;
34
35impl ExponentialBackoff {
36    /// Build a backoff. As in Kafka, an `initial` above `max` becomes `max`,
37    /// and a `max` at or below `initial` turns the growth off. A `jitter`
38    /// outside `0.0..=1.0` is clamped to that range.
39    #[must_use]
40    pub fn new(initial: Time, multiplier: u32, max: Time, jitter: f64) -> Self {
41        let jitter = jitter.clamp(0.0, 1.0);
42        let max_ms = max.secs_f64() * 1000.0;
43        let initial_ms = (initial.secs_f64() * 1000.0).min(max_ms);
44        let multiplier = f64::from(multiplier);
45        let exp_max = if max_ms > initial_ms {
46            (max_ms / initial_ms.max(1.0)).log(multiplier)
47        } else {
48            0.0
49        };
50        Self {
51            initial_ms,
52            multiplier,
53            max_ms,
54            jitter,
55            exp_max,
56        }
57    }
58
59    /// Kafka's connection and retry backoff: multiplier 2 and jitter 0.2.
60    #[must_use]
61    pub fn kafka(initial: Time, max: Time) -> Self {
62        Self::new(initial, KAFKA_BACKOFF_MULTIPLIER, max, KAFKA_BACKOFF_JITTER)
63    }
64
65    /// The backoff after `attempts` failures, with a random jitter.
66    #[must_use]
67    pub fn backoff(&self, attempts: u32) -> Duration {
68        self.backoff_with(attempts, random_unit())
69    }
70
71    /// The backoff after `attempts` failures for a random value `unit` in
72    /// `[0, 1)`. `unit` 0.5 gives the backoff without jitter.
73    #[must_use]
74    pub fn backoff_with(&self, attempts: u32, unit: f64) -> Duration {
75        if self.exp_max == 0.0 {
76            return millis_duration(self.initial_ms);
77        }
78        let exp = f64::from(attempts).min(self.exp_max);
79        let term = self.initial_ms * self.multiplier.powf(exp);
80        let factor = if self.jitter < f64::MIN_POSITIVE {
81            1.0
82        } else {
83            (unit * 2.0).mul_add(self.jitter, 1.0 - self.jitter)
84        };
85        // Kafka truncates the jittered value to whole milliseconds.
86        millis_duration((factor * term).trunc().min(self.max_ms))
87    }
88}
89
90fn millis_duration(milliseconds: f64) -> Duration {
91    Duration::from_secs_f64(milliseconds.max(0.0) / 1000.0)
92}
93
94/// A random value in `0..bound`, or 0 for a `bound` of 0.
95pub fn random_below(bound: usize) -> usize {
96    let bound = u128::try_from(bound).unwrap_or(u128::MAX);
97    usize::try_from((u128::from(random_bits()) * bound) >> 32).unwrap_or(0)
98}
99
100/// A random value in `[0, 1)`.
101pub fn random_unit() -> f64 {
102    f64::from(random_bits()) / 4_294_967_296.0
103}
104
105/// 32 random bits. The jitter needs no strong randomness, so a hash of a
106/// counter with a random process key is enough.
107fn random_bits() -> u32 {
108    static COUNTER: AtomicU64 = AtomicU64::new(0);
109    static KEY: OnceLock<RandomState> = OnceLock::new();
110    let hash = KEY
111        .get_or_init(RandomState::new)
112        .hash_one(COUNTER.fetch_add(1, Ordering::Relaxed));
113    u32::try_from(hash >> 32).unwrap_or(u32::MAX)
114}
115#[cfg(test)]
116mod tests {
117    use assert2::check;
118    use krabka_units::{millis, secs};
119
120    use super::*;
121
122    /// `ExponentialBackoffTest` in Kafka: the value doubles per attempt, stays
123    /// within the jitter bounds, and never passes the maximum.
124    #[test]
125    fn backoff_grows_and_caps_as_kafka_does() {
126        let reconnect = ExponentialBackoff::kafka(millis(50), secs(1));
127        let setup = ExponentialBackoff::kafka(secs(10), secs(30));
128        let flat = ExponentialBackoff::kafka(millis(100), millis(100));
129        let above_max = ExponentialBackoff::kafka(secs(2), secs(1));
130        let ms = Duration::from_millis;
131        for (name, backoff, attempts, unit, expected) in [
132            ("reconnect, first", reconnect, 0, 0.5, ms(50)),
133            ("reconnect, second", reconnect, 1, 0.5, ms(100)),
134            ("reconnect, fourth", reconnect, 3, 0.5, ms(400)),
135            ("reconnect, low jitter", reconnect, 3, 0.0, ms(320)),
136            ("reconnect, capped", reconnect, 10, 0.5, ms(1000)),
137            (
138                "reconnect, jitter below the cap",
139                reconnect,
140                4,
141                0.99,
142                ms(956),
143            ),
144            (
145                "reconnect, capped after jitter",
146                reconnect,
147                5,
148                0.99,
149                ms(1000),
150            ),
151            ("setup timeout, first", setup, 0, 0.5, ms(10_000)),
152            ("setup timeout, second", setup, 1, 0.5, ms(20_000)),
153            ("setup timeout, capped", setup, 2, 0.5, ms(30_000)),
154            ("no growth when max equals initial", flat, 5, 0.9, ms(100)),
155            ("initial above max becomes max", above_max, 0, 0.9, ms(1000)),
156        ] {
157            check!(backoff.backoff_with(attempts, unit) == expected, "{name}");
158        }
159    }
160
161    #[test]
162    fn random_backoff_stays_within_the_jitter_bounds() {
163        let backoff = ExponentialBackoff::kafka(millis(50), secs(1));
164        for _ in 0..1000 {
165            let value = backoff.backoff(2);
166            check!(value >= Duration::from_millis(160));
167            check!(value < Duration::from_millis(240));
168        }
169    }
170}