headless_lms_models/credit_registrations/
metrics.rs1use super::state::{CreditRegistrationErrorCode, CreditRegistrationState};
4use crate::library::credit_registration::PendingReasonCounts;
5use crate::prelude::*;
6use utoipa::ToSchema;
7
8pub 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
28pub 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
71pub 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#[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
106pub 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#[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#[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
177pub 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#[derive(Debug, Clone, PartialEq, Default)]
209pub struct TerminalOutcomeTotals {
210 pub success_count: i64,
212 pub registered_count: i64,
214 pub failed_permanent_count: i64,
215 pub cancelled_count: i64,
216 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
247pub 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#[derive(Debug, Clone, PartialEq)]
273pub struct RegistrationLatency {
274 pub registered_count: i64,
275 pub p50_end_to_end_secs: Option<i64>,
277 pub p95_end_to_end_secs: Option<i64>,
278 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#[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 pub top_error_code: Option<CreditRegistrationErrorCode>,
352}
353
354pub 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#[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 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 pub severely_stuck_count: i64,
438 pub oldest_state_entered_at: Option<DateTime<Utc>>,
439}
440
441pub 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}