1use 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
22pub 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
39pub 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#[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 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 assert_eq!(advance_window(first, period, at(100))?, at(200));
134 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 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 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 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 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}