Skip to main content

headless_lms_models/credit_registrations/
attention.rs

1//! The Errors tab's attention queue: the rows that want a human, and why.
2
3use super::metrics::StuckThresholds;
4use super::state::{CreditRegistrationErrorCode, CreditRegistrationState, ResubmissionFacts};
5use crate::prelude::*;
6use utoipa::ToSchema;
7
8/// Which detector picked a row for the attention queue. A row can carry several.
9///
10/// Not `needs_admin_attention`: that flag is one of the conditions that puts a row in the queue,
11/// but it says nothing about why, so it is reported per row rather than as a reason of its own.
12#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Hash, ToSchema)]
13#[serde(rename_all = "snake_case")]
14// The API has always called it this; the short name is for Rust callers, who have the module.
15#[schema(as = CreditRegistrationAttentionReason)]
16pub enum AttentionReason {
17    /// Past its state's threshold with the pipeline still owning it.
18    StuckInState,
19    PermanentError,
20    RetryWindowExpired,
21    Misregistered,
22    TooManyAttempts,
23    /// `submission_uncertain`: never retried automatically, and never in bulk.
24    OutcomeUncertain,
25}
26
27impl AttentionReason {
28    pub const ALL: [Self; 6] = [
29        Self::StuckInState,
30        Self::PermanentError,
31        Self::RetryWindowExpired,
32        Self::Misregistered,
33        Self::TooManyAttempts,
34        Self::OutcomeUncertain,
35    ];
36
37    /// Bound into the query as a `text` array element; must match the serde names a caller sends
38    /// to narrow the queue.
39    fn as_str(self) -> &'static str {
40        match self {
41            Self::StuckInState => "stuck_in_state",
42            Self::PermanentError => "permanent_error",
43            Self::RetryWindowExpired => "retry_window_expired",
44            Self::Misregistered => "misregistered",
45            Self::TooManyAttempts => "too_many_attempts",
46            Self::OutcomeUncertain => "outcome_uncertain",
47        }
48    }
49}
50
51/// How the attention queue orders a page. The default puts the row that has been waiting longest
52/// first, which is the order an operator works the queue in.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
54pub enum AttentionSort {
55    #[default]
56    TimeInState,
57    NextAttempt,
58    Course,
59}
60
61impl AttentionSort {
62    /// Bound into the query's `ORDER BY` as a `text` parameter.
63    fn as_str(self) -> &'static str {
64        match self {
65            Self::TimeInState => "time_in_state",
66            Self::NextAttempt => "next_attempt",
67            Self::Course => "course",
68        }
69    }
70}
71
72/// One row the Errors tab wants a human to look at, with the detectors that picked it.
73///
74/// The `*_count` fields are totals over the whole queue this call selected, not over the page, so a
75/// caller reads them off the first row instead of running a second aggregate.
76#[derive(Debug, Clone)]
77pub struct AttentionRegistration {
78    pub id: Uuid,
79    pub user_id: Uuid,
80    pub first_name: Option<String>,
81    pub last_name: Option<String>,
82    pub email: Option<String>,
83    pub course_id: Uuid,
84    pub course_name: String,
85    pub course_module_id: Uuid,
86    pub course_module_name: Option<String>,
87    pub state: CreditRegistrationState,
88    pub state_entered_at: DateTime<Utc>,
89    pub error_code: Option<CreditRegistrationErrorCode>,
90    pub attempt_count: i32,
91    /// The pipeline's cached "a human should look at this". Membership in the queue does not depend
92    /// on it alone, and it is never reported as a reason.
93    pub needs_admin_attention: bool,
94    pub next_attempt_at: DateTime<Utc>,
95    pub submitted_at: Option<DateTime<Utc>>,
96    pub resubmit_not_before: Option<DateTime<Utc>>,
97    pub student_number: Option<DbSecret>,
98    pub stuck_in_state: bool,
99    pub permanent_error: bool,
100    pub retry_window_expired: bool,
101    pub misregistered: bool,
102    pub too_many_attempts: bool,
103    pub outcome_uncertain: bool,
104    pub total_count: i64,
105    pub stuck_in_state_count: i64,
106    pub permanent_error_count: i64,
107    pub retry_window_expired_count: i64,
108    pub misregistered_count: i64,
109    pub too_many_attempts_count: i64,
110    pub outcome_uncertain_count: i64,
111    /// Rows the flag alone put in the queue. Reachable by no reason, so a caller grouping by reason
112    /// has to account for them separately or leave part of its own queue unreachable.
113    pub flagged_without_reason_count: i64,
114}
115
116impl AttentionRegistration {
117    /// What decides whether a human may move this row; see [`ResubmissionFacts`]. The queue never
118    /// holds a superseded row.
119    pub fn resubmission_facts(&self) -> ResubmissionFacts {
120        ResubmissionFacts {
121            state: self.state,
122            is_superseded: false,
123            error_code: self.error_code,
124            resubmit_not_before: self.resubmit_not_before,
125            submitted_at: self.submitted_at,
126        }
127    }
128
129    /// The detectors that picked this row.
130    pub fn reasons(&self) -> Vec<AttentionReason> {
131        AttentionReason::ALL
132            .into_iter()
133            .filter(|reason| match reason {
134                AttentionReason::StuckInState => self.stuck_in_state,
135                AttentionReason::PermanentError => self.permanent_error,
136                AttentionReason::RetryWindowExpired => self.retry_window_expired,
137                AttentionReason::Misregistered => self.misregistered,
138                AttentionReason::TooManyAttempts => self.too_many_attempts,
139                AttentionReason::OutcomeUncertain => self.outcome_uncertain,
140            })
141            .collect()
142    }
143
144    /// How many rows of the whole queue each detector picked, in [`AttentionReason::ALL`] order.
145    pub fn counts_by_reason(&self) -> Vec<(AttentionReason, i64)> {
146        vec![
147            (AttentionReason::StuckInState, self.stuck_in_state_count),
148            (AttentionReason::PermanentError, self.permanent_error_count),
149            (
150                AttentionReason::RetryWindowExpired,
151                self.retry_window_expired_count,
152            ),
153            (AttentionReason::Misregistered, self.misregistered_count),
154            (
155                AttentionReason::TooManyAttempts,
156                self.too_many_attempts_count,
157            ),
158            (
159                AttentionReason::OutcomeUncertain,
160                self.outcome_uncertain_count,
161            ),
162        ]
163    }
164}
165
166/// Which rows of the attention queue a call wants, and which slice of them.
167///
168/// `reasons` and `only_without_reason` narrow the whole selection, totals included, so a caller
169/// after facet counts over the unnarrowed queue leaves both at their defaults.
170#[derive(Debug, Clone, Copy)]
171pub struct AttentionSelection<'a> {
172    pub reasons: &'a [AttentionReason],
173    pub only_without_reason: bool,
174    pub sort: AttentionSort,
175    pub limit: i64,
176    pub offset: i64,
177}
178
179impl Default for AttentionSelection<'_> {
180    fn default() -> Self {
181        Self {
182            reasons: &[],
183            only_without_reason: false,
184            sort: AttentionSort::TimeInState,
185            limit: 1,
186            offset: 0,
187        }
188    }
189}
190
191/// A page of the attention queue, with the totals for everything the call selected on every row.
192///
193/// The one query behind the Errors tab's pages and [`count_needing_attention`], so the queue an
194/// operator works through and the counts the Overview tile and tab badge show cannot disagree.
195///
196/// A row is in the queue when at least one detector fired or the pipeline flagged it, so clearing
197/// the flag by hand only removes a row no detector also picked. Superseded rows are excluded in the
198/// query itself rather than a later predicate: a false positive here costs an operator's attention
199/// directly. `thresholds` are the same seconds [`count_stuck`](super::count_stuck) uses, so the
200/// table and the alert can't disagree about what stuck means. `reasons` and `only_without_reason`
201/// behave as on [`AttentionSelection`].
202pub async fn get_attention_items(
203    conn: &mut PgConnection,
204    thresholds: &StuckThresholds,
205    too_many_attempts: i32,
206    selection: AttentionSelection<'_>,
207) -> ModelResult<Vec<AttentionRegistration>> {
208    let AttentionSelection {
209        reasons,
210        only_without_reason,
211        sort,
212        limit,
213        offset,
214    } = selection;
215    let (state_thresholds, threshold_secs) = thresholds.state_seconds_arrays();
216    let reason_names: Vec<&str> = reasons.iter().map(|reason| reason.as_str()).collect();
217    let res = sqlx::query_as!(
218        AttentionRegistration,
219        r#"
220SELECT cr.id,
221  cr.user_id,
222  ud.first_name AS "first_name?",
223  ud.last_name AS "last_name?",
224  ud.email AS "email?",
225  cr.course_id,
226  c.name AS course_name,
227  cr.course_module_id,
228  cm.name AS course_module_name,
229  cr.state,
230  cr.state_entered_at,
231  cr.error_code AS "error_code?",
232  cr.submit_retry_count + cr.verify_attempt_count AS "attempt_count!",
233  cr.needs_admin_attention,
234  cr.next_attempt_at,
235  cr.submitted_at,
236  cr.resubmit_not_before,
237  cr.student_number,
238  d.stuck_in_state AS "stuck_in_state!",
239  d.permanent_error AS "permanent_error!",
240  d.retry_window_expired AS "retry_window_expired!",
241  d.misregistered AS "misregistered!",
242  d.too_many_attempts AS "too_many_attempts!",
243  d.outcome_uncertain AS "outcome_uncertain!",
244  COUNT(*) OVER () AS "total_count!",
245  COUNT(*) FILTER (
246    WHERE d.stuck_in_state
247  ) OVER () AS "stuck_in_state_count!",
248  COUNT(*) FILTER (
249    WHERE d.permanent_error
250  ) OVER () AS "permanent_error_count!",
251  COUNT(*) FILTER (
252    WHERE d.retry_window_expired
253  ) OVER () AS "retry_window_expired_count!",
254  COUNT(*) FILTER (
255    WHERE d.misregistered
256  ) OVER () AS "misregistered_count!",
257  COUNT(*) FILTER (
258    WHERE d.too_many_attempts
259  ) OVER () AS "too_many_attempts_count!",
260  COUNT(*) FILTER (
261    WHERE d.outcome_uncertain
262  ) OVER () AS "outcome_uncertain_count!",
263  COUNT(*) FILTER (
264    WHERE NOT any_d.any_reason
265  ) OVER () AS "flagged_without_reason_count!"
266FROM credit_registrations cr
267  JOIN courses c ON c.id = cr.course_id
268  JOIN course_modules cm ON cm.id = cr.course_module_id
269  LEFT JOIN user_details ud ON ud.user_id = cr.user_id
270  LEFT JOIN LATERAL (
271    SELECT u.threshold_secs
272    FROM UNNEST($1::credit_registration_state [], $2::double precision []) AS u(state, threshold_secs)
273    WHERE u.state = cr.state
274  ) t ON TRUE
275  CROSS JOIN LATERAL (
276    SELECT cr.terminal_at IS NULL
277      AND t.threshold_secs IS NOT NULL
278      AND now() - cr.state_entered_at > MAKE_INTERVAL(secs => t.threshold_secs) AS stuck_in_state,
279      cr.state = 'failed_permanent'
280      AND cr.needs_admin_attention AS permanent_error,
281      -- Coalesced because error_code is nullable and this is selected into a plain `bool`: a row
282      -- another detector picked while holding no error code would otherwise fail to decode and take
283      -- the whole table down with it.
284      COALESCE(cr.error_code = 'retry_window_expired', FALSE) AS retry_window_expired,
285      cr.state = 'misregistered' AS misregistered,
286      cr.submit_retry_count >= $3 AS too_many_attempts,
287      cr.state = 'submission_uncertain' AS outcome_uncertain
288  ) d
289  CROSS JOIN LATERAL (
290    SELECT d.stuck_in_state
291      OR d.permanent_error
292      OR d.retry_window_expired
293      OR d.misregistered
294      OR d.too_many_attempts
295      OR d.outcome_uncertain AS any_reason
296  ) any_d
297WHERE cr.superseded_by_id IS NULL
298  AND cr.deleted_at IS NULL
299  AND (
300    any_d.any_reason
301    OR cr.needs_admin_attention
302  )
303  AND (
304    NOT $8::bool
305    OR NOT any_d.any_reason
306  )
307  AND (
308    CARDINALITY($4::text []) = 0
309    OR (d.stuck_in_state AND 'stuck_in_state' = ANY($4))
310    OR (d.permanent_error AND 'permanent_error' = ANY($4))
311    OR (
312      d.retry_window_expired
313      AND 'retry_window_expired' = ANY($4)
314    )
315    OR (d.misregistered AND 'misregistered' = ANY($4))
316    OR (
317      d.too_many_attempts
318      AND 'too_many_attempts' = ANY($4)
319    )
320    OR (
321      d.outcome_uncertain
322      AND 'outcome_uncertain' = ANY($4)
323    )
324  )
325ORDER BY CASE
326    WHEN $5::text = 'course' THEN c.name
327  END,
328  CASE
329    WHEN $5::text = 'next_attempt' THEN cr.next_attempt_at
330    ELSE cr.state_entered_at
331  END,
332  cr.id
333LIMIT $6 OFFSET $7
334        "#,
335        &state_thresholds as &[CreditRegistrationState],
336        &threshold_secs as &[f64],
337        too_many_attempts,
338        &reason_names as &[&str],
339        sort.as_str(),
340        limit,
341        offset,
342        only_without_reason,
343    )
344    .fetch_all(conn)
345    .await?;
346    Ok(res)
347}
348
349/// The whole queue's totals: how many rows need a human, and how many of them each detector picked.
350///
351/// The canonical "needs a human" count. Every surface that shows one — the Overview tile, the tab
352/// badge, the Errors queue — reads this, so none can disagree. `None` when the queue is empty.
353pub async fn count_needing_attention(
354    conn: &mut PgConnection,
355    thresholds: &StuckThresholds,
356    too_many_attempts: i32,
357) -> ModelResult<Option<AttentionRegistration>> {
358    let rows = get_attention_items(
359        conn,
360        thresholds,
361        too_many_attempts,
362        AttentionSelection::default(),
363    )
364    .await?;
365    Ok(rows.into_iter().next())
366}