Skip to main content

headless_lms_models/credit_registrations/
metrics.rs

1//! The dashboard's and the health alerts' counts over the whole ledger.
2
3use super::attention::{AttentionReason, AttentionRegistration};
4use super::state::{CreditRegistrationErrorCode, CreditRegistrationState};
5use crate::credit_registration_enrolment_routes::CreditRegistrationEnrolmentRoute;
6use crate::library::credit_registration::PendingReasonCounts;
7use crate::prelude::*;
8use chrono::TimeDelta;
9use std::collections::HashMap;
10use utoipa::ToSchema;
11
12/// Live rows per state, for the dashboard funnel. Superseded attempts are excluded, as in the
13/// per-course sibling, or a course that regrades counts every student twice.
14pub async fn count_by_state(
15    conn: &mut PgConnection,
16) -> ModelResult<Vec<(CreditRegistrationState, i64)>> {
17    let rows = sqlx::query!(
18        r#"
19SELECT state,
20  COUNT(*) AS "count!"
21FROM credit_registrations
22WHERE superseded_by_id IS NULL
23  AND deleted_at IS NULL
24GROUP BY state
25        "#,
26    )
27    .fetch_all(conn)
28    .await?;
29    Ok(rows.into_iter().map(|r| (r.state, r.count)).collect())
30}
31
32/// Live rows parked in `no_usable_enrolment` that an unscoped resolve-enrolments claim would take
33/// for an enrolment check now; the rest wait for their schedule, their module, or a lookup already
34/// out. Leaves out the claim's one-row-per-student-and-module hold, which only defers a row.
35///
36/// Shares its filters with the unscoped `claim` and with the released-check test of
37/// [`pull_forward_batched_checks`](crate::library::credit_registration::enrolment_checks::pull_forward_batched_checks);
38/// change all three together, or the queue depth counts rows no claim takes.
39pub async fn count_due_enrolment_checks(conn: &mut PgConnection) -> ModelResult<i64> {
40    let count = sqlx::query_scalar!(
41        r#"
42SELECT COUNT(*) AS "count!"
43FROM credit_registrations cr
44  JOIN credit_registration_active_course_modules acm ON acm.course_module_id = cr.course_module_id
45  JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
46WHERE cr.state = 'no_usable_enrolment'
47  AND cr.next_attempt_at <= now()
48  AND cr.superseded_by_id IS NULL
49  AND cr.deleted_at IS NULL
50  AND (
51    cmc.register_credits_via_suotar
52    OR cr.submitted_at IS NOT NULL
53  )
54  AND (
55    cr.enrolment_check_claimed_until IS NULL
56    OR cr.enrolment_check_claimed_until <= now()
57  )
58  AND NOT EXISTS (
59    SELECT 1
60    FROM credit_registration_test_exclusive_holds h
61    WHERE h.user_id = cr.user_id
62      AND (
63        h.course_id IS NULL
64        OR h.course_id = cr.course_id
65      )
66      AND h.held_until > now()
67  )
68        "#,
69    )
70    .fetch_one(conn)
71    .await?;
72    Ok(count)
73}
74
75/// Live `pending` rows per blocker. Derived from `credit_registration_preconditions`, so it cannot
76/// disagree with what the recompute is waiting for or with what the student is shown.
77pub async fn count_pending_by_reason(conn: &mut PgConnection) -> ModelResult<PendingReasonCounts> {
78    let row = sqlx::query!(
79        r#"
80SELECT COUNT(*) FILTER (
81    WHERE NOT p.completion_eligible
82  ) AS "completion_count!",
83  COUNT(*) FILTER (
84    WHERE p.completion_eligible
85      AND NOT p.has_verified_student_number
86  ) AS "student_number_count!",
87  COUNT(*) FILTER (
88    WHERE p.completion_eligible
89      AND p.has_verified_student_number
90      AND NOT p.course_code_allowed
91  ) AS "course_code_count!"
92FROM credit_registrations cr
93  JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
94WHERE cr.state = 'pending'
95  AND cr.superseded_by_id IS NULL
96  AND cr.deleted_at IS NULL
97        "#,
98    )
99    .fetch_one(conn)
100    .await?;
101    Ok(PendingReasonCounts {
102        completion_count: row.completion_count,
103        student_number_count: row.student_number_count,
104        course_code_count: row.course_code_count,
105    })
106}
107
108/// A `pending` registration waiting for a student number, with what its student has done about it.
109#[derive(Debug, Clone, PartialEq)]
110pub struct WaitingForStudentNumber {
111    pub credit_registration_id: Uuid,
112    pub user_id: Uuid,
113    pub email: Option<String>,
114    pub first_name: Option<String>,
115    pub last_name: Option<String>,
116    pub course_id: Uuid,
117    pub course_name: String,
118    pub course_module_name: Option<String>,
119    pub uh_course_code: Option<String>,
120    pub completion_date: DateTime<Utc>,
121    pub last_visited_at: Option<DateTime<Utc>>,
122    /// The last "I have enrolled" press; `None` when the latest check request came from someone else.
123    pub last_check_requested_at: Option<DateTime<Utc>>,
124    /// The student's standing "I have enrolled" press, which is what makes them Pressed.
125    pub pressed_at: Option<DateTime<Utc>>,
126}
127
128/// The rows behind [`PendingReasonCounts::student_number_count`] completed on or after `since`,
129/// longest waiting first, with how many there are in all.
130pub async fn get_waiting_for_student_number(
131    conn: &mut PgConnection,
132    since: Option<DateTime<Utc>>,
133    limit: i64,
134) -> ModelResult<(Vec<WaitingForStudentNumber>, i64)> {
135    let rows = sqlx::query!(
136        r#"
137SELECT cr.id AS credit_registration_id,
138  cr.user_id,
139  ud.email AS "email?",
140  ud.first_name AS "first_name?",
141  ud.last_name AS "last_name?",
142  cr.course_id,
143  c.name AS course_name,
144  cm.name AS "course_module_name?",
145  cm.uh_course_code AS "uh_course_code?",
146  cmc.completion_date,
147  sig.last_visited_at AS "last_visited_at?",
148  CASE
149    WHEN sig.check_request_source = 'student_request' THEN sig.last_check_requested_at
150  END AS "last_check_requested_at?",
151  route.enrolment_confirmed_at AS "pressed_at?",
152  COUNT(*) OVER () AS "total!"
153FROM credit_registrations cr
154  JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
155  JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
156  AND cmc.deleted_at IS NULL
157  JOIN courses c ON c.id = cr.course_id
158  AND c.deleted_at IS NULL
159  JOIN course_modules cm ON cm.id = cr.course_module_id
160  AND cm.deleted_at IS NULL
161  LEFT JOIN user_details ud ON ud.user_id = cr.user_id
162  LEFT JOIN credit_registration_enrolment_check_signals sig ON sig.course_module_completion_id = cr.course_module_completion_id
163  AND sig.deleted_at IS NULL
164  LEFT JOIN credit_registration_enrolment_routes route ON route.course_module_completion_id = cr.course_module_completion_id
165  AND route.deleted_at IS NULL
166WHERE cr.state = 'pending'
167  AND cr.superseded_by_id IS NULL
168  AND cr.deleted_at IS NULL
169  AND p.completion_eligible
170  AND NOT p.has_verified_student_number
171  AND ($1::timestamptz IS NULL OR cmc.completion_date >= $1)
172ORDER BY cmc.completion_date,
173  cr.id
174LIMIT $2
175        "#,
176        since,
177        limit,
178    )
179    .fetch_all(conn)
180    .await?;
181    let total = rows.first().map_or(0, |row| row.total);
182    Ok((
183        rows.into_iter()
184            .map(|row| WaitingForStudentNumber {
185                credit_registration_id: row.credit_registration_id,
186                user_id: row.user_id,
187                email: row.email,
188                first_name: row.first_name,
189                last_name: row.last_name,
190                course_id: row.course_id,
191                course_name: row.course_name,
192                course_module_name: row.course_module_name,
193                uh_course_code: row.uh_course_code,
194                completion_date: row.completion_date,
195                last_visited_at: row.last_visited_at,
196                last_check_requested_at: row.last_check_requested_at,
197                pressed_at: row.pressed_at,
198            })
199            .collect(),
200        total,
201    ))
202}
203
204/// A student waiting for a student number who pressed "I have enrolled", with what has happened on
205/// their course code since.
206#[derive(Debug, Clone, PartialEq)]
207pub struct WaitingPresser {
208    pub credit_registration_id: Uuid,
209    pub user_id: Uuid,
210    pub email: Option<String>,
211    pub first_name: Option<String>,
212    pub last_name: Option<String>,
213    pub course_id: Uuid,
214    pub course_name: String,
215    pub course_module_id: Uuid,
216    pub course_module_name: Option<String>,
217    /// The module's code now, trimmed.
218    pub uh_course_code: Option<String>,
219    pub completion_date: DateTime<Utc>,
220    pub pressed_at: DateTime<Utc>,
221    pub enrolment_route: CreditRegistrationEnrolmentRoute,
222    /// Linking emails claimed on any course sharing the code since the press. Not attributable to
223    /// this student: an email is tied to an account only once its link is used.
224    pub linking_emails_on_code_since_press: i64,
225    pub last_linking_email_on_code_at: Option<DateTime<Utc>>,
226}
227
228/// Students waiting for a student number who pressed "I have enrolled" and completed on or after
229/// `since`, longest waiting first. `credit_registration_id` narrows to one row.
230pub async fn get_waiting_pressers(
231    conn: &mut PgConnection,
232    since: Option<DateTime<Utc>>,
233    credit_registration_id: Option<Uuid>,
234) -> ModelResult<Vec<WaitingPresser>> {
235    let res = sqlx::query_as!(
236        WaitingPresser,
237        r#"
238SELECT cr.id AS credit_registration_id,
239  cr.user_id,
240  ud.email AS "email?",
241  ud.first_name AS "first_name?",
242  ud.last_name AS "last_name?",
243  cr.course_id,
244  c.name AS course_name,
245  cr.course_module_id,
246  cm.name AS "course_module_name?",
247  NULLIF(TRIM(cm.uh_course_code), '') AS "uh_course_code?",
248  cmc.completion_date,
249  route.enrolment_confirmed_at AS "pressed_at!",
250  route.route AS "enrolment_route: CreditRegistrationEnrolmentRoute",
251  emails.count AS "linking_emails_on_code_since_press!",
252  emails.last_at AS "last_linking_email_on_code_at?"
253FROM credit_registrations cr
254  JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
255  JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
256  AND cmc.deleted_at IS NULL
257  JOIN courses c ON c.id = cr.course_id
258  AND c.deleted_at IS NULL
259  JOIN course_modules cm ON cm.id = cr.course_module_id
260  AND cm.deleted_at IS NULL
261  JOIN credit_registration_enrolment_routes route ON route.course_module_completion_id = cr.course_module_completion_id
262  AND route.deleted_at IS NULL
263  AND route.enrolment_confirmed_at IS NOT NULL
264  LEFT JOIN user_details ud ON ud.user_id = cr.user_id
265  CROSS JOIN LATERAL (
266    SELECT COUNT(*) AS count,
267      MAX(e.sent_at) AS last_at
268    FROM credit_registration_account_linking_emails e
269      JOIN course_modules code_module ON code_module.course_id = e.course_id
270      AND code_module.deleted_at IS NULL
271    WHERE TRIM(code_module.uh_course_code) = TRIM(cm.uh_course_code)
272      AND e.sent_at >= route.enrolment_confirmed_at
273      AND e.deleted_at IS NULL
274  ) emails
275WHERE cr.state = 'pending'
276  AND cr.superseded_by_id IS NULL
277  AND cr.deleted_at IS NULL
278  AND p.completion_eligible
279  AND NOT p.has_verified_student_number
280  AND ($1::timestamptz IS NULL OR cmc.completion_date >= $1)
281  AND ($2::uuid IS NULL OR cr.id = $2)
282ORDER BY route.enrolment_confirmed_at,
283  cr.id
284        "#,
285        since,
286        credit_registration_id,
287    )
288    .fetch_all(conn)
289    .await?;
290    Ok(res)
291}
292
293/// Live rows carrying an error code, split by whether the pipeline is still working on them.
294#[derive(Debug, Clone, PartialEq)]
295pub struct CreditRegistrationErrorCodeCount {
296    pub error_code: CreditRegistrationErrorCode,
297    /// Every live row carrying the code: what the Registrations list filtered by it shows.
298    pub live_count: i64,
299    pub in_flight_count: i64,
300    pub terminal_failure_count: i64,
301}
302
303/// The error-code breakdown the Overview shows.
304pub async fn count_by_error_code(
305    conn: &mut PgConnection,
306) -> ModelResult<Vec<CreditRegistrationErrorCodeCount>> {
307    let rows = sqlx::query!(
308        r#"
309SELECT error_code AS "error_code!",
310  COUNT(*) AS "live_count!",
311  COUNT(*) FILTER (WHERE terminal_at IS NULL) AS "in_flight_count!",
312  COUNT(*) FILTER (
313    WHERE state = ANY($1::credit_registration_state [])
314  ) AS "terminal_failure_count!"
315FROM credit_registrations
316WHERE error_code IS NOT NULL
317  AND superseded_by_id IS NULL
318  AND deleted_at IS NULL
319GROUP BY error_code
320ORDER BY COUNT(*) DESC
321        "#,
322        &CreditRegistrationState::HARD_FAILURE_STATES as &[CreditRegistrationState],
323    )
324    .fetch_all(conn)
325    .await?;
326    Ok(rows
327        .into_iter()
328        .map(|row| CreditRegistrationErrorCodeCount {
329            error_code: row.error_code,
330            live_count: row.live_count,
331            in_flight_count: row.in_flight_count,
332            terminal_failure_count: row.terminal_failure_count,
333        })
334        .collect())
335}
336
337/// The row that has been waiting longest for the pipeline to do something with it.
338#[derive(Debug, Clone, PartialEq)]
339pub struct OldestNonTerminalRegistration {
340    pub id: Uuid,
341    pub state: CreditRegistrationState,
342    pub state_changed_at: DateTime<Utc>,
343}
344
345pub async fn get_oldest_non_terminal(
346    conn: &mut PgConnection,
347) -> ModelResult<Option<OldestNonTerminalRegistration>> {
348    let row = sqlx::query_as!(
349        OldestNonTerminalRegistration,
350        r#"
351SELECT id,
352  state,
353  state_changed_at
354FROM credit_registrations
355WHERE terminal_at IS NULL
356  AND superseded_by_id IS NULL
357  AND deleted_at IS NULL
358ORDER BY state_changed_at
359LIMIT 1
360        "#,
361    )
362    .fetch_optional(conn)
363    .await?;
364    Ok(row)
365}
366
367/// One day of terminal outcomes, for the throughput series.
368#[derive(Debug, Clone, PartialEq)]
369pub struct CreditRegistrationThroughputDay {
370    pub day: DateTime<Utc>,
371    pub registered_count: i64,
372    pub other_success_count: i64,
373    pub failed_count: i64,
374}
375
376/// Daily terminal outcomes over the window. Withdrawn rows are in no column: they are neither a
377/// success nor a failure.
378pub async fn get_throughput_by_day(
379    conn: &mut PgConnection,
380    since: DateTime<Utc>,
381) -> ModelResult<Vec<CreditRegistrationThroughputDay>> {
382    let rows = sqlx::query_as!(
383        CreditRegistrationThroughputDay,
384        r#"
385SELECT DATE_TRUNC('day', terminal_at) AS "day!",
386  COUNT(*) FILTER (WHERE state = 'registered') AS "registered_count!",
387  COUNT(*) FILTER (
388    WHERE state = ANY($2::credit_registration_state [])
389  ) AS "other_success_count!",
390  COUNT(*) FILTER (WHERE state = 'failed_permanent') AS "failed_count!"
391FROM credit_registrations
392WHERE terminal_at >= $1
393  AND superseded_by_id IS NULL
394  AND deleted_at IS NULL
395GROUP BY 1
396ORDER BY 1
397        "#,
398        since,
399        &CreditRegistrationState::OTHER_SUCCESS_STATES as &[CreditRegistrationState],
400    )
401    .fetch_all(conn)
402    .await?;
403    Ok(rows)
404}
405
406/// What the pipeline finished in a window.
407#[derive(Debug, Clone, PartialEq, Default)]
408pub struct TerminalOutcomeTotals {
409    /// `registered`, `duplicate` and `not_improved`.
410    pub success_count: i64,
411    /// The subset we put in the registry ourselves.
412    pub registered_count: i64,
413    pub failed_permanent_count: i64,
414    pub cancelled_count: i64,
415    /// The denominator of the success rate.
416    pub total_count: i64,
417}
418
419pub async fn count_terminal_outcomes_since(
420    conn: &mut PgConnection,
421    since: DateTime<Utc>,
422) -> ModelResult<TerminalOutcomeTotals> {
423    let res = sqlx::query_as!(
424        TerminalOutcomeTotals,
425        r#"
426SELECT COUNT(*) FILTER (
427    WHERE state = ANY($2::credit_registration_state [])
428  ) AS "success_count!",
429  COUNT(*) FILTER (WHERE state = 'registered') AS "registered_count!",
430  COUNT(*) FILTER (WHERE state = 'failed_permanent') AS "failed_permanent_count!",
431  COUNT(*) FILTER (WHERE state = 'cancelled') AS "cancelled_count!",
432  COUNT(*) AS "total_count!"
433FROM credit_registrations
434WHERE terminal_at >= $1
435  AND superseded_by_id IS NULL
436  AND deleted_at IS NULL
437        "#,
438        since,
439        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
440    )
441    .fetch_one(conn)
442    .await?;
443    Ok(res)
444}
445
446/// Live rows that entered one state within the window. `misregistered` is not terminal, so
447/// `terminal_at` cannot answer this.
448pub async fn count_entered_state_since(
449    conn: &mut PgConnection,
450    state: CreditRegistrationState,
451    since: DateTime<Utc>,
452) -> ModelResult<i64> {
453    let count = sqlx::query_scalar!(
454        r#"
455SELECT COUNT(*) AS "count!"
456FROM credit_registrations
457WHERE state = $1
458  AND state_changed_at >= $2
459  AND superseded_by_id IS NULL
460  AND deleted_at IS NULL
461        "#,
462        state as CreditRegistrationState,
463        since,
464    )
465    .fetch_one(conn)
466    .await?;
467    Ok(count)
468}
469
470/// How long registration took, in seconds, for rows that reached `registered` in a window.
471#[derive(Debug, Clone, PartialEq)]
472pub struct RegistrationLatency {
473    pub registered_count: i64,
474    /// `terminal_at - created_at`: the student's wait, most of which is theirs to end.
475    pub p50_end_to_end_secs: Option<i64>,
476    pub p95_end_to_end_secs: Option<i64>,
477    /// `registered_at - submitted_at`: how long the study registry took, which is the number to
478    /// quote at them.
479    pub p50_confirmation_secs: Option<i64>,
480    pub p95_confirmation_secs: Option<i64>,
481}
482
483pub async fn get_registration_latency_between(
484    conn: &mut PgConnection,
485    from: DateTime<Utc>,
486    to: DateTime<Utc>,
487) -> ModelResult<RegistrationLatency> {
488    let res = sqlx::query_as!(
489        RegistrationLatency,
490        r#"
491SELECT COUNT(*) AS "registered_count!",
492  CEIL(
493    EXTRACT(
494      EPOCH
495      FROM PERCENTILE_DISC(0.5) WITHIN GROUP (
496          ORDER BY terminal_at - created_at
497        )
498    )
499  )::bigint AS "p50_end_to_end_secs",
500  CEIL(
501    EXTRACT(
502      EPOCH
503      FROM PERCENTILE_DISC(0.95) WITHIN GROUP (
504          ORDER BY terminal_at - created_at
505        )
506    )
507  )::bigint AS "p95_end_to_end_secs",
508  CEIL(
509    EXTRACT(
510      EPOCH
511      FROM PERCENTILE_DISC(0.5) WITHIN GROUP (
512          ORDER BY registered_at - submitted_at
513        )
514    )
515  )::bigint AS "p50_confirmation_secs",
516  CEIL(
517    EXTRACT(
518      EPOCH
519      FROM PERCENTILE_DISC(0.95) WITHIN GROUP (
520          ORDER BY registered_at - submitted_at
521        )
522    )
523  )::bigint AS "p95_confirmation_secs"
524FROM credit_registrations
525WHERE state = 'registered'
526  AND terminal_at >= $1
527  AND terminal_at < $2
528  AND superseded_by_id IS NULL
529  AND deleted_at IS NULL
530        "#,
531        from,
532        to,
533    )
534    .fetch_one(conn)
535    .await?;
536    Ok(res)
537}
538
539/// Live volumes per course module, for the Courses tab's one row per module.
540#[derive(Debug, Clone, PartialEq)]
541pub struct ModuleRegistrationTotals {
542    pub course_module_id: Uuid,
543    pub total_count: i64,
544    pub success_count: i64,
545    /// Of `success_count`, the ones we registered.
546    pub registered_count: i64,
547    /// Of `success_count`, the ones Sisu already held: duplicate or not improved.
548    pub already_in_sisu_count: i64,
549    pub in_flight_count: i64,
550    pub failed_count: i64,
551    pub last_registered_at: Option<DateTime<Utc>>,
552    /// The code most of the module's failing rows carry, which is usually the whole diagnosis.
553    pub top_error_code: Option<CreditRegistrationErrorCode>,
554}
555
556/// `failed_count` is `failed_permanent` and `misregistered` only, so the columns do not add up to
557/// the total by design.
558pub async fn count_by_module(
559    conn: &mut PgConnection,
560) -> ModelResult<Vec<ModuleRegistrationTotals>> {
561    let res = sqlx::query_as!(
562        ModuleRegistrationTotals,
563        r#"
564SELECT cr.course_module_id,
565  COUNT(*) AS "total_count!",
566  COUNT(*) FILTER (
567    WHERE cr.state = ANY($1::credit_registration_state [])
568  ) AS "success_count!",
569  COUNT(*) FILTER (WHERE cr.state = 'registered') AS "registered_count!",
570  COUNT(*) FILTER (
571    WHERE cr.state = ANY($3::credit_registration_state [])
572  ) AS "already_in_sisu_count!",
573  COUNT(*) FILTER (WHERE cr.terminal_at IS NULL) AS "in_flight_count!",
574  COUNT(*) FILTER (
575    WHERE cr.state = ANY($2::credit_registration_state [])
576  ) AS "failed_count!",
577  MAX(cr.registered_at) AS "last_registered_at",
578  (
579    SELECT inner_cr.error_code
580    FROM credit_registrations inner_cr
581    WHERE inner_cr.course_module_id = cr.course_module_id
582      AND inner_cr.error_code IS NOT NULL
583      AND inner_cr.superseded_by_id IS NULL
584      AND inner_cr.deleted_at IS NULL
585    GROUP BY inner_cr.error_code
586    ORDER BY COUNT(*) DESC,
587      inner_cr.error_code
588    LIMIT 1
589  ) AS "top_error_code?: CreditRegistrationErrorCode"
590FROM credit_registrations cr
591WHERE superseded_by_id IS NULL
592  AND deleted_at IS NULL
593GROUP BY cr.course_module_id
594        "#,
595        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
596        &CreditRegistrationState::HARD_FAILURE_STATES as &[CreditRegistrationState],
597        &CreditRegistrationState::OTHER_SUCCESS_STATES as &[CreditRegistrationState],
598    )
599    .fetch_all(conn)
600    .await?;
601    Ok(res)
602}
603
604/// How long a row may sit in one state before it counts as stuck. Seconds, per state.
605///
606/// Also the wire payload the health endpoint reports as thresholds: field names are the
607/// `stuck_*_secs` keys the frontend reads, so do not rename without updating it.
608#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, ToSchema)]
609pub struct StuckThresholds {
610    pub stuck_ready_to_submit_secs: i64,
611    pub stuck_submitting_secs: i64,
612    pub stuck_awaiting_verification_secs: i64,
613    pub stuck_failed_retryable_secs: i64,
614}
615
616/// The longest `submissionPending` asks verify to wait before polling again.
617const SUOTAR_PENDING_WAIT: TimeDelta = TimeDelta::days(1);
618
619impl StuckThresholds {
620    /// The thresholds every detector and alert uses.
621    pub const CURRENT: Self = Self {
622        stuck_ready_to_submit_secs: 2 * 60 * 60,
623        stuck_submitting_secs: 90 * 60,
624        stuck_awaiting_verification_secs: SUOTAR_PENDING_WAIT.num_seconds() + 2 * 60 * 60,
625        stuck_failed_retryable_secs: 3 * 24 * 60 * 60,
626    };
627
628    /// The threshold for `state`, or `None` for a state that waits on a student or a person.
629    pub fn seconds_for(&self, state: CreditRegistrationState) -> Option<i64> {
630        let (states, seconds) = self.state_seconds_arrays();
631        states
632            .iter()
633            .position(|candidate| *candidate == state)
634            .map(|index| seconds[index] as i64)
635    }
636
637    /// The four states this covers, paired with their threshold in seconds, for a query to `UNNEST`
638    /// into a state -> threshold lookup.
639    pub(super) fn state_seconds_arrays(&self) -> ([CreditRegistrationState; 4], [f64; 4]) {
640        (
641            [
642                CreditRegistrationState::ReadyToSubmit,
643                CreditRegistrationState::Submitting,
644                CreditRegistrationState::AwaitingVerification,
645                CreditRegistrationState::FailedRetryable,
646            ],
647            [
648                self.stuck_ready_to_submit_secs as f64,
649                self.stuck_submitting_secs as f64,
650                self.stuck_awaiting_verification_secs as f64,
651                self.stuck_failed_retryable_secs as f64,
652            ],
653        )
654    }
655}
656
657const _: () = assert!(
658    StuckThresholds::CURRENT.stuck_failed_retryable_secs
659        < crate::library::credit_registration::backoff::SUBMIT_MAX_RETRY_AGE.num_seconds(),
660    "a row must be considered stuck before backoff gives up retrying it"
661);
662const _: () = assert!(
663    StuckThresholds::CURRENT.stuck_submitting_secs
664        > crate::library::credit_registration::backoff::SUBMITTING_RECOVERY_GRACE.num_seconds(),
665    "the stuck threshold must outlast the grace period that lets a submit recover on its own"
666);
667
668#[derive(Debug, Clone, PartialEq)]
669pub struct StuckRegistrationCount {
670    pub state: CreditRegistrationState,
671    pub count: i64,
672    /// Over three times the threshold, which is what makes the alert critical.
673    pub severely_stuck_count: i64,
674    pub oldest_state_changed_at: Option<DateTime<Utc>>,
675}
676
677/// Rows past their state's threshold, per state, for the stuck-registrations alert. Counted
678/// whatever their Needs attention standing: a dismissal or another reason does not unstick a row.
679pub fn count_stuck_in_state(
680    rows: &[AttentionRegistration],
681    thresholds: &StuckThresholds,
682    now: DateTime<Utc>,
683) -> Vec<StuckRegistrationCount> {
684    let mut by_state: HashMap<CreditRegistrationState, StuckRegistrationCount> = HashMap::new();
685    for row in rows
686        .iter()
687        .filter(|row| row.reasons.contains(&AttentionReason::StuckInState))
688    {
689        let severe = thresholds
690            .seconds_for(row.state)
691            .is_some_and(|secs| (now - row.state_changed_at).num_seconds() > secs * 3);
692        let count = by_state.entry(row.state).or_insert(StuckRegistrationCount {
693            state: row.state,
694            count: 0,
695            severely_stuck_count: 0,
696            oldest_state_changed_at: None,
697        });
698        count.count += 1;
699        count.severely_stuck_count += i64::from(severe);
700        count.oldest_state_changed_at = Some(
701            count
702                .oldest_state_changed_at
703                .map_or(row.state_changed_at, |oldest| {
704                    oldest.min(row.state_changed_at)
705                }),
706        );
707    }
708    let mut counts: Vec<StuckRegistrationCount> = by_state.into_values().collect();
709    counts.sort_by_key(|count| {
710        CreditRegistrationState::ALL
711            .iter()
712            .position(|state| *state == count.state)
713    });
714    counts
715}