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 std::sync::LazyLock;
8
9use utoipa::ToSchema;
10
11use crate::prelude::*;
12use chrono::TimeDelta;
13
14/// How strongly a student has signalled that they are enrolling, which picks the ladder. Ordered: a
15/// row only ever moves to a later variant.
16#[derive(
17    Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Type, ToSchema,
18)]
19#[sqlx(type_name = "enrolment_check_group", rename_all = "snake_case")]
20#[serde(rename_all = "snake_case")]
21pub enum EnrolmentCheckGroup {
22    /// Completed the module and has not opened its registration page since.
23    Completed,
24    /// Opened the registration page after completing, with the enrolment instructions showing.
25    Visited,
26    /// Asked for a check, had a teacher ask for one, or linked from a roster mail.
27    CheckRequested,
28}
29
30/// What made an enrolment check run when it did.
31#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, Type, ToSchema)]
32#[sqlx(type_name = "enrolment_check_source", rename_all = "snake_case")]
33#[serde(rename_all = "snake_case")]
34pub enum EnrolmentCheckSource {
35    /// The group's own ladder: the only source lateness is measured on.
36    Schedule,
37    StudentRequest,
38    TeacherRequest,
39    AdminRequest,
40    /// The course roster listed an enrolment the row had not seen.
41    RosterListing,
42    /// The student linked their number from a mail sent off the roster, which proves an enrolment.
43    AccountLink,
44}
45
46impl EnrolmentCheckSource {
47    /// Whether someone pressed a button for the check, and so is waiting to see its answer.
48    pub fn is_request(self) -> bool {
49        matches!(
50            self,
51            Self::StudentRequest | Self::TeacherRequest | Self::AdminRequest
52        )
53    }
54}
55
56/// Suotar's copy of Sisu shows a new enrolment after 15 to 60 minutes, so a check sooner than this
57/// after an enrolment usually cannot see it.
58pub const REGISTRY_LAG: TimeDelta = TimeDelta::hours(1);
59
60/// How soon after a check request, or after the last check, another request may start one. Shared
61/// by the student's recheck, Done and the teacher's button.
62pub const CHECK_REQUEST_MIN_INTERVAL: TimeDelta = TimeDelta::minutes(30);
63/// How old a row must be before its student may ask for a check: a new enrolment takes 15 to 60
64/// minutes to show, so an earlier request only finds nothing and spends the allowance.
65pub const STUDENT_CHECK_REQUEST_MIN_ROW_AGE: TimeDelta = TimeDelta::hours(1);
66/// How many check requests a day may restart the ladder. Past it a request gets one check only.
67pub const MAX_CHECK_REQUEST_RESTARTS_PER_DAY: i32 = 4;
68pub const CHECK_REQUEST_RESTART_WINDOW: TimeDelta = TimeDelta::days(1);
69/// A repeat visit restarts the visited ladder at most this often.
70pub const VISIT_RESTART_MIN_INTERVAL: TimeDelta = TimeDelta::days(1);
71
72/// Slow checks go out together on boundaries this far apart.
73pub const BATCH_INTERVAL: TimeDelta = TimeDelta::minutes(5);
74/// A slow check due this soon after a batch goes out joins it.
75pub const BATCH_PULL_FORWARD: TimeDelta = TimeDelta::minutes(15);
76/// A rung at least this far after the one before it is a slow one.
77const SLOW_GAP: TimeDelta = TimeDelta::days(1);
78
79/// How long a check that failed in transit waits before the same rung is tried again.
80pub const TRANSIENT_FAILURE_RETRY: TimeDelta = TimeDelta::minutes(5);
81
82/// Where a stopped row's `next_attempt_at` is parked: it is never claimed on its own again.
83pub fn never() -> DateTime<Utc> {
84    DateTime::<Utc>::from_naive_utc_and_offset(
85        chrono::NaiveDate::from_ymd_opt(9999, 12, 31)
86            .and_then(|date| date.and_hms_opt(0, 0, 0))
87            .unwrap_or_default(),
88        Utc,
89    )
90}
91
92/// One rung of a ladder, resolved against an anchor.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub struct ScheduledEnrolmentCheck {
95    pub step: i32,
96    pub due_at: DateTime<Utc>,
97    /// A slow rung, released in five-minute batches rather than on the ten-second tick.
98    pub is_batched: bool,
99}
100
101impl ScheduledEnrolmentCheck {
102    /// When the row may be claimed for this check: the due time itself, or for a slow rung the
103    /// first batch boundary at or after it.
104    pub fn release_at(&self) -> DateTime<Utc> {
105        if !self.is_batched {
106            return self.due_at;
107        }
108        let secs = self.due_at.timestamp();
109        let interval_secs = BATCH_INTERVAL.num_seconds();
110        let boundary = secs.div_euclid(interval_secs) * interval_secs;
111        let boundary = if boundary < secs {
112            boundary + interval_secs
113        } else {
114            boundary
115        };
116        DateTime::from_timestamp(boundary, 0).unwrap_or(self.due_at)
117    }
118}
119
120/// The group's ladder, as offsets from the anchor, ascending.
121fn ladder_offsets(group: EnrolmentCheckGroup) -> &'static [TimeDelta] {
122    const DAY: TimeDelta = TimeDelta::days(1);
123    const WEEK: TimeDelta = TimeDelta::weeks(1);
124    static COMPLETED: LazyLock<Vec<TimeDelta>> = LazyLock::new(|| {
125        [1, 3, 7, 14, 30, 60, 90]
126            .into_iter()
127            .map(TimeDelta::days)
128            .collect()
129    });
130    static VISITED: LazyLock<Vec<TimeDelta>> = LazyLock::new(|| {
131        let mut offsets: Vec<TimeDelta> = [60, 75, 120, 240, 480]
132            .into_iter()
133            .map(TimeDelta::minutes)
134            .collect();
135        extend_by(&mut offsets, DAY, TimeDelta::days(14));
136        extend_by(&mut offsets, WEEK, TimeDelta::days(90));
137        offsets
138    });
139    static CHECK_REQUESTED: LazyLock<Vec<TimeDelta>> = LazyLock::new(|| {
140        let mut offsets: Vec<TimeDelta> = [15, 50, 60, 75, 120, 180, 360, 720, 1440]
141            .into_iter()
142            .map(TimeDelta::minutes)
143            .collect();
144        extend_by(&mut offsets, DAY, TimeDelta::days(28));
145        extend_by(&mut offsets, WEEK, TimeDelta::days(180));
146        offsets
147    });
148    match group {
149        EnrolmentCheckGroup::Completed => &COMPLETED,
150        EnrolmentCheckGroup::Visited => &VISITED,
151        EnrolmentCheckGroup::CheckRequested => &CHECK_REQUESTED,
152    }
153}
154
155fn push_after(offsets: &mut Vec<TimeDelta>, gap: TimeDelta) {
156    let last = offsets.last().copied().unwrap_or_default();
157    offsets.push(last + gap);
158}
159
160/// Appends rungs `gap` apart until the next one would pass `until`.
161fn extend_by(offsets: &mut Vec<TimeDelta>, gap: TimeDelta, until: TimeDelta) {
162    while offsets.last().copied().unwrap_or_default() + gap <= until {
163        push_after(offsets, gap);
164    }
165}
166
167fn resolve(
168    group: EnrolmentCheckGroup,
169    anchor: DateTime<Utc>,
170    step: usize,
171) -> Option<ScheduledEnrolmentCheck> {
172    let offsets = ladder_offsets(group);
173    let offset = *offsets.get(step)?;
174    let previous = step
175        .checked_sub(1)
176        .map_or(TimeDelta::zero(), |previous| offsets[previous]);
177    Some(ScheduledEnrolmentCheck {
178        step: i32::try_from(step).ok()?,
179        due_at: anchor + offset,
180        is_batched: offset - previous >= SLOW_GAP,
181    })
182}
183
184/// The first rung of a ladder started at `anchor`. A check request's own check runs before it, at
185/// once.
186pub fn first_check(
187    group: EnrolmentCheckGroup,
188    anchor: DateTime<Utc>,
189) -> Option<ScheduledEnrolmentCheck> {
190    resolve(group, anchor, 0)
191}
192
193/// The first rung strictly after `after`, or `None` once the ladder has run out.
194pub fn next_check_after(
195    group: EnrolmentCheckGroup,
196    anchor: DateTime<Utc>,
197    after: DateTime<Utc>,
198) -> Option<ScheduledEnrolmentCheck> {
199    let elapsed = after - anchor;
200    let step = ladder_offsets(group).partition_point(|&offset| offset <= elapsed);
201    resolve(group, anchor, step)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    const ALL_GROUPS: [EnrolmentCheckGroup; 3] = [
209        EnrolmentCheckGroup::Completed,
210        EnrolmentCheckGroup::Visited,
211        EnrolmentCheckGroup::CheckRequested,
212    ];
213
214    fn at(offset: TimeDelta) -> DateTime<Utc> {
215        DateTime::from_timestamp(1_800_000_000, 0).unwrap() + offset
216    }
217
218    fn offsets_hours(group: EnrolmentCheckGroup) -> Vec<f64> {
219        ladder_offsets(group)
220            .iter()
221            .map(|offset| offset.num_seconds() as f64 / 3600.0)
222            .collect()
223    }
224
225    #[test]
226    fn the_ladders_follow_the_plan() {
227        let completed = offsets_hours(EnrolmentCheckGroup::Completed);
228        assert_eq!(completed, [24.0, 72.0, 168.0, 336.0, 720.0, 1440.0, 2160.0]);
229
230        let visited = offsets_hours(EnrolmentCheckGroup::Visited);
231        assert_eq!(visited[..6], [1.0, 1.25, 2.0, 4.0, 8.0, 32.0]);
232        assert!(visited.last().unwrap() <= &(90.0 * 24.0));
233
234        let requested = offsets_hours(EnrolmentCheckGroup::CheckRequested);
235        assert_eq!(
236            requested[..10],
237            [
238                0.25,
239                50.0 / 60.0,
240                1.0,
241                1.25,
242                2.0,
243                3.0,
244                6.0,
245                12.0,
246                24.0,
247                48.0
248            ]
249        );
250        assert!(requested.last().unwrap() <= &(180.0 * 24.0));
251        for group in ALL_GROUPS {
252            assert!(
253                ladder_offsets(group)
254                    .windows(2)
255                    .all(|pair| pair[0] < pair[1])
256            );
257        }
258    }
259
260    #[test]
261    fn the_next_check_is_the_first_rung_after_now() {
262        let anchor = at(TimeDelta::zero());
263        let requested = EnrolmentCheckGroup::CheckRequested;
264        assert_eq!(
265            first_check(requested, anchor).unwrap().due_at,
266            at(TimeDelta::minutes(15))
267        );
268        let after_first = next_check_after(requested, anchor, at(TimeDelta::minutes(15))).unwrap();
269        assert_eq!(after_first.step, 1);
270        assert_eq!(after_first.due_at, at(TimeDelta::minutes(50)));
271        // A row that sat out several rungs gets one catch-up, not one per rung.
272        let late = next_check_after(requested, anchor, at(TimeDelta::hours(4))).unwrap();
273        assert_eq!(late.due_at, at(TimeDelta::hours(6)));
274        assert_eq!(
275            next_check_after(
276                EnrolmentCheckGroup::Completed,
277                anchor,
278                at(TimeDelta::days(91))
279            ),
280            None
281        );
282    }
283
284    #[test]
285    fn only_slow_rungs_are_batched_on_five_minute_boundaries() {
286        let anchor = at(TimeDelta::seconds(7));
287        let first = first_check(EnrolmentCheckGroup::Completed, anchor).unwrap();
288        assert!(first.is_batched);
289        assert_eq!(
290            first.release_at().timestamp() % BATCH_INTERVAL.num_seconds(),
291            0
292        );
293        assert!(first.release_at() >= first.due_at);
294        assert!(first.release_at() < first.due_at + BATCH_INTERVAL);
295
296        let visit = first_check(EnrolmentCheckGroup::Visited, anchor).unwrap();
297        assert!(!visit.is_batched);
298        assert_eq!(visit.release_at(), visit.due_at);
299    }
300}