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::state::{CreditRegistrationErrorCode, CreditRegistrationState};
4use crate::library::credit_registration::PendingReasonCounts;
5use crate::prelude::*;
6use utoipa::ToSchema;
7
8/// Live rows per state, for the dashboard funnel. Superseded attempts are excluded, as in the
9/// per-course sibling, or a course that regrades counts every student twice.
10pub async fn count_by_state(
11    conn: &mut PgConnection,
12) -> ModelResult<Vec<(CreditRegistrationState, i64)>> {
13    let rows = sqlx::query!(
14        r#"
15SELECT state,
16  COUNT(*) AS "count!"
17FROM credit_registrations
18WHERE superseded_by_id IS NULL
19  AND deleted_at IS NULL
20GROUP BY state
21        "#,
22    )
23    .fetch_all(conn)
24    .await?;
25    Ok(rows.into_iter().map(|r| (r.state, r.count)).collect())
26}
27
28/// Live rows parked in `no_usable_enrolment` that an unscoped resolve-enrolments claim would take
29/// for an enrolment check now; the rest wait for their schedule, their module, or a lookup already
30/// out. Leaves out the claim's one-row-per-student-and-module hold, which only defers a row.
31///
32/// Shares its filters with the unscoped `claim` and with the released-check test of
33/// [`pull_forward_batched_checks`](crate::library::credit_registration::enrolment_checks::pull_forward_batched_checks);
34/// change all three together, or the queue depth counts rows no claim takes.
35pub async fn count_due_enrolment_checks(conn: &mut PgConnection) -> ModelResult<i64> {
36    let count = sqlx::query_scalar!(
37        r#"
38SELECT COUNT(*) AS "count!"
39FROM credit_registrations cr
40  JOIN credit_registration_active_course_modules acm ON acm.course_module_id = cr.course_module_id
41  JOIN course_module_completions cmc ON cmc.id = cr.course_module_completion_id
42WHERE cr.state = 'no_usable_enrolment'
43  AND cr.next_attempt_at <= now()
44  AND cr.superseded_by_id IS NULL
45  AND cr.deleted_at IS NULL
46  AND (
47    cmc.register_credits_via_suotar
48    OR cr.submitted_at IS NOT NULL
49  )
50  AND (
51    cr.enrolment_check_claimed_until IS NULL
52    OR cr.enrolment_check_claimed_until <= now()
53  )
54  AND NOT EXISTS (
55    SELECT 1
56    FROM credit_registration_test_exclusive_holds h
57    WHERE h.user_id = cr.user_id
58      AND (
59        h.course_id IS NULL
60        OR h.course_id = cr.course_id
61      )
62      AND h.held_until > now()
63  )
64        "#,
65    )
66    .fetch_one(conn)
67    .await?;
68    Ok(count)
69}
70
71/// Live `pending` rows per blocker. Derived from `credit_registration_preconditions`, so it cannot
72/// disagree with what the recompute is waiting for or with what the student is shown.
73pub async fn count_pending_by_reason(conn: &mut PgConnection) -> ModelResult<PendingReasonCounts> {
74    let row = sqlx::query!(
75        r#"
76SELECT COUNT(*) FILTER (
77    WHERE NOT p.completion_eligible
78  ) AS "completion_count!",
79  COUNT(*) FILTER (
80    WHERE p.completion_eligible
81      AND NOT p.has_verified_student_number
82  ) AS "student_number_count!"
83FROM credit_registrations cr
84  JOIN credit_registration_preconditions p ON p.credit_registration_id = cr.id
85WHERE cr.state = 'pending'
86  AND cr.superseded_by_id IS NULL
87  AND cr.deleted_at IS NULL
88        "#,
89    )
90    .fetch_one(conn)
91    .await?;
92    Ok(PendingReasonCounts {
93        completion_count: row.completion_count,
94        student_number_count: row.student_number_count,
95    })
96}
97
98/// Live rows carrying an error code, split by whether the pipeline is still working on them.
99#[derive(Debug, Clone, PartialEq)]
100pub struct CreditRegistrationErrorCodeCount {
101    pub error_code: CreditRegistrationErrorCode,
102    pub in_flight_count: i64,
103    pub terminal_failure_count: i64,
104}
105
106/// The error-code breakdown the Overview shows.
107pub async fn count_by_error_code(
108    conn: &mut PgConnection,
109) -> ModelResult<Vec<CreditRegistrationErrorCodeCount>> {
110    let rows = sqlx::query!(
111        r#"
112SELECT error_code AS "error_code!",
113  COUNT(*) FILTER (WHERE terminal_at IS NULL) AS "in_flight_count!",
114  COUNT(*) FILTER (
115    WHERE state = ANY($1::credit_registration_state [])
116  ) AS "terminal_failure_count!"
117FROM credit_registrations
118WHERE error_code IS NOT NULL
119  AND superseded_by_id IS NULL
120  AND deleted_at IS NULL
121GROUP BY error_code
122ORDER BY COUNT(*) DESC
123        "#,
124        &CreditRegistrationState::HARD_FAILURE_STATES as &[CreditRegistrationState],
125    )
126    .fetch_all(conn)
127    .await?;
128    Ok(rows
129        .into_iter()
130        .map(|row| CreditRegistrationErrorCodeCount {
131            error_code: row.error_code,
132            in_flight_count: row.in_flight_count,
133            terminal_failure_count: row.terminal_failure_count,
134        })
135        .collect())
136}
137
138/// The row that has been waiting longest for the pipeline to do something with it.
139#[derive(Debug, Clone, PartialEq)]
140pub struct OldestNonTerminalRegistration {
141    pub id: Uuid,
142    pub state: CreditRegistrationState,
143    pub state_entered_at: DateTime<Utc>,
144}
145
146pub async fn get_oldest_non_terminal(
147    conn: &mut PgConnection,
148) -> ModelResult<Option<OldestNonTerminalRegistration>> {
149    let row = sqlx::query_as!(
150        OldestNonTerminalRegistration,
151        r#"
152SELECT id,
153  state,
154  state_entered_at
155FROM credit_registrations
156WHERE terminal_at IS NULL
157  AND superseded_by_id IS NULL
158  AND deleted_at IS NULL
159ORDER BY state_entered_at
160LIMIT 1
161        "#,
162    )
163    .fetch_optional(conn)
164    .await?;
165    Ok(row)
166}
167
168/// One day of terminal outcomes, for the throughput series.
169#[derive(Debug, Clone, PartialEq)]
170pub struct CreditRegistrationThroughputDay {
171    pub day: DateTime<Utc>,
172    pub registered_count: i64,
173    pub other_success_count: i64,
174    pub failed_count: i64,
175}
176
177/// Daily terminal outcomes over the window. Withdrawn rows are in no column: they are neither a
178/// success nor a failure.
179pub async fn get_throughput_by_day(
180    conn: &mut PgConnection,
181    since: DateTime<Utc>,
182) -> ModelResult<Vec<CreditRegistrationThroughputDay>> {
183    let rows = sqlx::query_as!(
184        CreditRegistrationThroughputDay,
185        r#"
186SELECT DATE_TRUNC('day', terminal_at) AS "day!",
187  COUNT(*) FILTER (WHERE state = 'registered') AS "registered_count!",
188  COUNT(*) FILTER (
189    WHERE state = ANY($2::credit_registration_state [])
190  ) AS "other_success_count!",
191  COUNT(*) FILTER (WHERE state = 'failed_permanent') AS "failed_count!"
192FROM credit_registrations
193WHERE terminal_at >= $1
194  AND superseded_by_id IS NULL
195  AND deleted_at IS NULL
196GROUP BY 1
197ORDER BY 1
198        "#,
199        since,
200        &CreditRegistrationState::OTHER_SUCCESS_STATES as &[CreditRegistrationState],
201    )
202    .fetch_all(conn)
203    .await?;
204    Ok(rows)
205}
206
207/// What the pipeline finished in a window.
208#[derive(Debug, Clone, PartialEq, Default)]
209pub struct TerminalOutcomeTotals {
210    /// `registered`, `duplicate` and `not_improved`.
211    pub success_count: i64,
212    /// The subset we put in the registry ourselves.
213    pub registered_count: i64,
214    pub failed_permanent_count: i64,
215    pub cancelled_count: i64,
216    /// The denominator of the success rate.
217    pub total_count: i64,
218}
219
220pub async fn count_terminal_outcomes_since(
221    conn: &mut PgConnection,
222    since: DateTime<Utc>,
223) -> ModelResult<TerminalOutcomeTotals> {
224    let res = sqlx::query_as!(
225        TerminalOutcomeTotals,
226        r#"
227SELECT COUNT(*) FILTER (
228    WHERE state = ANY($2::credit_registration_state [])
229  ) AS "success_count!",
230  COUNT(*) FILTER (WHERE state = 'registered') AS "registered_count!",
231  COUNT(*) FILTER (WHERE state = 'failed_permanent') AS "failed_permanent_count!",
232  COUNT(*) FILTER (WHERE state = 'cancelled') AS "cancelled_count!",
233  COUNT(*) AS "total_count!"
234FROM credit_registrations
235WHERE terminal_at >= $1
236  AND superseded_by_id IS NULL
237  AND deleted_at IS NULL
238        "#,
239        since,
240        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
241    )
242    .fetch_one(conn)
243    .await?;
244    Ok(res)
245}
246
247/// Live rows that entered one state within the window. `misregistered` is not terminal, so
248/// `terminal_at` cannot answer this.
249pub async fn count_entered_state_since(
250    conn: &mut PgConnection,
251    state: CreditRegistrationState,
252    since: DateTime<Utc>,
253) -> ModelResult<i64> {
254    let count = sqlx::query_scalar!(
255        r#"
256SELECT COUNT(*) AS "count!"
257FROM credit_registrations
258WHERE state = $1
259  AND state_entered_at >= $2
260  AND superseded_by_id IS NULL
261  AND deleted_at IS NULL
262        "#,
263        state as CreditRegistrationState,
264        since,
265    )
266    .fetch_one(conn)
267    .await?;
268    Ok(count)
269}
270
271/// How long registration took, in seconds, for rows that reached `registered` in a window.
272#[derive(Debug, Clone, PartialEq)]
273pub struct RegistrationLatency {
274    pub registered_count: i64,
275    /// `terminal_at - created_at`: the student's wait, most of which is theirs to end.
276    pub p50_end_to_end_secs: Option<i64>,
277    pub p95_end_to_end_secs: Option<i64>,
278    /// `registered_at - submitted_at`: how long the study registry took, which is the number to
279    /// quote at them.
280    pub p50_confirmation_secs: Option<i64>,
281    pub p95_confirmation_secs: Option<i64>,
282}
283
284pub async fn get_registration_latency_between(
285    conn: &mut PgConnection,
286    from: DateTime<Utc>,
287    to: DateTime<Utc>,
288) -> ModelResult<RegistrationLatency> {
289    let res = sqlx::query_as!(
290        RegistrationLatency,
291        r#"
292SELECT COUNT(*) AS "registered_count!",
293  CEIL(
294    EXTRACT(
295      EPOCH
296      FROM PERCENTILE_DISC(0.5) WITHIN GROUP (
297          ORDER BY terminal_at - created_at
298        )
299    )
300  )::bigint AS "p50_end_to_end_secs",
301  CEIL(
302    EXTRACT(
303      EPOCH
304      FROM PERCENTILE_DISC(0.95) WITHIN GROUP (
305          ORDER BY terminal_at - created_at
306        )
307    )
308  )::bigint AS "p95_end_to_end_secs",
309  CEIL(
310    EXTRACT(
311      EPOCH
312      FROM PERCENTILE_DISC(0.5) WITHIN GROUP (
313          ORDER BY registered_at - submitted_at
314        )
315    )
316  )::bigint AS "p50_confirmation_secs",
317  CEIL(
318    EXTRACT(
319      EPOCH
320      FROM PERCENTILE_DISC(0.95) WITHIN GROUP (
321          ORDER BY registered_at - submitted_at
322        )
323    )
324  )::bigint AS "p95_confirmation_secs"
325FROM credit_registrations
326WHERE state = 'registered'
327  AND terminal_at >= $1
328  AND terminal_at < $2
329  AND superseded_by_id IS NULL
330  AND deleted_at IS NULL
331        "#,
332        from,
333        to,
334    )
335    .fetch_one(conn)
336    .await?;
337    Ok(res)
338}
339
340/// Live volumes per course module, for the Courses tab's one row per module.
341#[derive(Debug, Clone, PartialEq)]
342pub struct ModuleRegistrationTotals {
343    pub course_module_id: Uuid,
344    pub total_count: i64,
345    pub success_count: i64,
346    pub in_flight_count: i64,
347    pub failed_count: i64,
348    pub needs_admin_attention_count: i64,
349    pub last_registered_at: Option<DateTime<Utc>>,
350    /// The code most of the module's failing rows carry, which is usually the whole diagnosis.
351    pub top_error_code: Option<CreditRegistrationErrorCode>,
352}
353
354/// `failed_count` is `failed_permanent` and `misregistered` only, so the columns do not add up to
355/// the total by design.
356pub async fn count_by_module(
357    conn: &mut PgConnection,
358) -> ModelResult<Vec<ModuleRegistrationTotals>> {
359    let res = sqlx::query_as!(
360        ModuleRegistrationTotals,
361        r#"
362SELECT cr.course_module_id,
363  COUNT(*) AS "total_count!",
364  COUNT(*) FILTER (
365    WHERE cr.state = ANY($1::credit_registration_state [])
366  ) AS "success_count!",
367  COUNT(*) FILTER (WHERE cr.terminal_at IS NULL) AS "in_flight_count!",
368  COUNT(*) FILTER (
369    WHERE cr.state = ANY($2::credit_registration_state [])
370  ) AS "failed_count!",
371  COUNT(*) FILTER (WHERE cr.needs_admin_attention) AS "needs_admin_attention_count!",
372  MAX(cr.registered_at) AS "last_registered_at",
373  (
374    SELECT inner_cr.error_code
375    FROM credit_registrations inner_cr
376    WHERE inner_cr.course_module_id = cr.course_module_id
377      AND inner_cr.error_code IS NOT NULL
378      AND inner_cr.superseded_by_id IS NULL
379      AND inner_cr.deleted_at IS NULL
380    GROUP BY inner_cr.error_code
381    ORDER BY COUNT(*) DESC,
382      inner_cr.error_code
383    LIMIT 1
384  ) AS "top_error_code?: CreditRegistrationErrorCode"
385FROM credit_registrations cr
386WHERE superseded_by_id IS NULL
387  AND deleted_at IS NULL
388GROUP BY cr.course_module_id
389        "#,
390        &CreditRegistrationState::SUCCESS_STATES as &[CreditRegistrationState],
391        &CreditRegistrationState::HARD_FAILURE_STATES as &[CreditRegistrationState],
392    )
393    .fetch_all(conn)
394    .await?;
395    Ok(res)
396}
397
398/// How long a row may sit in one state before it counts as stuck. Seconds, per state.
399///
400/// Also the wire payload the health endpoint reports as thresholds: field names are the
401/// `stuck_*_secs` keys the frontend reads, so do not rename without updating it.
402#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, ToSchema)]
403pub struct StuckThresholds {
404    pub stuck_ready_to_submit_secs: i64,
405    pub stuck_submitting_secs: i64,
406    pub stuck_awaiting_verification_secs: i64,
407    pub stuck_failed_retryable_secs: i64,
408}
409
410impl StuckThresholds {
411    /// The four states this covers, paired with their threshold in seconds, in a fixed order both
412    /// `get_attention_items` and `count_stuck` bind the same way: `UNNEST`ed into a
413    /// state -> threshold lookup rather than each carrying its own copy of the `CASE`.
414    pub(super) fn state_seconds_arrays(&self) -> ([CreditRegistrationState; 4], [f64; 4]) {
415        (
416            [
417                CreditRegistrationState::ReadyToSubmit,
418                CreditRegistrationState::Submitting,
419                CreditRegistrationState::AwaitingVerification,
420                CreditRegistrationState::FailedRetryable,
421            ],
422            [
423                self.stuck_ready_to_submit_secs as f64,
424                self.stuck_submitting_secs as f64,
425                self.stuck_awaiting_verification_secs as f64,
426                self.stuck_failed_retryable_secs as f64,
427            ],
428        )
429    }
430}
431
432#[derive(Debug, Clone, PartialEq)]
433pub struct StuckRegistrationCount {
434    pub state: CreditRegistrationState,
435    pub count: i64,
436    /// Over three times the threshold, which is what makes the alert critical.
437    pub severely_stuck_count: i64,
438    pub oldest_state_entered_at: Option<DateTime<Utc>>,
439}
440
441/// Rows the pipeline should have moved by now, per state. Only the four states with a threshold
442/// count: the rest wait on a student or a human, where an alert would fire on normal operation.
443pub async fn count_stuck(
444    conn: &mut PgConnection,
445    thresholds: &StuckThresholds,
446) -> ModelResult<Vec<StuckRegistrationCount>> {
447    let (state_thresholds, threshold_secs) = thresholds.state_seconds_arrays();
448    let rows = sqlx::query_as!(
449        StuckRegistrationCount,
450        r#"
451SELECT cr.state AS "state!",
452  COUNT(*) AS "count!",
453  COUNT(*) FILTER (
454    WHERE now() - cr.state_entered_at > MAKE_INTERVAL(secs => t.threshold_secs * 3)
455  ) AS "severely_stuck_count!",
456  MIN(cr.state_entered_at) AS "oldest_state_entered_at"
457FROM credit_registrations cr
458  JOIN UNNEST($1::credit_registration_state [], $2::double precision []) AS t(state, threshold_secs) ON t.state = cr.state
459WHERE cr.terminal_at IS NULL
460  AND cr.superseded_by_id IS NULL
461  AND cr.deleted_at IS NULL
462  AND now() - cr.state_entered_at > MAKE_INTERVAL(secs => t.threshold_secs)
463GROUP BY cr.state
464        "#,
465        &state_thresholds as &[CreditRegistrationState],
466        &threshold_secs as &[f64],
467    )
468    .fetch_all(conn)
469    .await?;
470    Ok(rows)
471}