use crate::history::Jitter;
use crate::task::ScheduledTask;
use chrono::{DateTime, Duration, Utc};
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
fn stable_hash(entry_name: &str, fire_time: DateTime<Utc>) -> u64 {
let mut hash = FNV_OFFSET_BASIS;
for byte in entry_name.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash ^= 0xff;
hash = hash.wrapping_mul(FNV_PRIME);
let seconds = fire_time.timestamp();
for byte in seconds.to_le_bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
pub fn bounded_jitter_offset(entry_name: &str, fire_time: DateTime<Utc>, window_secs: u64) -> i64 {
if window_secs == 0 {
return 0;
}
let hash = stable_hash(entry_name, fire_time);
let span = window_secs
.saturating_mul(2)
.saturating_add(1)
.min(u64::from(u32::MAX));
let position = (hash % span) as i64;
position - window_secs as i64
}
pub fn apply_jitter(fire_time: DateTime<Utc>, entry_name: &str, window_secs: u64) -> DateTime<Utc> {
let offset = bounded_jitter_offset(entry_name, fire_time, window_secs);
fire_time + Duration::seconds(offset)
}
impl ScheduledTask {
pub fn with_jitter_window(self, window_secs: u64) -> Self {
let window = i64::try_from(window_secs).unwrap_or(i64::MAX);
self.with_jitter(Jitter::symmetric(window))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schedule::Schedule;
use chrono::TimeZone;
fn fixed_fire() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 6, 13, 12, 0, 0)
.single()
.expect("fixed timestamp is valid")
}
#[test]
fn zero_window_is_no_op() {
let fire = fixed_fire();
assert_eq!(bounded_jitter_offset("anything", fire, 0), 0);
assert_eq!(apply_jitter(fire, "anything", 0), fire);
}
#[test]
fn offset_is_within_window() {
let fire = fixed_fire();
for name in ["a", "task-1", "really_long_entry_name_for_coverage", ""] {
for window in [1u64, 5, 30, 60, 3600] {
let off = bounded_jitter_offset(name, fire, window);
assert!(
off >= -(window as i64) && off <= window as i64,
"offset {off} out of +/-{window} for {name:?}"
);
}
}
}
#[test]
fn offset_is_deterministic_across_repeated_calls() {
let fire = fixed_fire();
let first = bounded_jitter_offset("stable", fire, 120);
for _ in 0..1000 {
assert_eq!(bounded_jitter_offset("stable", fire, 120), first);
}
let dt_first = apply_jitter(fire, "stable", 120);
assert_eq!(apply_jitter(fire, "stable", 120), dt_first);
}
#[test]
fn offset_differs_across_entries() {
let fire = fixed_fire();
let names = [
"alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta",
];
let offsets: std::collections::HashSet<i64> = names
.iter()
.map(|n| bounded_jitter_offset(n, fire, 600))
.collect();
assert!(
offsets.len() > 1,
"expected distinct offsets across entries, got {offsets:?}"
);
}
#[test]
fn offset_differs_across_fire_times() {
let base = fixed_fire();
let later = base + Duration::seconds(60);
let a = bounded_jitter_offset("rotating", base, 600);
let b = bounded_jitter_offset("rotating", later, 600);
assert_ne!(a, b);
}
#[test]
fn with_jitter_window_zero_keeps_next_run_unchanged() {
let schedule = Schedule::interval(60);
let last = fixed_fire();
let plain = ScheduledTask::new("t".to_string(), schedule.clone());
let mut plain_with_last = plain.clone();
plain_with_last.last_run_at = Some(last);
let jittered = ScheduledTask::new("t".to_string(), schedule).with_jitter_window(0);
let mut jittered_with_last = jittered.clone();
jittered_with_last.last_run_at = Some(last);
assert_eq!(
plain_with_last
.next_run_time()
.expect("plain next run computes"),
jittered_with_last
.next_run_time()
.expect("jittered next run computes"),
);
}
#[test]
fn with_jitter_window_applies_in_next_run_computation() {
let schedule = Schedule::interval(60);
let last = fixed_fire();
let mut task = ScheduledTask::new("herd".to_string(), schedule).with_jitter_window(30);
task.last_run_at = Some(last);
let base_next = last + Duration::seconds(60);
let jittered_next = task.next_run_time().expect("next run computes");
let delta = (jittered_next - base_next).num_seconds();
assert!(
(-30..=30).contains(&delta),
"jittered next-run delta {delta} outside +/-30"
);
let again = task.clone();
assert_eq!(again.next_run_time().expect("recompute"), jittered_next);
}
}