Skip to main content

max / balanced_breakfast

4.7 KB · 165 lines History Blame Raw
1 //! Pure scheduling helpers.
2 //!
3 //! Timing and backoff logic used by both the auto-fetch loop and the sync
4 //! scheduler. All functions are deterministic (time is passed in explicitly)
5 //! so they can be unit-tested without async or database dependencies.
6
7 use bb_db::parse_timestamp;
8 use chrono::{DateTime, Utc};
9
10 #[tracing::instrument(skip_all)]
11 /// Check whether a single feed is overdue for fetching
12 pub fn is_single_feed_due(
13 last_fetch: Option<&str>,
14 interval_secs: u64,
15 now: DateTime<Utc>,
16 ) -> bool {
17 let last = last_fetch
18 .map(parse_timestamp)
19 .unwrap_or(DateTime::UNIX_EPOCH);
20 let elapsed = now.signed_duration_since(last);
21 elapsed.num_seconds() >= interval_secs as i64
22 }
23
24 #[tracing::instrument(skip_all)]
25 /// Check whether any feed in a set is overdue for fetching
26 pub fn any_feed_due(
27 last_fetches: &[Option<&str>],
28 interval_secs: u64,
29 now: DateTime<Utc>,
30 ) -> bool {
31 last_fetches
32 .iter()
33 .any(|ts| is_single_feed_due(*ts, interval_secs, now))
34 }
35
36 #[tracing::instrument(skip_all)]
37 /// Compute exponential backoff delay in seconds, capped at `max_secs`
38 pub fn exponential_backoff_secs(consecutive_failures: u32, max_secs: u64) -> u64 {
39 let raw = 2u64.saturating_pow(consecutive_failures);
40 raw.min(max_secs)
41 }
42
43 #[cfg(test)]
44 mod tests {
45 use super::*;
46 use chrono::{Duration, Utc};
47
48 // --- is_single_feed_due ---
49
50 #[test]
51 fn is_fetch_due_first_time() {
52 // Never fetched (None) should always be due.
53 let now = Utc::now();
54 assert!(is_single_feed_due(None, 3600, now));
55 }
56
57 #[test]
58 fn is_fetch_due_overdue() {
59 // Last fetch was 2 hours ago, interval is 1 hour -- due.
60 let now = Utc::now();
61 let two_hours_ago = now - Duration::hours(2);
62 let ts = two_hours_ago.to_rfc3339();
63 assert!(is_single_feed_due(Some(&ts), 3600, now));
64 }
65
66 #[test]
67 fn is_fetch_due_not_yet() {
68 // Last fetch was 5 minutes ago, interval is 1 hour -- not due.
69 let now = Utc::now();
70 let five_min_ago = now - Duration::minutes(5);
71 let ts = five_min_ago.to_rfc3339();
72 assert!(!is_single_feed_due(Some(&ts), 3600, now));
73 }
74
75 // --- any_feed_due ---
76
77 #[test]
78 fn any_feed_due_empty() {
79 // No feeds means nothing is due.
80 let now = Utc::now();
81 assert!(!any_feed_due(&[], 3600, now));
82 }
83
84 #[test]
85 fn any_feed_due_one_overdue() {
86 let now = Utc::now();
87 let two_hours_ago = (now - Duration::hours(2)).to_rfc3339();
88 let five_min_ago = (now - Duration::minutes(5)).to_rfc3339();
89 let feeds: Vec<Option<&str>> = vec![
90 Some(five_min_ago.as_str()),
91 Some(two_hours_ago.as_str()),
92 ];
93 assert!(any_feed_due(&feeds, 3600, now));
94 }
95
96 #[test]
97 fn any_feed_due_none_overdue() {
98 let now = Utc::now();
99 let five_min_ago = (now - Duration::minutes(5)).to_rfc3339();
100 let ten_min_ago = (now - Duration::minutes(10)).to_rfc3339();
101 let feeds: Vec<Option<&str>> = vec![
102 Some(five_min_ago.as_str()),
103 Some(ten_min_ago.as_str()),
104 ];
105 assert!(!any_feed_due(&feeds, 3600, now));
106 }
107
108 #[test]
109 fn any_feed_due_never_fetched() {
110 // One feed never fetched (None) — should be due.
111 let now = Utc::now();
112 let five_min_ago = (now - Duration::minutes(5)).to_rfc3339();
113 let feeds: Vec<Option<&str>> = vec![
114 Some(five_min_ago.as_str()),
115 None,
116 ];
117 assert!(any_feed_due(&feeds, 3600, now));
118 }
119
120 #[test]
121 fn any_feed_due_all_never_fetched() {
122 let now = Utc::now();
123 let feeds: Vec<Option<&str>> = vec![None, None, None];
124 assert!(any_feed_due(&feeds, 3600, now));
125 }
126
127 // --- exponential_backoff_secs ---
128
129 #[test]
130 fn backoff_zero_failures() {
131 // 2^0 = 1 second
132 assert_eq!(exponential_backoff_secs(0, 900), 1);
133 }
134
135 #[test]
136 fn backoff_one_failure() {
137 // 2^1 = 2 seconds
138 assert_eq!(exponential_backoff_secs(1, 900), 2);
139 }
140
141 #[test]
142 fn backoff_three_failures() {
143 // 2^3 = 8 seconds
144 assert_eq!(exponential_backoff_secs(3, 900), 8);
145 }
146
147 #[test]
148 fn backoff_capped_at_max() {
149 // 2^20 = 1_048_576, but max is 900 → capped at 900
150 assert_eq!(exponential_backoff_secs(20, 900), 900);
151 }
152
153 #[test]
154 fn backoff_exact_cap() {
155 // 2^4 = 16, max is 16 → exactly 16
156 assert_eq!(exponential_backoff_secs(4, 16), 16);
157 }
158
159 #[test]
160 fn backoff_large_failure_count_no_overflow() {
161 // Very large exponent should not panic, just saturate + cap.
162 assert_eq!(exponential_backoff_secs(64, 900), 900);
163 }
164 }
165