headless_lms_models/library/credit_registration/
enrolment_check_schedule.rs1use std::sync::LazyLock;
8
9use utoipa::ToSchema;
10
11use crate::prelude::*;
12use chrono::TimeDelta;
13
14#[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,
24 Visited,
26 CheckRequested,
28}
29
30#[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 Schedule,
37 StudentRequest,
38 TeacherRequest,
39 AdminRequest,
40 RosterListing,
42 AccountLink,
44}
45
46impl EnrolmentCheckSource {
47 pub fn is_request(self) -> bool {
49 matches!(
50 self,
51 Self::StudentRequest | Self::TeacherRequest | Self::AdminRequest
52 )
53 }
54}
55
56pub const REGISTRY_LAG: TimeDelta = TimeDelta::hours(1);
59
60pub const CHECK_REQUEST_MIN_INTERVAL: TimeDelta = TimeDelta::minutes(30);
63pub const STUDENT_CHECK_REQUEST_MIN_ROW_AGE: TimeDelta = TimeDelta::hours(1);
66pub const MAX_CHECK_REQUEST_RESTARTS_PER_DAY: i32 = 4;
68pub const CHECK_REQUEST_RESTART_WINDOW: TimeDelta = TimeDelta::days(1);
69pub const VISIT_RESTART_MIN_INTERVAL: TimeDelta = TimeDelta::days(1);
71
72pub const BATCH_INTERVAL: TimeDelta = TimeDelta::minutes(5);
74pub const BATCH_PULL_FORWARD: TimeDelta = TimeDelta::minutes(15);
76const SLOW_GAP: TimeDelta = TimeDelta::days(1);
78
79pub const TRANSIENT_FAILURE_RETRY: TimeDelta = TimeDelta::minutes(5);
81
82pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub struct ScheduledEnrolmentCheck {
95 pub step: i32,
96 pub due_at: DateTime<Utc>,
97 pub is_batched: bool,
99}
100
101impl ScheduledEnrolmentCheck {
102 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
120fn 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
160fn 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
184pub fn first_check(
187 group: EnrolmentCheckGroup,
188 anchor: DateTime<Utc>,
189) -> Option<ScheduledEnrolmentCheck> {
190 resolve(group, anchor, 0)
191}
192
193pub 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 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}