headless_lms_models/library/credit_registration/
enrolment_check_schedule.rs1use utoipa::ToSchema;
8
9use crate::prelude::*;
10use chrono::TimeDelta;
11
12#[derive(
15 Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Type, ToSchema,
16)]
17#[sqlx(type_name = "enrolment_check_group", rename_all = "snake_case")]
18#[serde(rename_all = "snake_case")]
19pub enum EnrolmentCheckGroup {
20 Completed,
22 Visited,
24 CheckRequested,
26}
27
28#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
30#[sqlx(type_name = "enrolment_check_source", rename_all = "snake_case")]
31#[serde(rename_all = "snake_case")]
32pub enum EnrolmentCheckSource {
33 Schedule,
35 StudentRequest,
36 TeacherRequest,
37 AdminRequest,
38 RosterListing,
40 AccountLink,
42}
43
44impl EnrolmentCheckSource {
45 pub fn is_prompted(self) -> bool {
48 self != Self::Schedule
49 }
50
51 pub fn own_check_at(self, anchor: DateTime<Utc>) -> Option<DateTime<Utc>> {
54 (self == Self::AccountLink).then(|| anchor + ACCOUNT_LINK_CHECK_DELAY)
55 }
56}
57
58pub const REGISTRY_LAG: TimeDelta = TimeDelta::hours(1);
61
62pub const ACCOUNT_LINK_CHECK_DELAY: TimeDelta = TimeDelta::minutes(1);
65
66pub const CHECK_REQUEST_MIN_INTERVAL: TimeDelta = TimeDelta::minutes(30);
69pub const STUDENT_CHECK_REQUEST_MIN_ROW_AGE: TimeDelta = TimeDelta::hours(1);
72pub const MAX_CHECK_REQUEST_RESTARTS_PER_DAY: i32 = 4;
74pub const CHECK_REQUEST_RESTART_WINDOW: TimeDelta = TimeDelta::days(1);
75pub const VISIT_RESTART_MIN_INTERVAL: TimeDelta = TimeDelta::days(1);
77
78pub const BATCH_INTERVAL: TimeDelta = TimeDelta::minutes(5);
80pub const BATCH_PULL_FORWARD: TimeDelta = TimeDelta::minutes(15);
82const SLOW_GAP: TimeDelta = TimeDelta::days(1);
84
85pub const TRANSIENT_FAILURE_RETRY: TimeDelta = TimeDelta::minutes(5);
87
88pub fn never() -> DateTime<Utc> {
90 DateTime::<Utc>::from_naive_utc_and_offset(
91 chrono::NaiveDate::from_ymd_opt(9999, 12, 31)
92 .and_then(|date| date.and_hms_opt(0, 0, 0))
93 .unwrap_or_default(),
94 Utc,
95 )
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct ScheduledEnrolmentCheck {
101 pub step: i32,
102 pub due_at: DateTime<Utc>,
103 pub is_batched: bool,
105}
106
107impl ScheduledEnrolmentCheck {
108 pub fn release_at(&self) -> DateTime<Utc> {
111 if !self.is_batched {
112 return self.due_at;
113 }
114 let secs = self.due_at.timestamp();
115 let interval_secs = BATCH_INTERVAL.num_seconds();
116 let boundary = secs.div_euclid(interval_secs) * interval_secs;
117 let boundary = if boundary < secs {
118 boundary + interval_secs
119 } else {
120 boundary
121 };
122 DateTime::from_timestamp(boundary, 0).unwrap_or(self.due_at)
123 }
124}
125
126pub(crate) const MINUTE_SECS: i64 = 60;
127pub(crate) const HOUR_SECS: i64 = 60 * MINUTE_SECS;
128pub(crate) const DAY_SECS: i64 = 24 * HOUR_SECS;
129pub(crate) const WEEK_SECS: i64 = 7 * DAY_SECS;
130
131pub(crate) const fn ladder<const N: usize>(
134 head: &[i64],
135 head_unit: i64,
136 extensions: &[(i64, i64)],
137) -> [i64; N] {
138 let mut rungs = [0; N];
139 let mut len = 0;
140 while len < head.len() {
141 rungs[len] = head[len] * head_unit;
142 len += 1;
143 }
144 let mut i = 0;
145 while i < extensions.len() {
146 let (gap, until) = extensions[i];
147 while rungs[len - 1] + gap <= until {
148 rungs[len] = rungs[len - 1] + gap;
149 len += 1;
150 }
151 i += 1;
152 }
153 assert!(len == N, "the ladder has a different number of rungs");
154 rungs
155}
156
157pub(crate) const COMPLETED_LADDER: [i64; 7] = ladder(&[1, 3, 7, 14, 30, 60, 90], DAY_SECS, &[]);
159const VISITED_LADDER: [i64; 28] = ladder(
160 &[60, 75, 120, 240, 480],
161 MINUTE_SECS,
162 &[(DAY_SECS, 14 * DAY_SECS), (WEEK_SECS, 90 * DAY_SECS)],
163);
164const CHECK_REQUESTED_LADDER: [i64; 57] = ladder(
165 &[15, 50, 60, 75, 120, 180, 360, 720, 1440],
166 MINUTE_SECS,
167 &[(DAY_SECS, 28 * DAY_SECS), (WEEK_SECS, 180 * DAY_SECS)],
168);
169
170fn ladder_offsets(group: EnrolmentCheckGroup) -> &'static [i64] {
172 match group {
173 EnrolmentCheckGroup::Completed => &COMPLETED_LADDER,
174 EnrolmentCheckGroup::Visited => &VISITED_LADDER,
175 EnrolmentCheckGroup::CheckRequested => &CHECK_REQUESTED_LADDER,
176 }
177}
178
179fn resolve(
180 group: EnrolmentCheckGroup,
181 anchor: DateTime<Utc>,
182 step: usize,
183) -> Option<ScheduledEnrolmentCheck> {
184 let offsets = ladder_offsets(group);
185 let offset = *offsets.get(step)?;
186 let previous = step.checked_sub(1).map_or(0, |previous| offsets[previous]);
187 Some(ScheduledEnrolmentCheck {
188 step: i32::try_from(step).ok()?,
189 due_at: anchor + TimeDelta::seconds(offset),
190 is_batched: offset - previous >= SLOW_GAP.num_seconds(),
191 })
192}
193
194pub fn first_check(
197 group: EnrolmentCheckGroup,
198 anchor: DateTime<Utc>,
199) -> Option<ScheduledEnrolmentCheck> {
200 resolve(group, anchor, 0)
201}
202
203pub fn next_check_after(
205 group: EnrolmentCheckGroup,
206 anchor: DateTime<Utc>,
207 after: DateTime<Utc>,
208) -> Option<ScheduledEnrolmentCheck> {
209 let elapsed = (after - anchor).num_seconds();
210 let step = ladder_offsets(group).partition_point(|&offset| offset <= elapsed);
211 resolve(group, anchor, step)
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 const ALL_GROUPS: [EnrolmentCheckGroup; 3] = [
219 EnrolmentCheckGroup::Completed,
220 EnrolmentCheckGroup::Visited,
221 EnrolmentCheckGroup::CheckRequested,
222 ];
223
224 fn at(offset: TimeDelta) -> DateTime<Utc> {
225 DateTime::from_timestamp(1_800_000_000, 0).unwrap() + offset
226 }
227
228 fn offsets_hours(group: EnrolmentCheckGroup) -> Vec<f64> {
229 ladder_offsets(group)
230 .iter()
231 .map(|&offset| offset as f64 / 3600.0)
232 .collect()
233 }
234
235 #[test]
236 fn the_ladders_follow_the_plan() {
237 let completed = offsets_hours(EnrolmentCheckGroup::Completed);
238 assert_eq!(completed, [24.0, 72.0, 168.0, 336.0, 720.0, 1440.0, 2160.0]);
239
240 let visited = offsets_hours(EnrolmentCheckGroup::Visited);
241 assert_eq!(visited[..6], [1.0, 1.25, 2.0, 4.0, 8.0, 32.0]);
242 assert!(visited.last().unwrap() <= &(90.0 * 24.0));
243
244 let requested = offsets_hours(EnrolmentCheckGroup::CheckRequested);
245 assert_eq!(
246 requested[..10],
247 [
248 0.25,
249 50.0 / 60.0,
250 1.0,
251 1.25,
252 2.0,
253 3.0,
254 6.0,
255 12.0,
256 24.0,
257 48.0
258 ]
259 );
260 assert!(requested.last().unwrap() <= &(180.0 * 24.0));
261 for group in ALL_GROUPS {
262 assert!(
263 ladder_offsets(group)
264 .windows(2)
265 .all(|pair| pair[0] < pair[1])
266 );
267 }
268 }
269
270 #[test]
271 fn the_next_check_is_the_first_rung_after_now() {
272 let anchor = at(TimeDelta::zero());
273 let requested = EnrolmentCheckGroup::CheckRequested;
274 assert_eq!(
275 first_check(requested, anchor).unwrap().due_at,
276 at(TimeDelta::minutes(15))
277 );
278 let after_first = next_check_after(requested, anchor, at(TimeDelta::minutes(15))).unwrap();
279 assert_eq!(after_first.step, 1);
280 assert_eq!(after_first.due_at, at(TimeDelta::minutes(50)));
281 let late = next_check_after(requested, anchor, at(TimeDelta::hours(4))).unwrap();
283 assert_eq!(late.due_at, at(TimeDelta::hours(6)));
284 assert_eq!(
285 next_check_after(
286 EnrolmentCheckGroup::Completed,
287 anchor,
288 at(TimeDelta::days(91))
289 ),
290 None
291 );
292 }
293
294 #[test]
295 fn only_an_account_link_checks_ahead_of_the_first_rung() {
296 let anchor = at(TimeDelta::zero());
297 assert_eq!(
298 EnrolmentCheckSource::AccountLink.own_check_at(anchor),
299 Some(at(ACCOUNT_LINK_CHECK_DELAY))
300 );
301 assert_eq!(EnrolmentCheckSource::Schedule.own_check_at(anchor), None);
302 assert_eq!(
303 EnrolmentCheckSource::StudentRequest.own_check_at(anchor),
304 None
305 );
306 let after_own_check = next_check_after(
308 EnrolmentCheckGroup::CheckRequested,
309 anchor,
310 at(ACCOUNT_LINK_CHECK_DELAY),
311 )
312 .unwrap();
313 assert_eq!(after_own_check.step, 0);
314 assert_eq!(after_own_check.due_at, at(TimeDelta::minutes(15)));
315 }
316
317 #[test]
318 fn only_slow_rungs_are_batched_on_five_minute_boundaries() {
319 let anchor = at(TimeDelta::seconds(7));
320 let first = first_check(EnrolmentCheckGroup::Completed, anchor).unwrap();
321 assert!(first.is_batched);
322 assert_eq!(
323 first.release_at().timestamp() % BATCH_INTERVAL.num_seconds(),
324 0
325 );
326 assert!(first.release_at() >= first.due_at);
327 assert!(first.release_at() < first.due_at + BATCH_INTERVAL);
328
329 let visit = first_check(EnrolmentCheckGroup::Visited, anchor).unwrap();
330 assert!(!visit.is_batched);
331 assert_eq!(visit.release_at(), visit.due_at);
332 }
333}