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}