Skip to main content

headless_lms_server/domain/credit_registration/
health.rs

1//! The alert rules the credit registration dashboard renders.
2//!
3//! An alert carries identifiers and numbers, never prose: the study registry's own error text is
4//! written for an integrator and is not translated, so the frontend renders one key per alert id
5//! with these values interpolated. The thresholds travel with the alerts, not hardcoded twice.
6
7use headless_lms_models::credit_registrations::{
8    CreditRegistrationState, StuckRegistrationCount, StuckThresholds,
9};
10use headless_lms_models::library::credit_registration::materialize::get_unmaterialised_eligible_completions;
11use headless_lms_models::{ModelResult, prelude::*};
12use headless_lms_models::{
13    course_module_suotar_configurations, credit_registration_account_linking_emails,
14    credit_registration_events, credit_registration_phase_state,
15    credit_registration_roster_schedules, credit_registrations,
16    study_registry_student_number_conflicts, suotar_api_calls,
17};
18use utoipa::ToSchema;
19
20use crate::domain::system_health::HealthStatus;
21use chrono::TimeDelta;
22use headless_lms_credit_registration::CreditRegistrationPhase;
23pub(crate) use headless_lms_credit_registration::attention::{
24    PHASE_HEARTBEAT_INTERVAL_MULTIPLIER, is_heartbeat_late,
25};
26use headless_lms_credit_registration::registry_health::max_study_registry_wait;
27use headless_lms_models::credit_registration_phase_state::CreditRegistrationPhaseState;
28
29/// Within this much of the past, one rejected credential is enough.
30const CREDENTIAL_REJECTION_WINDOW: TimeDelta = TimeDelta::hours(1);
31/// Long enough to hold three failed calls at the longest request timeout.
32const UNREACHABLE_WINDOW: TimeDelta = TimeDelta::hours(4);
33/// Below this the run is a bad minute rather than an outage.
34const UNREACHABLE_CONSECUTIVE_FAILURES: i64 = 3;
35const SERVICE_OUTAGE_WINDOW: TimeDelta = TimeDelta::hours(1);
36/// Below this many items the share below is one bad batch, not a signal.
37const SERVICE_OUTAGE_MIN_ITEMS: i64 = 10;
38const SERVICE_OUTAGE_FAILURE_SHARE_PERCENT: i64 = 30;
39/// Above this many stuck rows the backlog stops being something to look at tomorrow.
40const STUCK_CRITICAL_COUNT: i64 = 50;
41const LINKING_MAIL_WINDOW: TimeDelta = TimeDelta::days(7);
42/// Failures in a row before a phase counts as broken rather than unlucky.
43pub(crate) const PHASE_CONSECUTIVE_FAILURE_LIMIT: i32 = 5;
44/// A phase that owns a nonempty queue and has not succeeded within this many of its own intervals
45/// is running without getting anywhere, which no failure count catches. Never less than its
46/// slowest possible iteration plus [`PHASE_SUCCESS_CALL_MARGIN`], or one slow call would look
47/// like a wedge.
48const PHASE_SUCCESS_INTERVAL_MULTIPLIER: i32 = 10;
49const PHASE_SUCCESS_CALL_MARGIN: TimeDelta = TimeDelta::minutes(10);
50/// The window every "in the last day" rule shares.
51const TERMINAL_WINDOW: TimeDelta = TimeDelta::days(1);
52const PERMANENT_FAILURE_COUNT: i64 = 20;
53const PERMANENT_FAILURE_RATE_PERCENT: i64 = 10;
54/// A reversal is always worth saying; this many at once is an incident.
55const MISREGISTRATION_CRITICAL_COUNT: i64 = 5;
56/// Linking mails one hour may hand over before the volume itself is the problem.
57const LINKING_MAIL_HOURLY_CAP: i64 = 500;
58const LINKING_MAIL_RATE_WINDOW: TimeDelta = TimeDelta::hours(1);
59/// Queued work that makes a day without a single completion mean something.
60const IDLE_QUEUE_DEPTH: i64 = 20;
61/// How long a completion may sit outside the ledger before `materialize` is the suspect rather
62/// than the clock.
63const NEVER_ENTERED_MIN_AGE: TimeDelta = TimeDelta::hours(6);
64/// Bounds the anti-join behind that rule; a bigger backlog reports as this many.
65const NEVER_ENTERED_SAMPLE_LIMIT: i64 = 100;
66const LATENCY_WINDOW: TimeDelta = TimeDelta::days(7);
67/// Under this the registry is quick enough that a doubling says nothing.
68const LATENCY_REGRESSION_FLOOR: TimeDelta = TimeDelta::hours(6);
69const LATENCY_REGRESSION_FACTOR: i64 = 2;
70
71#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
72#[serde(rename_all = "snake_case")]
73pub enum CreditRegistrationAlertId {
74    CredentialsRejected,
75    StudyRegistryUnreachable,
76    ServiceUnavailable,
77    StuckRegistrations,
78    LinkingMailSendFailed,
79    LinkingMailRateCapExceeded,
80    PhaseHeartbeatStale,
81    PhaseFailing,
82    PermanentFailuresAccumulating,
83    MisregistrationsDetected,
84    CourseConfigurationBroken,
85    PipelineIdle,
86    CompletionsNeverEntered,
87    ConfirmationLatencyRegressed,
88    PipelinePausedGlobally,
89    StudyRegistryStudentNumberConflicts,
90    RosterCourseCodeFailing,
91}
92
93#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, ToSchema)]
94#[serde(rename_all = "snake_case")]
95pub enum CreditRegistrationAlertSeverity {
96    /// Worth knowing, not worth acting on. Never makes the overall status anything but healthy.
97    Info,
98    Warning,
99    Critical,
100}
101
102#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
103pub struct CreditRegistrationAlert {
104    pub id: CreditRegistrationAlertId,
105    pub severity: CreditRegistrationAlertSeverity,
106    /// How many rows, calls or phases the rule found.
107    pub count: i64,
108    /// What `count` is out of, where the rule measured one. Not a threshold: thresholds are the
109    /// same for every evaluation and travel separately.
110    pub total: Option<i64>,
111    /// How far back the rule looked, where it looked back at all. `None` for a rule that reads the
112    /// live state or a threshold rather than a window; without it, two alerts counting the same
113    /// thing over different windows read as a contradiction.
114    pub window_secs: Option<i64>,
115    /// When it last happened, where the rule has an instant to point at.
116    pub at: Option<DateTime<Utc>>,
117    /// An identifier the operator can act on — a phase name, a ledger state, a mail domain. Never a
118    /// sentence, and never anything the study registry wrote.
119    pub subject: Option<String>,
120}
121
122#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
123pub struct CreditRegistrationHealth {
124    pub status: HealthStatus,
125    /// Critical first, and a rejected credential first of all: nothing registers until it is fixed.
126    pub alerts: Vec<CreditRegistrationAlert>,
127    /// The only thresholds the frontend reads off the health poll: how long a row may sit in each
128    /// state before it counts as stuck. The other rule constants stay server-side.
129    pub thresholds: StuckThresholds,
130}
131
132pub fn stuck_thresholds() -> StuckThresholds {
133    StuckThresholds::CURRENT
134}
135
136/// Whether a phase counts as failing: too many failures in a row, or a nonempty queue with no
137/// success for too long, or an iteration hung past that same bound. Never while paused. The one
138/// definition behind both the `PhaseFailing` alert and the Workers tab's `failing` flag.
139///
140/// `depth_of` is the live count of a state; `due_enrolment_checks` as for
141/// [`CreditRegistrationPhase::queue_depth`].
142pub(crate) fn is_phase_failing(
143    phase: &CreditRegistrationPhaseState,
144    now: DateTime<Utc>,
145    depth_of: impl Fn(CreditRegistrationState) -> i64,
146    due_enrolment_checks: i64,
147) -> bool {
148    if phase.paused_at.is_some() {
149        return false;
150    }
151    let known_phase = CreditRegistrationPhase::from_phase_name(&phase.phase);
152    let owns_work =
153        known_phase.is_some_and(|known| known.queue_depth(&depth_of, due_enrolment_checks) > 0);
154    let slowest_iteration_secs = known_phase.map_or(0, |known| {
155        i64::try_from(max_study_registry_wait(known).as_secs()).unwrap_or(i64::MAX)
156            + PHASE_SUCCESS_CALL_MARGIN.num_seconds()
157    });
158    let unproductive_after_secs = (i64::from(phase.expected_interval_secs)
159        * i64::from(PHASE_SUCCESS_INTERVAL_MULTIPLIER))
160    .max(slowest_iteration_secs);
161    let unproductive = owns_work
162        && phase.last_success_at.is_some_and(|last_success_at| {
163            (now - last_success_at).num_seconds() > unproductive_after_secs
164        });
165    // The keep-alive refreshes the heartbeat for as long as an iteration runs, so a hung one
166    // shows only here.
167    let hung = phase.last_run_started_at.is_some_and(|started_at| {
168        phase
169            .last_run_finished_at
170            .is_none_or(|finished_at| finished_at < started_at)
171            && (now - started_at).num_seconds() > unproductive_after_secs
172    });
173    phase.consecutive_failures >= PHASE_CONSECUTIVE_FAILURE_LIMIT || unproductive || hung
174}
175
176/// Runs every rule and ranks what it found.
177///
178/// `stuck` and `depths` are passed in because the caller already reads both aggregates, the two
179/// most expensive reads in the request. `depths` is the live count per state, superseded rows
180/// excluded, as [`credit_registrations::count_by_state`] returns it.
181pub async fn evaluate(
182    conn: &mut PgConnection,
183    stuck: &[StuckRegistrationCount],
184    depths: &[(CreditRegistrationState, i64)],
185) -> ModelResult<CreditRegistrationHealth> {
186    let now = Utc::now();
187    let mut alerts = Vec::new();
188
189    let credentials = suotar_api_calls::count_credential_rejections_since(
190        conn,
191        now - CREDENTIAL_REJECTION_WINDOW,
192    )
193    .await?;
194    if credentials.count > 0 {
195        alerts.push(CreditRegistrationAlert {
196            id: CreditRegistrationAlertId::CredentialsRejected,
197            window_secs: Some(CREDENTIAL_REJECTION_WINDOW.num_seconds()),
198            severity: CreditRegistrationAlertSeverity::Critical,
199            count: credentials.count,
200            total: None,
201            at: credentials.last_at,
202            subject: None,
203        });
204    }
205
206    let unreachable =
207        suotar_api_calls::count_unreachable_run_since(conn, now - UNREACHABLE_WINDOW).await?;
208    if unreachable.count >= UNREACHABLE_CONSECUTIVE_FAILURES {
209        alerts.push(CreditRegistrationAlert {
210            id: CreditRegistrationAlertId::StudyRegistryUnreachable,
211            window_secs: Some(UNREACHABLE_WINDOW.num_seconds()),
212            severity: CreditRegistrationAlertSeverity::Critical,
213            count: unreachable.count,
214            total: None,
215            at: unreachable.last_at,
216            subject: None,
217        });
218    }
219
220    if let Some(alert) = service_outage_alert(conn, now).await? {
221        alerts.push(alert);
222    }
223    if let Some(alert) = stuck_alert(stuck) {
224        alerts.push(alert);
225    }
226    if let Some(alert) = linking_mail_alert(conn, now).await? {
227        alerts.push(alert);
228    }
229    if let Some(alert) = linking_mail_rate_alert(conn, now).await? {
230        alerts.push(alert);
231    }
232    alerts.extend(phase_alerts(conn, now, depths).await?);
233    alerts.extend(terminal_outcome_alerts(conn, now, depths).await?);
234    if let Some(alert) = course_configuration_alert(conn).await? {
235        alerts.push(alert);
236    }
237    if let Some(alert) = never_entered_alert(conn).await? {
238        alerts.push(alert);
239    }
240    if let Some(alert) = latency_regression_alert(conn, now).await? {
241        alerts.push(alert);
242    }
243    if let Some(alert) = study_registry_conflict_alert(conn).await? {
244        alerts.push(alert);
245    }
246    if let Some(alert) = roster_course_code_alert(conn).await? {
247        alerts.push(alert);
248    }
249
250    alerts.sort_by_key(|alert| {
251        (
252            std::cmp::Reverse(alert.severity),
253            alert.id != CreditRegistrationAlertId::CredentialsRejected,
254        )
255    });
256    let status = match alerts.iter().map(|alert| alert.severity).max() {
257        Some(CreditRegistrationAlertSeverity::Critical) => HealthStatus::Error,
258        Some(CreditRegistrationAlertSeverity::Warning) => HealthStatus::Warning,
259        Some(CreditRegistrationAlertSeverity::Info) | None => HealthStatus::Healthy,
260    };
261
262    Ok(CreditRegistrationHealth {
263        status,
264        alerts,
265        thresholds: stuck_thresholds(),
266    })
267}
268
269/// The share of recent items that failed on Suotar or Sisu being unavailable. Our only proxy for
270/// Sisu's uptime, which is why it is a rule of its own rather than part of the request-level one
271/// above.
272async fn service_outage_alert(
273    conn: &mut PgConnection,
274    now: DateTime<Utc>,
275) -> ModelResult<Option<CreditRegistrationAlert>> {
276    let totals =
277        credit_registration_events::count_item_outcomes_since(conn, now - SERVICE_OUTAGE_WINDOW)
278            .await?;
279    if totals.item_count < SERVICE_OUTAGE_MIN_ITEMS
280        || totals.service_unavailable_count * 100
281            < totals.item_count * SERVICE_OUTAGE_FAILURE_SHARE_PERCENT
282    {
283        return Ok(None);
284    }
285    Ok(Some(CreditRegistrationAlert {
286        id: CreditRegistrationAlertId::ServiceUnavailable,
287        window_secs: Some(SERVICE_OUTAGE_WINDOW.num_seconds()),
288        severity: CreditRegistrationAlertSeverity::Critical,
289        count: totals.service_unavailable_count,
290        total: Some(totals.item_count),
291        at: totals.last_service_unavailable_at,
292        subject: None,
293    }))
294}
295
296/// Rows the pipeline should have moved on by now. Terminal states are outside this by construction
297/// rather than by a filter that could be forgotten.
298fn stuck_alert(stuck: &[StuckRegistrationCount]) -> Option<CreditRegistrationAlert> {
299    let total: i64 = stuck.iter().map(|row| row.count).sum();
300    if total == 0 {
301        return None;
302    }
303    let severe: i64 = stuck.iter().map(|row| row.severely_stuck_count).sum();
304    let worst = stuck.iter().max_by_key(|row| row.count);
305    let severity = if total > STUCK_CRITICAL_COUNT || severe > 0 {
306        CreditRegistrationAlertSeverity::Critical
307    } else {
308        CreditRegistrationAlertSeverity::Warning
309    };
310    Some(CreditRegistrationAlert {
311        id: CreditRegistrationAlertId::StuckRegistrations,
312        window_secs: None,
313        severity,
314        count: total,
315        total: None,
316        at: worst.and_then(|row| row.oldest_state_changed_at),
317        subject: worst.map(|row| state_name(row.state)),
318    })
319}
320
321/// Linking mails we could not hand over at all. The recipient domain rides along because an
322/// undeliverable host is the usual cause.
323async fn linking_mail_alert(
324    conn: &mut PgConnection,
325    now: DateTime<Utc>,
326) -> ModelResult<Option<CreditRegistrationAlert>> {
327    let since = now - LINKING_MAIL_WINDOW;
328    let totals =
329        credit_registration_account_linking_emails::get_send_status_totals_since(conn, since, now)
330            .await?;
331    if totals.send_failed == 0 {
332        return Ok(None);
333    }
334    let top_domain = credit_registration_account_linking_emails::get_send_failure_domains_since(
335        conn, since, now,
336    )
337    .await?
338    .into_iter()
339    .next()
340    .map(|row| row.domain);
341    Ok(Some(CreditRegistrationAlert {
342        id: CreditRegistrationAlertId::LinkingMailSendFailed,
343        window_secs: Some(LINKING_MAIL_WINDOW.num_seconds()),
344        severity: CreditRegistrationAlertSeverity::Warning,
345        count: totals.send_failed,
346        total: Some(totals.mails_in_window),
347        at: totals.last_send_failed_at,
348        subject: top_domain,
349    }))
350}
351
352/// The volume guard: how many people we mailed in the last hour against what an hour should hold.
353/// Counts addresses, which is what the per-person caps govern.
354async fn linking_mail_rate_alert(
355    conn: &mut PgConnection,
356    now: DateTime<Utc>,
357) -> ModelResult<Option<CreditRegistrationAlert>> {
358    let sent = credit_registration_account_linking_emails::count_sent_since(
359        conn,
360        now - LINKING_MAIL_RATE_WINDOW,
361    )
362    .await?;
363    if sent <= LINKING_MAIL_HOURLY_CAP {
364        return Ok(None);
365    }
366    let severity = if sent > LINKING_MAIL_HOURLY_CAP * 2 {
367        CreditRegistrationAlertSeverity::Critical
368    } else {
369        CreditRegistrationAlertSeverity::Warning
370    };
371    Ok(Some(CreditRegistrationAlert {
372        id: CreditRegistrationAlertId::LinkingMailRateCapExceeded,
373        window_secs: Some(LINKING_MAIL_RATE_WINDOW.num_seconds()),
374        severity,
375        count: sent,
376        total: Some(LINKING_MAIL_HOURLY_CAP),
377        at: Some(now),
378        subject: None,
379    }))
380}
381
382/// What the phase table says about itself: phases that stopped reporting, and phases that report
383/// but get nowhere.
384///
385/// A phase that has never heartbeated is deliberately outside both: a freshly migrated database has
386/// no heartbeats at all, and that would keep the banner permanently red.
387async fn phase_alerts(
388    conn: &mut PgConnection,
389    now: DateTime<Utc>,
390    depths: &[(CreditRegistrationState, i64)],
391) -> ModelResult<Vec<CreditRegistrationAlert>> {
392    let phases = credit_registration_phase_state::get_all(conn).await?;
393    let due_enrolment_checks = credit_registrations::count_due_enrolment_checks(conn).await?;
394    let mut stale: Vec<&str> = Vec::new();
395    let mut failing: Vec<&str> = Vec::new();
396    let mut paused = 0;
397    let mut last_paused_at = None;
398    for phase in &phases {
399        if let Some(paused_at) = phase.paused_at {
400            paused += 1;
401            last_paused_at = last_paused_at.max(Some(paused_at));
402            continue;
403        }
404        if is_heartbeat_late(
405            phase.last_heartbeat_at,
406            phase.expected_interval_secs,
407            None,
408            now,
409        ) {
410            stale.push(&phase.phase);
411        }
412        if is_phase_failing(
413            phase,
414            now,
415            |state| depth_of(depths, state),
416            due_enrolment_checks,
417        ) {
418            failing.push(&phase.phase);
419        }
420    }
421
422    let mut alerts = Vec::new();
423    if !stale.is_empty() {
424        alerts.push(CreditRegistrationAlert {
425            id: CreditRegistrationAlertId::PhaseHeartbeatStale,
426            window_secs: None,
427            severity: CreditRegistrationAlertSeverity::Critical,
428            count: stale.len() as i64,
429            total: Some(phases.len() as i64),
430            at: None,
431            subject: stale.first().map(|phase| (*phase).to_string()),
432        });
433    }
434    if !failing.is_empty() {
435        alerts.push(CreditRegistrationAlert {
436            id: CreditRegistrationAlertId::PhaseFailing,
437            window_secs: None,
438            severity: CreditRegistrationAlertSeverity::Critical,
439            count: failing.len() as i64,
440            total: Some(phases.len() as i64),
441            at: None,
442            subject: failing.first().map(|phase| (*phase).to_string()),
443        });
444    }
445    if paused > 0 && paused == phases.len() {
446        alerts.push(CreditRegistrationAlert {
447            id: CreditRegistrationAlertId::PipelinePausedGlobally,
448            window_secs: None,
449            severity: CreditRegistrationAlertSeverity::Info,
450            count: paused as i64,
451            total: Some(phases.len() as i64),
452            at: last_paused_at,
453            subject: None,
454        });
455    }
456    Ok(alerts)
457}
458
459/// The three rules read off the last day's terminal outcomes: failures piling up, reversals, and a
460/// pipeline that finished nothing while holding work.
461async fn terminal_outcome_alerts(
462    conn: &mut PgConnection,
463    now: DateTime<Utc>,
464    depths: &[(CreditRegistrationState, i64)],
465) -> ModelResult<Vec<CreditRegistrationAlert>> {
466    let since = now - TERMINAL_WINDOW;
467    let totals = credit_registrations::count_terminal_outcomes_since(conn, since).await?;
468    let mut alerts = Vec::new();
469
470    let rate_broken = totals.total_count >= PERMANENT_FAILURE_COUNT
471        && totals.failed_permanent_count * 100
472            > totals.total_count * PERMANENT_FAILURE_RATE_PERCENT;
473    if totals.failed_permanent_count >= PERMANENT_FAILURE_COUNT || rate_broken {
474        alerts.push(CreditRegistrationAlert {
475            id: CreditRegistrationAlertId::PermanentFailuresAccumulating,
476            window_secs: Some(TERMINAL_WINDOW.num_seconds()),
477            severity: CreditRegistrationAlertSeverity::Warning,
478            count: totals.failed_permanent_count,
479            total: Some(totals.total_count),
480            at: Some(now),
481            subject: None,
482        });
483    }
484
485    let misregistered = credit_registrations::count_entered_state_since(
486        conn,
487        CreditRegistrationState::Misregistered,
488        since,
489    )
490    .await?;
491    if misregistered > 0 {
492        alerts.push(CreditRegistrationAlert {
493            id: CreditRegistrationAlertId::MisregistrationsDetected,
494            window_secs: Some(TERMINAL_WINDOW.num_seconds()),
495            severity: if misregistered >= MISREGISTRATION_CRITICAL_COUNT {
496                CreditRegistrationAlertSeverity::Critical
497            } else {
498                CreditRegistrationAlertSeverity::Warning
499            },
500            count: misregistered,
501            total: None,
502            at: Some(now),
503            subject: None,
504        });
505    }
506
507    let queued = depth_of(depths, CreditRegistrationState::ReadyToSubmit)
508        + depth_of(depths, CreditRegistrationState::AwaitingVerification)
509        + depth_of(depths, CreditRegistrationState::PartiallyRegistered);
510    if totals.total_count == 0 && queued > IDLE_QUEUE_DEPTH {
511        alerts.push(CreditRegistrationAlert {
512            id: CreditRegistrationAlertId::PipelineIdle,
513            window_secs: Some(TERMINAL_WINDOW.num_seconds()),
514            severity: CreditRegistrationAlertSeverity::Warning,
515            count: queued,
516            total: None,
517            at: Some(now),
518            subject: None,
519        });
520    }
521    Ok(alerts)
522}
523
524/// Modules the last configuration check found broken. Never checked is not counted: the Courses tab
525/// renders unknown and broken differently, and so must this.
526async fn course_configuration_alert(
527    conn: &mut PgConnection,
528) -> ModelResult<Option<CreditRegistrationAlert>> {
529    let count =
530        course_module_suotar_configurations::count_modules_failing_config_check(conn).await?;
531    Ok((count > 0).then_some(CreditRegistrationAlert {
532        id: CreditRegistrationAlertId::CourseConfigurationBroken,
533        window_secs: None,
534        severity: CreditRegistrationAlertSeverity::Warning,
535        count,
536        total: None,
537        at: None,
538        subject: None,
539    }))
540}
541
542/// Completions old enough that `materialize` has had every chance and still has no ledger row for
543/// them. Sampled rather than counted, so the anti-join stops early on a large backlog.
544async fn never_entered_alert(
545    conn: &mut PgConnection,
546) -> ModelResult<Option<CreditRegistrationAlert>> {
547    let found = get_unmaterialised_eligible_completions(
548        conn,
549        NEVER_ENTERED_MIN_AGE.num_seconds(),
550        NEVER_ENTERED_SAMPLE_LIMIT,
551    )
552    .await?;
553    if found.is_empty() {
554        return Ok(None);
555    }
556    Ok(Some(CreditRegistrationAlert {
557        id: CreditRegistrationAlertId::CompletionsNeverEntered,
558        window_secs: None,
559        severity: CreditRegistrationAlertSeverity::Warning,
560        count: found.len() as i64,
561        total: Some(NEVER_ENTERED_SAMPLE_LIMIT),
562        at: found.first().map(|row| row.created_at),
563        subject: None,
564    }))
565}
566
567/// How long the study registry is taking to confirm, this week against last. `count` is this
568/// week's p95 in seconds and `total` last week's, so the banner can name both.
569async fn latency_regression_alert(
570    conn: &mut PgConnection,
571    now: DateTime<Utc>,
572) -> ModelResult<Option<CreditRegistrationAlert>> {
573    let window = LATENCY_WINDOW;
574    let current =
575        credit_registrations::get_registration_latency_between(conn, now - window, now).await?;
576    let (Some(current_p95), true) = (current.p95_confirmation_secs, current.registered_count > 0)
577    else {
578        return Ok(None);
579    };
580    if current_p95 < LATENCY_REGRESSION_FLOOR.num_seconds() {
581        return Ok(None);
582    }
583    let previous = credit_registrations::get_registration_latency_between(
584        conn,
585        now - window * 2,
586        now - window,
587    )
588    .await?;
589    let Some(previous_p95) = previous
590        .p95_confirmation_secs
591        .filter(|_| previous.registered_count > 0)
592    else {
593        return Ok(None);
594    };
595    if current_p95 <= previous_p95 * LATENCY_REGRESSION_FACTOR {
596        return Ok(None);
597    }
598    Ok(Some(CreditRegistrationAlert {
599        id: CreditRegistrationAlertId::ConfirmationLatencyRegressed,
600        window_secs: Some(LATENCY_WINDOW.num_seconds()),
601        severity: CreditRegistrationAlertSeverity::Info,
602        count: current_p95,
603        total: Some(previous_p95),
604        at: Some(now),
605        subject: None,
606    }))
607}
608
609/// Accounts the study registry reported a number for that another live link kept us from linking.
610async fn study_registry_conflict_alert(
611    conn: &mut PgConnection,
612) -> ModelResult<Option<CreditRegistrationAlert>> {
613    let count = study_registry_student_number_conflicts::count_unresolved(conn).await?;
614    Ok((count > 0).then_some(CreditRegistrationAlert {
615        id: CreditRegistrationAlertId::StudyRegistryStudentNumberConflicts,
616        window_secs: None,
617        severity: CreditRegistrationAlertSeverity::Warning,
618        count,
619        total: None,
620        at: None,
621        subject: None,
622    }))
623}
624
625/// Course codes whose roster fails even when listed on their own, so enrolment discovery is backing
626/// them off. The worst one rides along, since the usual cause is that one code's configuration.
627async fn roster_course_code_alert(
628    conn: &mut PgConnection,
629) -> ModelResult<Option<CreditRegistrationAlert>> {
630    let failing = credit_registration_roster_schedules::get_failing_codes(conn).await?;
631    let Some(worst) = failing.first() else {
632        return Ok(None);
633    };
634    Ok(Some(CreditRegistrationAlert {
635        id: CreditRegistrationAlertId::RosterCourseCodeFailing,
636        window_secs: None,
637        severity: CreditRegistrationAlertSeverity::Warning,
638        count: i64::try_from(failing.len()).unwrap_or(i64::MAX),
639        total: None,
640        at: worst.last_attempted_at,
641        subject: Some(worst.course_code.clone()),
642    }))
643}
644
645fn depth_of(depths: &[(CreditRegistrationState, i64)], state: CreditRegistrationState) -> i64 {
646    depths
647        .iter()
648        .find(|(row_state, _)| *row_state == state)
649        .map_or(0, |(_, count)| *count)
650}
651
652/// The state's own wire name, taken from its serialisation so the two cannot drift.
653fn state_name(state: CreditRegistrationState) -> String {
654    serde_json::to_value(state)
655        .ok()
656        .and_then(|value| value.as_str().map(str::to_string))
657        .unwrap_or_default()
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    /// The alert's `subject` has to be the same spelling the ledger and the filters use.
665    #[test]
666    fn a_state_names_itself_the_way_the_wire_does() {
667        assert_eq!(
668            state_name(CreditRegistrationState::AwaitingVerification),
669            "awaiting_verification"
670        );
671        for state in CreditRegistrationState::ALL {
672            assert!(!state_name(state).is_empty());
673        }
674    }
675
676    /// Info exists to be shown without turning the page red.
677    #[test]
678    fn severity_ranks_the_way_the_banner_reads_it() {
679        assert!(
680            CreditRegistrationAlertSeverity::Critical > CreditRegistrationAlertSeverity::Warning
681        );
682        assert!(CreditRegistrationAlertSeverity::Warning > CreditRegistrationAlertSeverity::Info);
683    }
684}