Skip to main content

aion/workloop/
windows.rs

1//! Pure cadence-window arithmetic (R4.3/R13.3).
2//!
3//! One clock per loop (R2.2): windows sit on a fixed grid anchored at
4//! registration, so a late sweep never drifts the cadence — it fires the
5//! elapsed window once and re-arms on the original grid past `now`
6//! (lateness-not-death: a missed window is COUNTED by the dead-man switch,
7//! never silently replayed as a burst of catch-up fires).
8
9use std::time::Duration;
10
11use aion_store::WorkloopRecord;
12use chrono::{DateTime, Utc};
13
14use super::error::WorkloopError;
15
16fn chrono_period(period: Duration) -> Result<chrono::Duration, WorkloopError> {
17    chrono::Duration::from_std(period).map_err(|error| WorkloopError::UnrepresentableDuration {
18        reason: format!("cadence period {period:?}: {error}"),
19    })
20}
21
22/// The first cadence window after registration: `registered_at + period`.
23///
24/// # Errors
25///
26/// Refuses a period beyond the representable clock range.
27pub fn initial_window(
28    registered_at: DateTime<Utc>,
29    period: Duration,
30) -> Result<DateTime<Utc>, WorkloopError> {
31    let delta = chrono_period(period)?;
32    registered_at
33        .checked_add_signed(delta)
34        .ok_or_else(|| WorkloopError::UnrepresentableDuration {
35            reason: format!("first window past {registered_at} overflows the clock"),
36        })
37}
38
39/// The next window boundary on the loop's own grid strictly after `now`,
40/// starting from the boundary that just fired.
41///
42/// A sweep that arrives late (or after engine downtime) advances to the first
43/// future boundary rather than scheduling an immediate burst; the windows
44/// skipped in between are visible to the dead-man switch as missed windows,
45/// so lateness is counted, never papered over.
46///
47/// # Errors
48///
49/// Refuses a period beyond the representable clock range.
50pub fn advance_window(
51    fired_at: DateTime<Utc>,
52    period: Duration,
53    now: DateTime<Utc>,
54) -> Result<DateTime<Utc>, WorkloopError> {
55    let delta = chrono_period(period)?;
56    let mut next = fired_at.checked_add_signed(delta).ok_or_else(|| {
57        WorkloopError::UnrepresentableDuration {
58            reason: format!("window past {fired_at} overflows the clock"),
59        }
60    })?;
61    while next <= now {
62        next = next.checked_add_signed(delta).ok_or_else(|| {
63            WorkloopError::UnrepresentableDuration {
64                reason: format!("window past {next} overflows the clock"),
65            }
66        })?;
67    }
68    Ok(next)
69}
70
71/// When the sweeper must next look at this loop: the earliest of the next
72/// cadence window and every invariant's duration-form deadline (skipping
73/// invariants whose alarm is already latched — a latched alarm re-arms only
74/// through a confirmation, so there is nothing to poll for).
75///
76/// `None` means nothing is left to check engine-side: a signal-only loop with
77/// every duration alarm latched. Cadenced loops always have a next window.
78#[must_use]
79pub fn next_check_at(record: &WorkloopRecord) -> Option<DateTime<Utc>> {
80    let mut earliest: Option<DateTime<Utc>> = record.next_window_at;
81
82    for invariant in record.spec.invariants() {
83        let Some(unconfirmed_for) = invariant.tolerance.unconfirmed_for() else {
84            continue;
85        };
86        let state = record.invariant_health.get(&invariant.name);
87        if state.is_some_and(|state| state.alarmed) {
88            continue;
89        }
90        let since = state
91            .and_then(|state| state.last_confirmed_at)
92            .unwrap_or(record.registered_at);
93        let Ok(delta) = chrono::Duration::from_std(unconfirmed_for) else {
94            // Beyond the representable range: the deadline never arrives.
95            continue;
96        };
97        let Some(deadline) = since.checked_add_signed(delta) else {
98            continue;
99        };
100        earliest = Some(match earliest {
101            Some(current) if current <= deadline => current,
102            _ => deadline,
103        });
104    }
105
106    earliest
107}
108
109#[cfg(test)]
110mod tests {
111    use std::collections::BTreeMap;
112
113    use aion_core::{InvariantSpec, ToleranceSpec, WorkloopArming, WorkloopSpec};
114    use aion_store::InvariantHealthState;
115    use chrono::TimeZone;
116
117    use super::*;
118
119    fn at(offset: i64) -> DateTime<Utc> {
120        Utc.with_ymd_and_hms(2026, 8, 25, 6, 0, 0)
121            .single()
122            .unwrap_or_default()
123            + chrono::Duration::seconds(offset)
124    }
125
126    #[test]
127    fn windows_stay_on_the_registration_grid() -> Result<(), Box<dyn std::error::Error>> {
128        let period = Duration::from_secs(100);
129        let first = initial_window(at(0), period)?;
130        assert_eq!(first, at(100));
131
132        // On-time advance: the very next boundary.
133        assert_eq!(advance_window(first, period, at(100))?, at(200));
134        // A late sweep (two windows of downtime) lands on the grid past now —
135        // one boundary, not a burst.
136        assert_eq!(advance_window(first, period, at(350))?, at(400));
137        Ok(())
138    }
139
140    fn record_with(
141        arming: WorkloopArming,
142        tolerance: ToleranceSpec,
143        health: Option<InvariantHealthState>,
144        next_window_at: Option<DateTime<Utc>>,
145    ) -> Result<WorkloopRecord, Box<dyn std::error::Error>> {
146        let spec = WorkloopSpec::new(
147            arming,
148            vec![InvariantSpec {
149                name: String::from("serving"),
150                record_type: String::from("ServeState"),
151                tolerance,
152                confirms: vec![String::from("sweep")],
153            }],
154            Duration::from_secs(86_400),
155        )?;
156        Ok(WorkloopRecord {
157            loop_id: aion_core::WorkflowId::new_v4(),
158            namespace: String::from("default"),
159            spec,
160            window_seq: 0,
161            next_window_at,
162            next_check_at: None,
163            last_iteration_closed_window: None,
164            invariant_health: health
165                .map(|state| BTreeMap::from([(String::from("serving"), state)]))
166                .unwrap_or_default(),
167            registered_at: at(0),
168            updated_at: at(0),
169        })
170    }
171
172    #[test]
173    fn next_check_is_the_earliest_of_window_and_duration_deadline()
174    -> Result<(), Box<dyn std::error::Error>> {
175        // Cadence at +100, duration deadline at +300 (anchor = registration).
176        let record = record_with(
177            WorkloopArming::every(Duration::from_secs(100))?,
178            ToleranceSpec::both(3, Duration::from_secs(300))?,
179            None,
180            Some(at(100)),
181        )?;
182        assert_eq!(next_check_at(&record), Some(at(100)));
183
184        // Duration deadline earlier than the window: it wins.
185        let record = record_with(
186            WorkloopArming::every(Duration::from_secs(1000))?,
187            ToleranceSpec::both(3, Duration::from_secs(300))?,
188            None,
189            Some(at(1000)),
190        )?;
191        assert_eq!(next_check_at(&record), Some(at(300)));
192        Ok(())
193    }
194
195    #[test]
196    fn signal_only_loop_checks_at_the_duration_deadline_and_rests_when_latched()
197    -> Result<(), Box<dyn std::error::Error>> {
198        let arming = WorkloopArming::signal_only(vec![String::from("task_ready")])?;
199        let tolerance = ToleranceSpec::duration(Duration::from_secs(300))?;
200
201        let unlatched = record_with(arming.clone(), tolerance.clone(), None, None)?;
202        assert_eq!(next_check_at(&unlatched), Some(at(300)));
203
204        // Confirmation moves the deadline out from the confirmation instant.
205        let confirmed = record_with(
206            arming.clone(),
207            tolerance.clone(),
208            Some(InvariantHealthState {
209                last_confirmed_at: Some(at(200)),
210                consecutive_unconfirmed: 0,
211                last_evidence: None,
212                alarmed: false,
213            }),
214            None,
215        )?;
216        assert_eq!(next_check_at(&confirmed), Some(at(500)));
217
218        // A latched alarm has nothing left to poll: the loop rests entirely.
219        let latched = record_with(
220            arming,
221            tolerance,
222            Some(InvariantHealthState {
223                last_confirmed_at: None,
224                consecutive_unconfirmed: 0,
225                last_evidence: None,
226                alarmed: true,
227            }),
228            None,
229        )?;
230        assert_eq!(next_check_at(&latched), None);
231        Ok(())
232    }
233}