Skip to main content

headless_lms_models/
credit_registration_roster_schedules.rs

1//! When enrolment discovery fetches each course code's enrolment list.
2//!
3//! A code is due weekly, and sooner while somebody on its modules waits for a student number: each
4//! such person is on the ladders of the signals they have given that they are enrolling, and the
5//! code is due at the earliest rung no fetch that could claim linking mail has served. Every due
6//! time snaps up to the code's own grid, see [`fetch_slot`], so codes spread evenly over the hour.
7//! Both are derived whenever they are needed; the table keeps only what cannot be: when fetches
8//! ran, the failure backoff and how many fetches of the code went out today.
9
10use 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
22/// How far apart the points of a code's fetch grid are. Divides an hour.
23pub const FETCH_GRID_STEP: TimeDelta = TimeDelta::minutes(20);
24/// A code fetched this recently is not fetched again, whatever is due.
25pub const MIN_REFETCH_GAP: TimeDelta = TimeDelta::minutes(15);
26/// The shorter gap the first fetch after an "I have enrolled" press or an admin's request needs,
27/// since somebody is waiting on it.
28pub const PRESS_MIN_REFETCH_GAP: TimeDelta = TimeDelta::minutes(5);
29/// How often every code is fetched, whether or not anybody on it is waiting.
30pub const BASELINE_INTERVAL: TimeDelta = TimeDelta::weeks(1);
31/// How long after its anchor a waiting person still makes the code due: every ladder ends here.
32pub const WAITING_HORIZON: TimeDelta = TimeDelta::days(90);
33/// How many fetches of one code may go out in a UTC day. Past it, only the weekly fetch and an
34/// admin's request make the code due: a third of the grid points, so one busy code cannot crowd
35/// out the rest.
36pub const MAX_FETCHES_PER_DAY: i32 = 24;
37/// How long a code that failed on its own is left alone, by how many times in a row it has.
38const 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
62/// The first point of `course_code`'s fetch grid at or after `at`: [`FETCH_GRID_STEP`] apart, at an
63/// offset into the step fixed per code. The only place that decides when within the hour a code
64/// goes out.
65pub fn fetch_slot(course_code: &str, at: DateTime<Utc>) -> DateTime<Utc> {
66    // FNV-1a: the standard library's hashers do not promise a stable output, and a code should
67    // keep its grid across processes and releases.
68    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/// What one code's last enrolment list that fed account linking did, each person counted once in
81/// exactly one of the counters after `listed_person_count`. Written whole, so the dashboard never
82/// mixes two runs.
83#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, ToSchema)]
84pub struct AccountLinkingCodeCounters {
85    /// Only those enrolled since account linking began.
86    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    /// Persons the registry holds no address for: the one population no remedy here can reach.
92    pub no_address_count: i32,
93}
94
95/// One code's schedule, with the waiting people its next fetch is derived from.
96#[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    /// When the latest arrived fetch that could claim linking mail was sent.
102    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    /// The active modules on the code, which share its enrolment list.
110    pub module_count: i64,
111    /// Registrations on the code's modules waiting for a student number within
112    /// [`WAITING_HORIZON`] of an anchor. Always zero with account linking off.
113    pub waiting_count: i64,
114    /// The earliest unserved rung of a waiting person's ladder.
115    pub next_rung_at: Option<DateTime<Utc>>,
116    /// The latest unserved "I have enrolled" press a waiting person's ladder is anchored on.
117    pub unserved_press_at: Option<DateTime<Utc>>,
118    /// Fetches of the code sent today (UTC), against [`MAX_FETCHES_PER_DAY`].
119    pub fetches_today: i32,
120    /// An admin's fetch request; served once a fetch sent after it arrives.
121    pub fetch_requested_at: Option<DateTime<Utc>>,
122    /// The last enrolment list that fed account linking.
123    pub linking_counters: Option<AccountLinkingCodeCounters>,
124    /// From the same list: people who enrolled before account linking began and are linked to no
125    /// account, so no linking mail went to them.
126    pub unlinked_enrolled_before_count: Option<i32>,
127}
128
129impl RosterSchedule {
130    /// When the code is next due, ignoring the failure backoff: at once if it has never been
131    /// fetched.
132    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    /// The first grid point at or after `ready_at` that is at least `gap` after the last fetch.
156    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    /// An admin's fetch request no fetch sent after it has served yet.
164    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    /// Whether the code is to be fetched now, failure backoff included.
172    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
177/// Creates the schedule rows of every listable code, and the per-module configuration rows the
178/// listing status is written to. `course_id` narrows it to one course.
179pub 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/// Which codes [`get_schedules`] reads.
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum ScheduleSelection<'a> {
212    Every,
213    /// Only the codes not fetched in the last [`PRESS_MIN_REFETCH_GAP`] and out of backoff, a superset
214    /// [`RosterSchedule::is_due`] narrows down: the waiting people are then read for those alone.
215    DueCandidates,
216    /// Only this code, which must be passed trimmed.
217    Code(&'a str),
218}
219
220/// Every listable code that has a schedule row; see [`ensure_rows`]. `course_id` narrows the codes
221/// and the modules their waiting people come from to one course, for a scoped tick. Only
222/// completions on or after `account_linking_since` can wait, and with it `None` nobody does.
223pub 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    // Every rung comes after the epoch, so a code never fetched has served none.
302    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/// The people waiting on one code; see [`RosterSchedule`].
360#[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
367/// The people waiting on each code, their rungs and presses counted after the code's
368/// `served_until`.
369async 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
457/// The active modules on each of `course_codes`, which share its roster. `course_id` narrows them
458/// to one course, as it does for [`get_schedules`].
459pub 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
493/// Stamps the codes a listing request is about to go out for and counts the fetch against
494/// [`MAX_FETCHES_PER_DAY`]. Returns the stamp to hand to [`mark_fetched`].
495pub 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
520/// Records an enrolment list that arrived, or Suotar saying it holds no realisation of the code,
521/// and clears the failure streak. `started_at` is what [`mark_attempted`] returned for the request,
522/// and `could_claim_mail` whether the list was free to claim linking mail; only such a fetch serves
523/// the waiting people's rungs and presses. Returns when the code was fetched before this.
524pub 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
569/// Overwrites the code's linking counters with what its latest enrolment list did.
570/// `unlinked_enrolled_before_count` counts the people the list held outside the counters: enrolled
571/// before account linking began, and linked to no account.
572pub 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
604/// Makes the code due at its next grid point, past [`MAX_FETCHES_PER_DAY`]. Returns the schedule
605/// row's id, or `None` for a code with no row.
606pub 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
624/// Records a request batching several codes that failed as a whole. Each is listed on its own from
625/// now on, until it succeeds, so one bad code stops failing the others.
626pub 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
646/// Records a listing of the code on its own that failed, and backs the code off: an hour, four
647/// hours, then daily.
648pub 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/// Codes listed on their own that keep failing, for the admin alert.
674#[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
682/// The codes whose latest listings failed, most consecutive failures first.
683pub 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
702/// Test-mode setup the system tests drive through the mock Suotar control routes.
703pub mod testing {
704    use super::{BASELINE_INTERVAL, FETCH_GRID_STEP};
705    use crate::prelude::*;
706
707    /// Waits out the weekly fetch and the failure backoff of every code on the course's active
708    /// modules, and returns how many codes it moved. Exists only for test setup: specs cannot wait
709    /// for a grid point.
710    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}