Skip to main content

headless_lms_models/library/credit_registration/
enrolment_check_schedule.rs

1//! When a row waiting for an enrolment is checked next.
2//!
3//! Each group has a ladder of offsets from its anchor. The next check is always the first rung after
4//! now, so a row that missed rungs (an outage, a check brought forward) gets one catch-up check
5//! rather than one per missed rung, and a row past its last rung has stopped.
6
7use utoipa::ToSchema;
8
9use crate::prelude::*;
10use chrono::TimeDelta;
11
12/// How strongly a student has signalled that they are enrolling, which picks the ladder. Ordered: a
13/// row only ever moves to a later variant.
14#[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 the module and has not opened its registration page since.
21    Completed,
22    /// Opened the registration page after completing, with the enrolment instructions showing.
23    Visited,
24    /// Asked for a check, had a teacher ask for one, or linked from a roster mail.
25    CheckRequested,
26}
27
28/// What made an enrolment check run when it did.
29#[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    /// The group's own ladder: the only source lateness is measured on.
34    Schedule,
35    StudentRequest,
36    TeacherRequest,
37    AdminRequest,
38    /// The course roster listed an enrolment the row had not seen.
39    RosterListing,
40    /// The student linked their number from a mail sent off the roster, which proves an enrolment.
41    AccountLink,
42}
43
44impl EnrolmentCheckSource {
45    /// Whether something other than the row's own ladder set the check off: someone pressed a
46    /// button and is waiting for its answer, or an enrolment just turned up and the check is due now.
47    pub fn is_prompted(self) -> bool {
48        self != Self::Schedule
49    }
50
51    /// When a schedule this source started at `anchor` runs a check of its own ahead of the ladder's
52    /// first rung, or `None` for one that waits for that rung. The ladder runs on unchanged after.
53    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
58/// Suotar's copy of Sisu shows a new enrolment after 15 to 60 minutes, so a check sooner than this
59/// after an enrolment usually cannot see it.
60pub const REGISTRY_LAG: TimeDelta = TimeDelta::hours(1);
61
62/// How soon after a student links their number from a roster mail their enrolment is checked. The
63/// roster already listed the enrolment, so unlike a request this need not wait out [`REGISTRY_LAG`].
64pub const ACCOUNT_LINK_CHECK_DELAY: TimeDelta = TimeDelta::minutes(1);
65
66/// How soon after a check request, or after the last check, another request may start one. Shared
67/// by the student's recheck, Done and the teacher's button.
68pub const CHECK_REQUEST_MIN_INTERVAL: TimeDelta = TimeDelta::minutes(30);
69/// How old a row must be before its student may ask for a check: a new enrolment takes 15 to 60
70/// minutes to show, so an earlier request only finds nothing and spends the allowance.
71pub const STUDENT_CHECK_REQUEST_MIN_ROW_AGE: TimeDelta = TimeDelta::hours(1);
72/// How many check requests a day may restart the ladder. Past it a request gets one check only.
73pub const MAX_CHECK_REQUEST_RESTARTS_PER_DAY: i32 = 4;
74pub const CHECK_REQUEST_RESTART_WINDOW: TimeDelta = TimeDelta::days(1);
75/// A repeat visit restarts the visited ladder at most this often.
76pub const VISIT_RESTART_MIN_INTERVAL: TimeDelta = TimeDelta::days(1);
77
78/// Slow checks go out together on boundaries this far apart.
79pub const BATCH_INTERVAL: TimeDelta = TimeDelta::minutes(5);
80/// A slow check due this soon after a batch goes out joins it.
81pub const BATCH_PULL_FORWARD: TimeDelta = TimeDelta::minutes(15);
82/// A rung at least this far after the one before it is a slow one.
83const SLOW_GAP: TimeDelta = TimeDelta::days(1);
84
85/// How long a check that failed in transit waits before the same rung is tried again.
86pub const TRANSIENT_FAILURE_RETRY: TimeDelta = TimeDelta::minutes(5);
87
88/// Where a stopped row's `next_attempt_at` is parked: it is never claimed on its own again.
89pub 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/// One rung of a ladder, resolved against an anchor.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct ScheduledEnrolmentCheck {
101    pub step: i32,
102    pub due_at: DateTime<Utc>,
103    /// A slow rung, released in five-minute batches rather than on the ten-second tick.
104    pub is_batched: bool,
105}
106
107impl ScheduledEnrolmentCheck {
108    /// When the row may be claimed for this check: the due time itself, or for a slow rung the
109    /// first batch boundary at or after it.
110    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
131/// A ladder in seconds: `head` in `head_unit`s, then for each `(gap, until)` rungs `gap` apart while
132/// they stay within `until`. Fails to compile unless that makes exactly `N` rungs.
133pub(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
157/// Also the ladder of enrolment list fetches for a person waiting for a student number.
158pub(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
170/// The group's ladder, as offsets in seconds from the anchor, ascending.
171fn 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
194/// The first rung of a ladder started at `anchor`. A check request's own check runs before it, at
195/// once, and an account link's a minute after the link; see [`EnrolmentCheckSource::own_check_at`].
196pub fn first_check(
197    group: EnrolmentCheckGroup,
198    anchor: DateTime<Utc>,
199) -> Option<ScheduledEnrolmentCheck> {
200    resolve(group, anchor, 0)
201}
202
203/// The first rung strictly after `after`, or `None` once the ladder has run out.
204pub 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        // A row that sat out several rungs gets one catch-up, not one per rung.
282        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        // A miss carries on from the ladder's first rung.
307        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}