1use std::collections::HashMap;
11
12use chrono::TimeDelta;
13use utoipa::ToSchema;
14
15use crate::course_module_suotar_configurations::ModuleToList;
16use crate::credit_registrations::CreditRegistrationErrorCode;
17use crate::library::credit_registration::enrolment_check_schedule::{
18 COMPLETED_LADDER, DAY_SECS, HOUR_SECS, MINUTE_SECS, WEEK_SECS, ladder,
19};
20use crate::prelude::*;
21
22pub const FETCH_GRID_STEP: TimeDelta = TimeDelta::minutes(20);
24pub const MIN_REFETCH_GAP: TimeDelta = TimeDelta::minutes(15);
26pub const PRESS_MIN_REFETCH_GAP: TimeDelta = TimeDelta::minutes(5);
29pub const BASELINE_INTERVAL: TimeDelta = TimeDelta::weeks(1);
31pub const WAITING_HORIZON: TimeDelta = TimeDelta::days(90);
33pub const MAX_FETCHES_PER_DAY: i32 = 24;
37const ALONE_FAILURE_BACKOFF_SECS: [i64; 3] = [HOUR_SECS, 4 * HOUR_SECS, DAY_SECS];
39
40const WAITING_HORIZON_SECS: i64 = WAITING_HORIZON.num_seconds();
41const VISITED_LADDER: [i64; 33] = ladder(
42 &[1, 2, 3, 5, 8, 12, 24],
43 HOUR_SECS,
44 &[
45 (DAY_SECS, 14 * DAY_SECS),
46 (3 * DAY_SECS, 30 * DAY_SECS),
47 (WEEK_SECS, WAITING_HORIZON_SECS),
48 ],
49);
50const PRESSED_LADDER: [i64; 35] = ladder(
51 &[
52 0, 20, 40, 60, 80, 100, 120, 150, 180, 240, 360, 540, 720, 1440,
53 ],
54 MINUTE_SECS,
55 &[
56 (DAY_SECS, 7 * DAY_SECS),
57 (3 * DAY_SECS, 30 * DAY_SECS),
58 (WEEK_SECS, WAITING_HORIZON_SECS),
59 ],
60);
61
62pub fn fetch_slot(course_code: &str, at: DateTime<Utc>) -> DateTime<Utc> {
66 let hash = course_code
69 .bytes()
70 .fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
71 (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
72 });
73 let step = FETCH_GRID_STEP.num_seconds();
74 let offset = i64::try_from(hash % step.unsigned_abs()).unwrap_or(0);
75 let secs = at.timestamp() + i64::from(at.timestamp_subsec_nanos() > 0);
76 let point = (secs - offset + step - 1).div_euclid(step) * step + offset;
77 DateTime::from_timestamp(point, 0).unwrap_or(at)
78}
79
80#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, ToSchema)]
84pub struct AccountLinkingCodeCounters {
85 pub listed_person_count: i32,
87 pub already_linked_count: i32,
88 pub mailed_count: i32,
89 pub suppressed_by_dedup_count: i32,
90 pub suppressed_by_rate_cap_count: i32,
91 pub no_address_count: i32,
93}
94
95#[derive(Debug, Clone, PartialEq)]
97pub struct RosterSchedule {
98 pub course_code: String,
99 pub last_fetched_at: Option<DateTime<Utc>>,
100 pub last_fetch_started_at: Option<DateTime<Utc>>,
101 pub last_mailing_fetch_started_at: Option<DateTime<Utc>>,
103 pub last_fetch_duration_ms: Option<i32>,
104 pub last_listed_person_count: Option<i32>,
105 pub is_fetched_alone: bool,
106 pub consecutive_failures: i32,
107 pub retry_not_before: Option<DateTime<Utc>>,
108 pub last_error: Option<CreditRegistrationErrorCode>,
109 pub module_count: i64,
111 pub waiting_count: i64,
114 pub next_rung_at: Option<DateTime<Utc>>,
116 pub unserved_press_at: Option<DateTime<Utc>>,
118 pub fetches_today: i32,
120 pub fetch_requested_at: Option<DateTime<Utc>>,
122 pub linking_counters: Option<AccountLinkingCodeCounters>,
124 pub unlinked_enrolled_before_count: Option<i32>,
127}
128
129impl RosterSchedule {
130 pub fn next_fetch_at(&self, now: DateTime<Utc>) -> DateTime<Utc> {
133 let Some(last_fetched_at) = self.last_fetched_at else {
134 return now;
135 };
136 let mut reasons = vec![(last_fetched_at + BASELINE_INTERVAL, MIN_REFETCH_GAP)];
137 reasons.extend(
138 self.unserved_fetch_request_at()
139 .map(|requested_at| (requested_at, PRESS_MIN_REFETCH_GAP)),
140 );
141 if self.fetches_today < MAX_FETCHES_PER_DAY {
142 reasons.extend(self.next_rung_at.map(|rung_at| (rung_at, MIN_REFETCH_GAP)));
143 reasons.extend(
144 self.unserved_press_at
145 .map(|pressed_at| (pressed_at, PRESS_MIN_REFETCH_GAP)),
146 );
147 }
148 reasons
149 .into_iter()
150 .map(|(ready_at, gap)| self.slot_after(ready_at, gap))
151 .min()
152 .unwrap_or(now)
153 }
154
155 fn slot_after(&self, ready_at: DateTime<Utc>, gap: TimeDelta) -> DateTime<Utc> {
157 let ready_at = self
158 .last_fetched_at
159 .map_or(ready_at, |fetched_at| ready_at.max(fetched_at + gap));
160 fetch_slot(&self.course_code, ready_at)
161 }
162
163 pub fn unserved_fetch_request_at(&self) -> Option<DateTime<Utc>> {
165 self.fetch_requested_at.filter(|requested_at| {
166 self.last_fetch_started_at
167 .is_none_or(|started_at| *requested_at > started_at)
168 })
169 }
170
171 pub fn is_due(&self, now: DateTime<Utc>) -> bool {
173 self.retry_not_before.is_none_or(|retry| retry <= now) && self.next_fetch_at(now) <= now
174 }
175}
176
177pub async fn ensure_rows(conn: &mut PgConnection, course_id: Option<Uuid>) -> ModelResult<()> {
180 sqlx::query!(
181 r#"
182INSERT INTO course_module_suotar_configurations (course_module_id)
183SELECT acm.course_module_id
184FROM credit_registration_active_course_modules acm
185 JOIN course_modules cm ON cm.id = acm.course_module_id AND cm.deleted_at IS NULL
186WHERE TRIM(COALESCE(cm.uh_course_code, '')) <> ''
187 AND ($1::uuid IS NULL OR acm.course_id = $1) ON CONFLICT (course_module_id) DO NOTHING
188 "#,
189 course_id,
190 )
191 .execute(&mut *conn)
192 .await?;
193 sqlx::query!(
194 r#"
195INSERT INTO credit_registration_roster_schedules (course_code)
196SELECT DISTINCT TRIM(cm.uh_course_code)
197FROM credit_registration_active_course_modules acm
198 JOIN course_modules cm ON cm.id = acm.course_module_id AND cm.deleted_at IS NULL
199WHERE TRIM(COALESCE(cm.uh_course_code, '')) <> ''
200 AND ($1::uuid IS NULL OR acm.course_id = $1) ON CONFLICT (course_code) DO NOTHING
201 "#,
202 course_id,
203 )
204 .execute(conn)
205 .await?;
206 Ok(())
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum ScheduleSelection<'a> {
212 Every,
213 DueCandidates,
216 Code(&'a str),
218}
219
220pub async fn get_schedules(
224 conn: &mut PgConnection,
225 course_id: Option<Uuid>,
226 selection: ScheduleSelection<'_>,
227 account_linking_since: Option<DateTime<Utc>>,
228) -> ModelResult<Vec<RosterSchedule>> {
229 let only_code = match selection {
230 ScheduleSelection::Code(course_code) => Some(course_code),
231 ScheduleSelection::Every | ScheduleSelection::DueCandidates => None,
232 };
233 let rows = sqlx::query!(
234 r#"
235WITH modules AS (
236 SELECT TRIM(cm.uh_course_code) AS course_code
237 FROM credit_registration_active_course_modules acm
238 JOIN course_modules cm ON cm.id = acm.course_module_id AND cm.deleted_at IS NULL
239 WHERE TRIM(COALESCE(cm.uh_course_code, '')) <> ''
240 AND ($1::uuid IS NULL OR acm.course_id = $1)
241 AND (
242 $4::text IS NULL
243 OR TRIM(cm.uh_course_code) = $4
244 )
245)
246SELECT s.course_code,
247 s.last_fetched_at,
248 s.last_fetch_started_at,
249 s.last_mailing_fetch_started_at,
250 s.last_fetch_duration_ms,
251 s.last_listed_person_count,
252 s.is_fetched_alone,
253 s.consecutive_failures,
254 s.retry_not_before,
255 s.last_error,
256 s.fetch_requested_at,
257 s.linking_listed_count,
258 s.linking_already_linked_count,
259 s.linking_mailed_count,
260 s.linking_suppressed_by_dedup_count,
261 s.linking_suppressed_by_rate_cap_count,
262 s.linking_no_address_count,
263 s.linking_unlinked_enrolled_before_count,
264 CASE
265 WHEN s.fetch_day = (now() AT TIME ZONE 'UTC')::date THEN s.fetch_day_count
266 ELSE 0
267 END AS "fetches_today!",
268 (
269 SELECT COUNT(*)
270 FROM modules m
271 WHERE m.course_code = s.course_code
272 ) AS "module_count!"
273FROM credit_registration_roster_schedules s
274WHERE s.course_code IN (
275 SELECT course_code
276 FROM modules
277 )
278 AND (
279 NOT $2::boolean
280 OR (
281 (
282 s.retry_not_before IS NULL
283 OR s.retry_not_before <= now()
284 )
285 AND (
286 s.last_fetched_at IS NULL
287 OR s.last_fetched_at < $3
288 )
289 )
290 )
291ORDER BY s.course_code
292 "#,
293 course_id,
294 selection == ScheduleSelection::DueCandidates,
295 Utc::now() - PRESS_MIN_REFETCH_GAP,
296 only_code,
297 )
298 .fetch_all(&mut *conn)
299 .await?;
300 let codes: Vec<String> = rows.iter().map(|row| row.course_code.clone()).collect();
301 let served_until: Vec<DateTime<Utc>> = rows
303 .iter()
304 .map(|row| {
305 row.last_mailing_fetch_started_at
306 .unwrap_or(DateTime::UNIX_EPOCH)
307 })
308 .collect();
309 let mut waiting = get_waiting(
310 conn,
311 course_id,
312 &codes,
313 &served_until,
314 account_linking_since,
315 )
316 .await?;
317 Ok(rows
318 .into_iter()
319 .map(|row| {
320 let waiting = waiting.remove(&row.course_code).unwrap_or_default();
321 let linking_counters =
322 row.linking_listed_count
323 .map(|listed_person_count| AccountLinkingCodeCounters {
324 listed_person_count,
325 already_linked_count: row.linking_already_linked_count.unwrap_or(0),
326 mailed_count: row.linking_mailed_count.unwrap_or(0),
327 suppressed_by_dedup_count: row
328 .linking_suppressed_by_dedup_count
329 .unwrap_or(0),
330 suppressed_by_rate_cap_count: row
331 .linking_suppressed_by_rate_cap_count
332 .unwrap_or(0),
333 no_address_count: row.linking_no_address_count.unwrap_or(0),
334 });
335 RosterSchedule {
336 course_code: row.course_code,
337 last_fetched_at: row.last_fetched_at,
338 last_fetch_started_at: row.last_fetch_started_at,
339 last_mailing_fetch_started_at: row.last_mailing_fetch_started_at,
340 last_fetch_duration_ms: row.last_fetch_duration_ms,
341 last_listed_person_count: row.last_listed_person_count,
342 is_fetched_alone: row.is_fetched_alone,
343 consecutive_failures: row.consecutive_failures,
344 retry_not_before: row.retry_not_before,
345 last_error: row.last_error,
346 module_count: row.module_count,
347 waiting_count: waiting.waiting_count,
348 next_rung_at: waiting.next_rung_at,
349 unserved_press_at: waiting.unserved_press_at,
350 fetches_today: row.fetches_today,
351 fetch_requested_at: row.fetch_requested_at,
352 linking_counters,
353 unlinked_enrolled_before_count: row.linking_unlinked_enrolled_before_count,
354 }
355 })
356 .collect())
357}
358
359#[derive(Debug, Default)]
361struct CodeWaiting {
362 waiting_count: i64,
363 next_rung_at: Option<DateTime<Utc>>,
364 unserved_press_at: Option<DateTime<Utc>>,
365}
366
367async fn get_waiting(
370 conn: &mut PgConnection,
371 course_id: Option<Uuid>,
372 course_codes: &[String],
373 served_until: &[DateTime<Utc>],
374 account_linking_since: Option<DateTime<Utc>>,
375) -> ModelResult<HashMap<String, CodeWaiting>> {
376 if account_linking_since.is_none() || course_codes.is_empty() {
377 return Ok(HashMap::new());
378 }
379 let rows = sqlx::query!(
380 r#"
381WITH codes AS (
382 SELECT *
383 FROM UNNEST($2::text [], $3::timestamptz []) AS code(course_code, served_until)
384),
385waiting AS (
386 SELECT codes.course_code,
387 codes.served_until,
388 cr.id AS credit_registration_id,
389 ladder.anchor_at,
390 ladder.offsets,
391 ladder.is_press
392 FROM codes
393 JOIN course_modules cm ON TRIM(cm.uh_course_code) = codes.course_code
394 AND cm.deleted_at IS NULL
395 JOIN credit_registration_active_course_modules acm ON acm.course_module_id = cm.id
396 JOIN credit_registrations cr ON cr.course_module_id = cm.id
397 JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
398 JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
399 LEFT JOIN credit_registration_enrolment_check_signals sig ON sig.course_module_completion_id = cr.course_module_completion_id
400 AND sig.deleted_at IS NULL
401 CROSS JOIN LATERAL (
402 VALUES (cmc.completion_date, $4::bigint [], FALSE),
403 (sig.visit_ladder_anchor_at, $5::bigint [], FALSE),
404 (sig.check_request_ladder_anchor_at, $6::bigint [], TRUE)
405 ) AS ladder(anchor_at, offsets, is_press)
406 WHERE ($1::uuid IS NULL OR acm.course_id = $1)
407 AND cr.state = 'pending'
408 AND cr.superseded_by_id IS NULL
409 AND cr.deleted_at IS NULL
410 AND p.completion_eligible
411 AND NOT p.has_verified_student_number
412 AND cmc.completion_date >= $7::timestamptz
413 AND ladder.anchor_at > now() - ($8::bigint * INTERVAL '1 second')
414)
415SELECT w.course_code AS "course_code!",
416 COUNT(DISTINCT w.credit_registration_id) AS "waiting_count!",
417 MIN(rung.due_at) FILTER (
418 WHERE rung.due_at > w.served_until
419 ) AS next_rung_at,
420 MAX(w.anchor_at) FILTER (
421 WHERE w.is_press
422 AND w.anchor_at > w.served_until
423 ) AS unserved_press_at
424FROM waiting w
425 CROSS JOIN LATERAL (
426 SELECT w.anchor_at + (o.secs * INTERVAL '1 second') AS due_at
427 FROM unnest(w.offsets) AS o(secs)
428 ) rung
429GROUP BY w.course_code
430 "#,
431 course_id,
432 course_codes,
433 served_until,
434 &COMPLETED_LADDER[..],
435 &VISITED_LADDER[..],
436 &PRESSED_LADDER[..],
437 account_linking_since,
438 WAITING_HORIZON.num_seconds(),
439 )
440 .fetch_all(conn)
441 .await?;
442 Ok(rows
443 .into_iter()
444 .map(|row| {
445 (
446 row.course_code,
447 CodeWaiting {
448 waiting_count: row.waiting_count,
449 next_rung_at: row.next_rung_at,
450 unserved_press_at: row.unserved_press_at,
451 },
452 )
453 })
454 .collect())
455}
456
457pub async fn get_modules_by_code(
460 conn: &mut PgConnection,
461 course_id: Option<Uuid>,
462 course_codes: &[String],
463) -> ModelResult<HashMap<String, Vec<ModuleToList>>> {
464 let modules = sqlx::query_as!(
465 ModuleToList,
466 r#"
467SELECT acm.course_module_id AS "course_module_id!",
468 acm.course_id AS "course_id!",
469 TRIM(cm.uh_course_code) AS "uh_course_code!",
470 co.language_code AS "course_language_code!"
471FROM credit_registration_active_course_modules acm
472 JOIN course_modules cm ON cm.id = acm.course_module_id AND cm.deleted_at IS NULL
473 JOIN courses co ON co.id = acm.course_id AND co.deleted_at IS NULL
474WHERE TRIM(cm.uh_course_code) = ANY($2::text [])
475 AND ($1::uuid IS NULL OR acm.course_id = $1)
476ORDER BY cm.id
477 "#,
478 course_id,
479 course_codes,
480 )
481 .fetch_all(conn)
482 .await?;
483 let mut modules_by_code: HashMap<String, Vec<ModuleToList>> = HashMap::new();
484 for module in modules {
485 modules_by_code
486 .entry(module.uh_course_code.clone())
487 .or_default()
488 .push(module);
489 }
490 Ok(modules_by_code)
491}
492
493pub async fn mark_attempted(
496 conn: &mut PgConnection,
497 course_codes: &[String],
498) -> ModelResult<DateTime<Utc>> {
499 let started_at = sqlx::query_scalar!(
500 r#"
501WITH stamped AS (
502 UPDATE credit_registration_roster_schedules
503 SET last_attempted_at = now(),
504 fetch_day_count = CASE
505 WHEN fetch_day = (now() AT TIME ZONE 'UTC')::date THEN fetch_day_count + 1
506 ELSE 1
507 END,
508 fetch_day = (now() AT TIME ZONE 'UTC')::date
509 WHERE course_code = ANY($1::text [])
510)
511SELECT now() AS "started_at!"
512 "#,
513 course_codes,
514 )
515 .fetch_one(conn)
516 .await?;
517 Ok(started_at)
518}
519
520pub async fn mark_fetched(
525 conn: &mut PgConnection,
526 course_code: &str,
527 started_at: DateTime<Utc>,
528 could_claim_mail: bool,
529 listed_person_count: i32,
530 duration_ms: i32,
531) -> ModelResult<Option<DateTime<Utc>>> {
532 let previous = sqlx::query_scalar!(
533 r#"
534WITH previous AS (
535 SELECT id,
536 last_fetched_at
537 FROM credit_registration_roster_schedules
538 WHERE course_code = $1
539 FOR UPDATE
540)
541UPDATE credit_registration_roster_schedules s
542SET last_fetched_at = now(),
543 last_fetch_started_at = $4,
544 last_mailing_fetch_started_at = CASE
545 WHEN $5 THEN $4
546 ELSE s.last_mailing_fetch_started_at
547 END,
548 last_fetch_duration_ms = $3,
549 last_listed_person_count = $2,
550 is_fetched_alone = FALSE,
551 consecutive_failures = 0,
552 retry_not_before = NULL,
553 last_error = NULL
554FROM previous
555WHERE s.id = previous.id
556RETURNING previous.last_fetched_at
557 "#,
558 course_code,
559 listed_person_count,
560 duration_ms,
561 started_at,
562 could_claim_mail,
563 )
564 .fetch_optional(conn)
565 .await?;
566 Ok(previous.flatten())
567}
568
569pub async fn record_linking_counters(
573 conn: &mut PgConnection,
574 course_code: &str,
575 counters: &AccountLinkingCodeCounters,
576 unlinked_enrolled_before_count: i32,
577) -> ModelResult<()> {
578 sqlx::query!(
579 r#"
580UPDATE credit_registration_roster_schedules
581SET linking_listed_count = $2,
582 linking_already_linked_count = $3,
583 linking_mailed_count = $4,
584 linking_suppressed_by_dedup_count = $5,
585 linking_suppressed_by_rate_cap_count = $6,
586 linking_no_address_count = $7,
587 linking_unlinked_enrolled_before_count = $8
588WHERE course_code = $1
589 "#,
590 course_code,
591 counters.listed_person_count,
592 counters.already_linked_count,
593 counters.mailed_count,
594 counters.suppressed_by_dedup_count,
595 counters.suppressed_by_rate_cap_count,
596 counters.no_address_count,
597 unlinked_enrolled_before_count,
598 )
599 .execute(conn)
600 .await?;
601 Ok(())
602}
603
604pub async fn request_fetch(
607 conn: &mut PgConnection,
608 course_code: &str,
609) -> ModelResult<Option<Uuid>> {
610 let id = sqlx::query_scalar!(
611 r#"
612UPDATE credit_registration_roster_schedules
613SET fetch_requested_at = now()
614WHERE course_code = $1
615RETURNING id
616 "#,
617 course_code,
618 )
619 .fetch_optional(conn)
620 .await?;
621 Ok(id)
622}
623
624pub async fn mark_batch_failed(
627 conn: &mut PgConnection,
628 course_codes: &[String],
629 error: CreditRegistrationErrorCode,
630) -> ModelResult<()> {
631 sqlx::query!(
632 r#"
633UPDATE credit_registration_roster_schedules
634SET is_fetched_alone = TRUE,
635 last_error = $2
636WHERE course_code = ANY($1::text [])
637 "#,
638 course_codes,
639 error as CreditRegistrationErrorCode,
640 )
641 .execute(conn)
642 .await?;
643 Ok(())
644}
645
646pub async fn mark_alone_failed(
649 conn: &mut PgConnection,
650 course_code: &str,
651 error: CreditRegistrationErrorCode,
652) -> ModelResult<()> {
653 sqlx::query!(
654 r#"
655UPDATE credit_registration_roster_schedules
656SET is_fetched_alone = TRUE,
657 consecutive_failures = consecutive_failures + 1,
658 last_error = $2,
659 retry_not_before = now() + (
660 ($3::bigint [])[LEAST(consecutive_failures + 1, CARDINALITY($3::bigint []))] * INTERVAL '1 second'
661 )
662WHERE course_code = $1
663 "#,
664 course_code,
665 error as CreditRegistrationErrorCode,
666 &ALONE_FAILURE_BACKOFF_SECS[..],
667 )
668 .execute(conn)
669 .await?;
670 Ok(())
671}
672
673#[derive(Debug, Clone, PartialEq)]
675pub struct FailingRosterCode {
676 pub course_code: String,
677 pub consecutive_failures: i32,
678 pub last_error: Option<CreditRegistrationErrorCode>,
679 pub last_attempted_at: Option<DateTime<Utc>>,
680}
681
682pub async fn get_failing_codes(conn: &mut PgConnection) -> ModelResult<Vec<FailingRosterCode>> {
684 let res = sqlx::query_as!(
685 FailingRosterCode,
686 r#"
687SELECT course_code,
688 consecutive_failures,
689 last_error,
690 last_attempted_at
691FROM credit_registration_roster_schedules
692WHERE consecutive_failures > 0
693ORDER BY consecutive_failures DESC,
694 course_code
695 "#,
696 )
697 .fetch_all(conn)
698 .await?;
699 Ok(res)
700}
701
702pub mod testing {
704 use super::{BASELINE_INTERVAL, FETCH_GRID_STEP};
705 use crate::prelude::*;
706
707 pub async fn make_listings_due_for_testing(
711 conn: &mut PgConnection,
712 course_id: Uuid,
713 ) -> ModelResult<u64> {
714 let res = sqlx::query!(
715 r#"
716UPDATE credit_registration_roster_schedules
717SET last_fetched_at = last_fetched_at - ($2::bigint * INTERVAL '1 second'),
718 retry_not_before = NULL
719WHERE course_code IN (
720 SELECT TRIM(cm.uh_course_code)
721 FROM credit_registration_active_course_modules acm
722 JOIN course_modules cm ON cm.id = acm.course_module_id AND cm.deleted_at IS NULL
723 WHERE acm.course_id = $1
724 )
725 "#,
726 course_id,
727 (BASELINE_INTERVAL + FETCH_GRID_STEP).num_seconds(),
728 )
729 .execute(conn)
730 .await?;
731 Ok(res.rows_affected())
732 }
733}