Skip to main content

celers_beat/
jitter.rs

1//! Deterministic schedule jitter (thundering-herd avoidance)
2//!
3//! When many scheduled entries share the same fire time (for example a fleet of
4//! `interval(60)` tasks all created at process start, or a crontab firing on the
5//! minute boundary) they would all be dispatched at exactly the same instant.
6//! That synchronized burst — the *thundering herd* — can overload the broker or
7//! downstream services.
8//!
9//! Jitter spreads those fires out by nudging each entry's next-run time by a
10//! small, bounded offset. Crucially the offset here is **deterministic**: it is
11//! derived purely from a stable hash of the entry name together with the target
12//! fire time, scaled into the configured `±window`. It never consults the wall
13//! clock or a random number generator. The same `(entry_name, fire_time,
14//! window)` therefore always yields the same offset, which makes the behaviour
15//! reproducible across restarts and across multiple beat instances, and lets the
16//! unit tests assert on fixed values.
17//!
18//! The application is purely additive: a window of `0` produces a zero offset
19//! and leaves the fire time unchanged, so enabling jitter never changes the
20//! semantics of an entry that opted out.
21//!
22//! The reusable primitive lives in [`bounded_jitter_offset`]; [`apply_jitter`]
23//! wraps it for callers that have a [`DateTime<Utc>`] in hand. The
24//! [`ScheduledTask::with_jitter_window`] builder wires a symmetric window into
25//! the existing next-run computation (see [`crate::task::ScheduledTask`]).
26
27use crate::history::Jitter;
28use crate::task::ScheduledTask;
29use chrono::{DateTime, Duration, Utc};
30
31/// FNV-1a 64-bit offset basis.
32const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
33/// FNV-1a 64-bit prime.
34const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
35
36/// Compute a stable 64-bit hash of an entry name plus a target fire time.
37///
38/// This uses the FNV-1a construction over the UTF-8 bytes of `entry_name`
39/// followed by the little-endian bytes of the fire time (as whole seconds since
40/// the Unix epoch). FNV-1a is chosen deliberately over [`std::collections::
41/// hash_map::DefaultHasher`] because its output is specified and stable across
42/// Rust releases and platforms, which is exactly the reproducibility guarantee
43/// the jitter feature promises. The fire time participates in the hash so that
44/// successive fires of the same entry receive different offsets (avoiding a
45/// fixed per-entry skew), while a given fire instant is always reproducible.
46fn stable_hash(entry_name: &str, fire_time: DateTime<Utc>) -> u64 {
47    let mut hash = FNV_OFFSET_BASIS;
48
49    for byte in entry_name.as_bytes() {
50        hash ^= u64::from(*byte);
51        hash = hash.wrapping_mul(FNV_PRIME);
52    }
53
54    // Mix a separator so that e.g. ("ab", t) and ("a", "b"||t) cannot collide
55    // purely from byte adjacency.
56    hash ^= 0xff;
57    hash = hash.wrapping_mul(FNV_PRIME);
58
59    let seconds = fire_time.timestamp();
60    for byte in seconds.to_le_bytes() {
61        hash ^= u64::from(byte);
62        hash = hash.wrapping_mul(FNV_PRIME);
63    }
64
65    hash
66}
67
68/// Compute a deterministic, bounded jitter offset in seconds for an entry.
69///
70/// The returned offset lies in the inclusive range `[-window_secs, +window_secs]`
71/// and is a pure function of `(entry_name, fire_time, window_secs)`. A
72/// `window_secs` of `0` always returns `0` (no jitter), making the feature
73/// additive — opting out reproduces the un-jittered behaviour exactly.
74///
75/// # Examples
76/// ```
77/// use celers_beat::jitter::bounded_jitter_offset;
78/// use chrono::{TimeZone, Utc};
79///
80/// let fire = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
81///
82/// // Zero window is always a no-op.
83/// assert_eq!(bounded_jitter_offset("task", fire, 0), 0);
84///
85/// // Within the bound and reproducible.
86/// let off = bounded_jitter_offset("task", fire, 30);
87/// assert!((-30..=30).contains(&off));
88/// assert_eq!(off, bounded_jitter_offset("task", fire, 30));
89/// ```
90pub fn bounded_jitter_offset(entry_name: &str, fire_time: DateTime<Utc>, window_secs: u64) -> i64 {
91    if window_secs == 0 {
92        return 0;
93    }
94
95    let hash = stable_hash(entry_name, fire_time);
96
97    // The full span is [-window, +window], i.e. (2 * window + 1) discrete
98    // positions. Reducing the hash modulo that span and subtracting `window`
99    // maps uniformly onto the symmetric range.
100    let span = window_secs
101        .saturating_mul(2)
102        .saturating_add(1)
103        .min(u64::from(u32::MAX));
104    let position = (hash % span) as i64;
105    position - window_secs as i64
106}
107
108/// Apply a deterministic, bounded jitter offset to a fire time.
109///
110/// Equivalent to adding [`bounded_jitter_offset`] seconds to `fire_time`. A
111/// `window_secs` of `0` returns `fire_time` unchanged.
112///
113/// # Examples
114/// ```
115/// use celers_beat::jitter::apply_jitter;
116/// use chrono::{TimeZone, Utc};
117///
118/// let fire = Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap();
119/// // No window => unchanged.
120/// assert_eq!(apply_jitter(fire, "task", 0), fire);
121///
122/// let jittered = apply_jitter(fire, "task", 45);
123/// let delta = (jittered - fire).num_seconds();
124/// assert!((-45..=45).contains(&delta));
125/// ```
126pub fn apply_jitter(fire_time: DateTime<Utc>, entry_name: &str, window_secs: u64) -> DateTime<Utc> {
127    let offset = bounded_jitter_offset(entry_name, fire_time, window_secs);
128    fire_time + Duration::seconds(offset)
129}
130
131impl ScheduledTask {
132    /// Add bounded, deterministic jitter to this entry's next-run time.
133    ///
134    /// This is a convenience over [`ScheduledTask::with_jitter`] that configures
135    /// a symmetric `±window_secs` window. The resulting offset is derived from a
136    /// stable hash of the entry name and the target fire time (see
137    /// [`bounded_jitter_offset`]) and is applied additively wherever the next
138    /// run is computed ([`ScheduledTask::next_run_time`],
139    /// [`ScheduledTask::is_due`], and the running beat loop).
140    ///
141    /// A `window_secs` of `0` is a no-op: the next-run time is unchanged, so
142    /// callers can configure jitter unconditionally without altering opted-out
143    /// behaviour.
144    ///
145    /// # Examples
146    /// ```
147    /// use celers_beat::{Schedule, ScheduledTask};
148    ///
149    /// let task = ScheduledTask::new("report".to_string(), Schedule::interval(60))
150    ///     .with_jitter_window(30);
151    /// assert!(task.jitter.is_some());
152    /// ```
153    pub fn with_jitter_window(self, window_secs: u64) -> Self {
154        // `i64::try_from` cannot fail for any realistic window; clamp defensively
155        // to avoid an overflow panic on absurd inputs (no-unwrap policy).
156        let window = i64::try_from(window_secs).unwrap_or(i64::MAX);
157        self.with_jitter(Jitter::symmetric(window))
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::schedule::Schedule;
165    use chrono::TimeZone;
166
167    fn fixed_fire() -> DateTime<Utc> {
168        Utc.with_ymd_and_hms(2026, 6, 13, 12, 0, 0)
169            .single()
170            .expect("fixed timestamp is valid")
171    }
172
173    #[test]
174    fn zero_window_is_no_op() {
175        let fire = fixed_fire();
176        assert_eq!(bounded_jitter_offset("anything", fire, 0), 0);
177        assert_eq!(apply_jitter(fire, "anything", 0), fire);
178    }
179
180    #[test]
181    fn offset_is_within_window() {
182        let fire = fixed_fire();
183        for name in ["a", "task-1", "really_long_entry_name_for_coverage", ""] {
184            for window in [1u64, 5, 30, 60, 3600] {
185                let off = bounded_jitter_offset(name, fire, window);
186                assert!(
187                    off >= -(window as i64) && off <= window as i64,
188                    "offset {off} out of +/-{window} for {name:?}"
189                );
190            }
191        }
192    }
193
194    #[test]
195    fn offset_is_deterministic_across_repeated_calls() {
196        let fire = fixed_fire();
197        let first = bounded_jitter_offset("stable", fire, 120);
198        for _ in 0..1000 {
199            assert_eq!(bounded_jitter_offset("stable", fire, 120), first);
200        }
201        // And via the DateTime wrapper.
202        let dt_first = apply_jitter(fire, "stable", 120);
203        assert_eq!(apply_jitter(fire, "stable", 120), dt_first);
204    }
205
206    #[test]
207    fn offset_differs_across_entries() {
208        let fire = fixed_fire();
209        // A spread of names should not collapse to a single offset.
210        let names = [
211            "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta",
212        ];
213        let offsets: std::collections::HashSet<i64> = names
214            .iter()
215            .map(|n| bounded_jitter_offset(n, fire, 600))
216            .collect();
217        assert!(
218            offsets.len() > 1,
219            "expected distinct offsets across entries, got {offsets:?}"
220        );
221    }
222
223    #[test]
224    fn offset_differs_across_fire_times() {
225        // The same entry at different fire instants should generally differ,
226        // so an entry is not permanently skewed by a constant amount.
227        let base = fixed_fire();
228        let later = base + Duration::seconds(60);
229        let a = bounded_jitter_offset("rotating", base, 600);
230        let b = bounded_jitter_offset("rotating", later, 600);
231        assert_ne!(a, b);
232    }
233
234    #[test]
235    fn with_jitter_window_zero_keeps_next_run_unchanged() {
236        let schedule = Schedule::interval(60);
237        let last = fixed_fire();
238
239        let plain = ScheduledTask::new("t".to_string(), schedule.clone());
240        let mut plain_with_last = plain.clone();
241        plain_with_last.last_run_at = Some(last);
242
243        let jittered = ScheduledTask::new("t".to_string(), schedule).with_jitter_window(0);
244        let mut jittered_with_last = jittered.clone();
245        jittered_with_last.last_run_at = Some(last);
246
247        assert_eq!(
248            plain_with_last
249                .next_run_time()
250                .expect("plain next run computes"),
251            jittered_with_last
252                .next_run_time()
253                .expect("jittered next run computes"),
254        );
255    }
256
257    #[test]
258    fn with_jitter_window_applies_in_next_run_computation() {
259        let schedule = Schedule::interval(60);
260        let last = fixed_fire();
261
262        let mut task = ScheduledTask::new("herd".to_string(), schedule).with_jitter_window(30);
263        task.last_run_at = Some(last);
264
265        let base_next = last + Duration::seconds(60);
266        let jittered_next = task.next_run_time().expect("next run computes");
267        let delta = (jittered_next - base_next).num_seconds();
268        assert!(
269            (-30..=30).contains(&delta),
270            "jittered next-run delta {delta} outside +/-30"
271        );
272
273        // Deterministic: recomputing yields the identical instant.
274        let again = task.clone();
275        assert_eq!(again.next_run_time().expect("recompute"), jittered_next);
276    }
277}