Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-poll-cadence/src/lib.rs

//! **Poll-cadence gate** — the pure, chrono-only scheduling predicates behind
//! nornir's monitored-workspace sweep (bug #10).
//!
//! A monitored workspace carries a per-workspace `poll` cadence
//! (`"60s"`/`"5m"`/`"2h"`/bare-seconds). This leaf owns the pure math that gates
//! a git fetch to that cadence instead of fetching every workspace on every fixed
//! tick: [`parse_interval`] parses the cadence string, [`is_workspace_due`]
//! decides whether a workspace is due on this tick, and [`latest_synced`] folds a
//! set of RFC-3339 member stamps into the most-recent sync time.
//!
//! Extracted from `nornir::monitor` per the shrink plan: warehouse-free,
//! registry-free, clock-injected — so it is fully testable and reusable by both
//! the nornir server sweep and the dwarves scheduler. The registry-typed wrapper
//! (`workspace_last_synced(&Workspace)`) stays in nornir and delegates to
//! [`latest_synced`] here.

use std::time::Duration;

/// Parse `"60s"`, `"5m"`, `"2h"`, or a bare seconds count; fall back to `default`.
pub fn parse_interval(s: &str, default: Duration) -> Duration {
    let s = s.trim();
    if s.is_empty() {
        return default;
    }
    let (num, mult) = match s.chars().last() {
        Some('s') => (&s[..s.len() - 1], 1),
        Some('m') => (&s[..s.len() - 1], 60),
        Some('h') => (&s[..s.len() - 1], 3600),
        _ => (s, 1),
    };
    num.trim()
        .parse::<u64>()
        .ok()
        .map(|n| Duration::from_secs(n.saturating_mul(mult)))
        .unwrap_or(default)
}

/// The most-recent sync time across a set of RFC-3339 member stamps (`max`), or
/// `None` when none parse (empty/garbage stamps parse away). This is the pure
/// core of nornir's `workspace_last_synced`, which wraps it over a `Workspace`'s
/// members. Feeds [`is_workspace_due`].
pub fn latest_synced<'a, I>(stamps: I) -> Option<chrono::DateTime<chrono::Utc>>
where
    I: IntoIterator<Item = &'a str>,
{
    stamps
        .into_iter()
        .filter_map(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
        .map(|d| d.with_timezone(&chrono::Utc))
        .max()
}

/// Bug #10: is a monitored workspace due for a git fetch on this sweep tick?
///
/// The per-workspace `poll` cadence ("60s"/"5m"/"2h"/bare-seconds) was a DEAD
/// field — the sweep git-fetched every workspace on every fixed tick. This pure
/// predicate (testable, no clock/registry) gates the fetch: never-synced
/// (`last_synced == None`) is always due; otherwise due once `now - last_synced`
/// reaches the parsed poll interval. A future/negative stamp (clock skew) is
/// treated as due rather than starving the workspace.
pub fn is_workspace_due(
    poll: &str,
    last_synced: Option<chrono::DateTime<chrono::Utc>>,
    now: chrono::DateTime<chrono::Utc>,
    default: Duration,
) -> bool {
    let Some(last) = last_synced else { return true };
    let interval = parse_interval(poll, default);
    match now.signed_duration_since(last).to_std() {
        Ok(elapsed) => elapsed >= interval,
        Err(_) => true,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn interval_parsing() {
        let d = Duration::from_secs(60);
        assert_eq!(parse_interval("", d), Duration::from_secs(60));
        assert_eq!(parse_interval("30s", d), Duration::from_secs(30));
        assert_eq!(parse_interval("5m", d), Duration::from_secs(300));
        assert_eq!(parse_interval("2h", d), Duration::from_secs(7200));
        assert_eq!(parse_interval("90", d), Duration::from_secs(90));
        assert_eq!(parse_interval("garbage", d), Duration::from_secs(60));
    }

    /// Bug #10: the poll-cadence gate. Never-synced is always due; a recent sync
    /// with a long poll is NOT due; a stale sync (or a short poll) IS due.
    #[test]
    fn is_workspace_due_honors_poll_cadence() {
        use chrono::Utc;
        let default = Duration::from_secs(60);
        let now = Utc::now();

        // Never synced → always due, regardless of poll.
        assert!(is_workspace_due("1h", None, now, default));

        // Synced 10s ago, poll = 1h → NOT due yet.
        let recent = now - chrono::Duration::seconds(10);
        assert!(!is_workspace_due("1h", Some(recent), now, default));

        // Synced 2h ago, poll = 1h → due.
        let stale = now - chrono::Duration::hours(2);
        assert!(is_workspace_due("1h", Some(stale), now, default));

        // poll = 1s with a 10s-old sync → due (short cadence).
        assert!(is_workspace_due("1s", Some(recent), now, default));

        // Empty poll falls back to the default (60s): a 10s-old sync is not due.
        assert!(!is_workspace_due("", Some(recent), now, default));

        // Future stamp (clock skew) → due rather than starving the workspace.
        let future = now + chrono::Duration::hours(1);
        assert!(is_workspace_due("1h", Some(future), now, default));
    }

    /// `latest_synced` takes the MAX stamp and ignores empty/garbage stamps.
    #[test]
    fn latest_synced_picks_latest_stamp() {
        let got = latest_synced([
            "2026-06-01T00:00:00Z",
            "2026-06-10T00:00:00Z",
            "", // never-synced member ignored
        ])
        .unwrap();
        assert_eq!(got.to_rfc3339(), "2026-06-10T00:00:00+00:00");

        // Nothing parses → None.
        assert!(latest_synced([""]).is_none());
    }
}