//! Pure scheduling helpers. //! //! Timing and backoff logic used by both the auto-fetch loop and the sync //! scheduler. All functions are deterministic (time is passed in explicitly) //! so they can be unit-tested without async or database dependencies. use bb_db::parse_timestamp; use chrono::{DateTime, Utc}; #[tracing::instrument(skip_all)] /// Check whether a single feed is overdue for fetching pub fn is_single_feed_due( last_fetch: Option<&str>, interval_secs: u64, now: DateTime, ) -> bool { let last = last_fetch .map(parse_timestamp) .unwrap_or(DateTime::UNIX_EPOCH); let elapsed = now.signed_duration_since(last); elapsed.num_seconds() >= interval_secs as i64 } #[tracing::instrument(skip_all)] /// Check whether any feed in a set is overdue for fetching pub fn any_feed_due( last_fetches: &[Option<&str>], interval_secs: u64, now: DateTime, ) -> bool { last_fetches .iter() .any(|ts| is_single_feed_due(*ts, interval_secs, now)) } #[tracing::instrument(skip_all)] /// Compute exponential backoff delay in seconds, capped at `max_secs` pub fn exponential_backoff_secs(consecutive_failures: u32, max_secs: u64) -> u64 { let raw = 2u64.saturating_pow(consecutive_failures); raw.min(max_secs) } #[cfg(test)] mod tests { use super::*; use chrono::{Duration, Utc}; // --- is_single_feed_due --- #[test] fn is_fetch_due_first_time() { // Never fetched (None) should always be due. let now = Utc::now(); assert!(is_single_feed_due(None, 3600, now)); } #[test] fn is_fetch_due_overdue() { // Last fetch was 2 hours ago, interval is 1 hour -- due. let now = Utc::now(); let two_hours_ago = now - Duration::hours(2); let ts = two_hours_ago.to_rfc3339(); assert!(is_single_feed_due(Some(&ts), 3600, now)); } #[test] fn is_fetch_due_not_yet() { // Last fetch was 5 minutes ago, interval is 1 hour -- not due. let now = Utc::now(); let five_min_ago = now - Duration::minutes(5); let ts = five_min_ago.to_rfc3339(); assert!(!is_single_feed_due(Some(&ts), 3600, now)); } // --- any_feed_due --- #[test] fn any_feed_due_empty() { // No feeds means nothing is due. let now = Utc::now(); assert!(!any_feed_due(&[], 3600, now)); } #[test] fn any_feed_due_one_overdue() { let now = Utc::now(); let two_hours_ago = (now - Duration::hours(2)).to_rfc3339(); let five_min_ago = (now - Duration::minutes(5)).to_rfc3339(); let feeds: Vec> = vec![ Some(five_min_ago.as_str()), Some(two_hours_ago.as_str()), ]; assert!(any_feed_due(&feeds, 3600, now)); } #[test] fn any_feed_due_none_overdue() { let now = Utc::now(); let five_min_ago = (now - Duration::minutes(5)).to_rfc3339(); let ten_min_ago = (now - Duration::minutes(10)).to_rfc3339(); let feeds: Vec> = vec![ Some(five_min_ago.as_str()), Some(ten_min_ago.as_str()), ]; assert!(!any_feed_due(&feeds, 3600, now)); } #[test] fn any_feed_due_never_fetched() { // One feed never fetched (None) — should be due. let now = Utc::now(); let five_min_ago = (now - Duration::minutes(5)).to_rfc3339(); let feeds: Vec> = vec![ Some(five_min_ago.as_str()), None, ]; assert!(any_feed_due(&feeds, 3600, now)); } #[test] fn any_feed_due_all_never_fetched() { let now = Utc::now(); let feeds: Vec> = vec![None, None, None]; assert!(any_feed_due(&feeds, 3600, now)); } // --- exponential_backoff_secs --- #[test] fn backoff_zero_failures() { // 2^0 = 1 second assert_eq!(exponential_backoff_secs(0, 900), 1); } #[test] fn backoff_one_failure() { // 2^1 = 2 seconds assert_eq!(exponential_backoff_secs(1, 900), 2); } #[test] fn backoff_three_failures() { // 2^3 = 8 seconds assert_eq!(exponential_backoff_secs(3, 900), 8); } #[test] fn backoff_capped_at_max() { // 2^20 = 1_048_576, but max is 900 → capped at 900 assert_eq!(exponential_backoff_secs(20, 900), 900); } #[test] fn backoff_exact_cap() { // 2^4 = 16, max is 16 → exactly 16 assert_eq!(exponential_backoff_secs(4, 16), 16); } #[test] fn backoff_large_failure_count_no_overflow() { // Very large exponent should not panic, just saturate + cap. assert_eq!(exponential_backoff_secs(64, 900), 900); } }