1use std::collections::HashMap;
4
5use headless_lms_models::credit_registration_admin_actions::{
6 CreditRegistrationAdminAction, CreditRegistrationAdminActionTarget, GLOBAL_ADMIN_ROLE,
7 NewCreditRegistrationAdminAction,
8};
9use headless_lms_models::credit_registration_phase_state;
10use headless_lms_models::credit_registrations::{
11 self, CreditRegistrationErrorCode, CreditRegistrationErrorCodeCount, CreditRegistrationState,
12 OldestNonTerminalRegistration, StuckRegistrationCount,
13};
14use headless_lms_models::library::credit_registration::PendingReasonCounts;
15use headless_lms_models::suotar_api_calls::{
16 self, SuotarEndpoint, SuotarEndpointStanding as SuotarEndpointStandingRow,
17 SuotarEndpointStatsForWindow,
18};
19use utoipa::ToSchema;
20
21use crate::domain::credit_registration::health::{
22 CreditRegistrationHealth, evaluate, is_heartbeat_late, stuck_thresholds,
23};
24use crate::domain::credit_registration_phases::CreditRegistrationPhase;
25use crate::domain::credit_registration_phases::breaker::{
26 MAX_CONSECUTIVE_SUOTAR_FAILURES, ScopeKey, snapshot,
27};
28use crate::prelude::*;
29
30use super::{authorize_credit_registration_admin, required_reason};
31
32const THROUGHPUT_DAYS: i64 = 30;
33
34const ENDPOINT_STATS_WINDOWS_SECS: [i64; 3] = [60 * 60, 24 * 60 * 60, 7 * 24 * 60 * 60];
35
36#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
37pub struct CreditRegistrationStateTotal {
38 pub state: CreditRegistrationState,
39 pub count: i64,
40}
41
42#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
43pub struct CreditRegistrationErrorCodeTotal {
44 pub error_code: CreditRegistrationErrorCode,
45 pub in_flight_count: i64,
47 pub terminal_failure_count: i64,
49}
50
51#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
52pub struct CreditRegistrationOldestNonTerminal {
53 pub credit_registration_id: Uuid,
54 pub state: CreditRegistrationState,
55 pub state_entered_at: DateTime<Utc>,
56 pub seconds_in_state: i64,
59}
60
61#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
62pub struct CreditRegistrationThroughputBucket {
63 pub day: DateTime<Utc>,
64 pub registered_count: i64,
65 pub other_success_count: i64,
67 pub failed_count: i64,
68}
69
70#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
71pub struct CreditRegistrationStuckTotal {
72 pub state: CreditRegistrationState,
73 pub count: i64,
74 pub severely_stuck_count: i64,
75 pub oldest_state_entered_at: Option<DateTime<Utc>>,
76}
77
78#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
80pub struct SuotarEndpointStanding {
81 pub endpoint: SuotarEndpoint,
82 pub last_success_at: Option<DateTime<Utc>>,
83 pub last_failure_at: Option<DateTime<Utc>>,
84 pub consecutive_failures: i64,
85}
86
87#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
91pub struct CreditRegistrationCircuitBreakerState {
92 pub open: bool,
93 pub consecutive_failures: i64,
94 pub open_for_secs: Option<i64>,
95 pub trips_after_consecutive_failures: i64,
96}
97
98#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
102pub struct CreditRegistrationPhaseStatus {
103 pub phase: String,
104 pub process_name: String,
105 pub expected_interval_secs: i32,
106 pub last_heartbeat_at: Option<DateTime<Utc>>,
107 pub last_success_at: Option<DateTime<Utc>>,
108 pub last_run_finished_at: Option<DateTime<Utc>>,
109 pub items_processed_last_run: Option<i32>,
110 pub items_failed_last_run: Option<i32>,
111 pub consecutive_failures: i32,
112 pub paused_at: Option<DateTime<Utc>>,
113 pub pause_reason: Option<String>,
114 pub implemented: bool,
116 pub seconds_since_heartbeat: Option<i64>,
119 pub heartbeat_late: bool,
122}
123
124#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
125pub struct CreditRegistrationOverview {
126 pub health: CreditRegistrationHealth,
127 pub counts_by_state: Vec<CreditRegistrationStateTotal>,
128 pub pending_by_reason: PendingReasonCounts,
130 pub error_codes: Vec<CreditRegistrationErrorCodeTotal>,
131 pub needs_admin_attention_count: i64,
132 pub oldest_non_terminal: Option<CreditRegistrationOldestNonTerminal>,
133 pub throughput: Vec<CreditRegistrationThroughputBucket>,
134 pub throughput_days: i64,
135 pub stuck: Vec<CreditRegistrationStuckTotal>,
136 pub endpoints: Vec<SuotarEndpointStanding>,
137 pub circuit_breaker: CreditRegistrationCircuitBreakerState,
138}
139
140#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
141pub struct SuotarEndpointWindowStats {
142 pub endpoint: SuotarEndpoint,
143 pub call_count: i64,
144 pub failed_call_count: i64,
145 pub in_flight_count: i64,
146 pub ok_item_count: i64,
147 pub error_item_count: i64,
148 pub p50_duration_ms: Option<i32>,
149 pub p95_duration_ms: Option<i32>,
150 pub last_success_at: Option<DateTime<Utc>>,
151 pub last_failure_at: Option<DateTime<Utc>>,
152 pub last_request_level_error_code: Option<String>,
154}
155
156#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
157pub struct SuotarHealthWindow {
158 pub window_secs: i64,
159 pub endpoints: Vec<SuotarEndpointWindowStats>,
160}
161
162#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
163pub struct SuotarHealth {
164 pub windows: Vec<SuotarHealthWindow>,
165}
166
167#[derive(Debug, Deserialize, ToSchema)]
168pub struct AdminPausePhasePayload {
169 pub reason: String,
170}
171
172#[derive(Debug, Deserialize, ToSchema)]
173pub struct AdminPhaseActionPayload {
174 pub reason: Option<String>,
175}
176
177#[instrument(skip(pool))]
182#[utoipa::path(
183 get,
184 path = "/overview",
185 operation_id = "getCreditRegistrationOverview",
186 tag = "credit-registration-admin",
187 responses(
188 (status = 200, description = "Counts, throughput, phase heartbeats and the active alerts", body = CreditRegistrationOverview)
189 )
190)]
191pub async fn get_credit_registration_overview(
192 user: AuthUser,
193 pool: web::Data<PgPool>,
194) -> ControllerResult<web::Json<CreditRegistrationOverview>> {
195 let mut conn = pool.acquire().await?;
196 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
197
198 let stuck_rows = credit_registrations::count_stuck(&mut conn, &stuck_thresholds()).await?;
199 let depths = credit_registrations::count_by_state(&mut conn).await?;
200 let health = evaluate(&mut conn, &stuck_rows, &depths).await?;
201 let counts_by_state = depths
202 .iter()
203 .map(|&(state, count)| CreditRegistrationStateTotal { state, count })
204 .collect();
205 let pending_by_reason = credit_registrations::count_pending_by_reason(&mut conn).await?;
206 let error_codes = credit_registrations::count_by_error_code(&mut conn)
207 .await?
208 .into_iter()
209 .map(to_error_code_total)
210 .collect();
211 let needs_admin_attention_count =
212 credit_registrations::count_needing_admin_attention(&mut conn).await?;
213 let oldest_non_terminal = credit_registrations::get_oldest_non_terminal(&mut conn)
214 .await?
215 .map(|row| to_oldest_non_terminal(row, Utc::now()));
216 let throughput = credit_registrations::get_throughput_by_day(
217 &mut conn,
218 Utc::now() - chrono::Duration::days(THROUGHPUT_DAYS),
219 )
220 .await?
221 .into_iter()
222 .map(|row| CreditRegistrationThroughputBucket {
223 day: row.day,
224 registered_count: row.registered_count,
225 other_success_count: row.other_success_count,
226 failed_count: row.failed_count,
227 })
228 .collect();
229 let stuck = stuck_rows.into_iter().map(to_stuck_total).collect();
230 let endpoints = suotar_api_calls::get_endpoint_standings(&mut conn)
231 .await?
232 .into_iter()
233 .map(to_endpoint_standing)
234 .collect();
235
236 token.authorized_ok(web::Json(CreditRegistrationOverview {
237 health,
238 counts_by_state,
239 pending_by_reason,
240 error_codes,
241 needs_admin_attention_count,
242 oldest_non_terminal,
243 throughput,
244 throughput_days: THROUGHPUT_DAYS,
245 stuck,
246 endpoints,
247 circuit_breaker: circuit_breaker_state(),
248 }))
249}
250
251#[instrument(skip(pool))]
256#[utoipa::path(
257 get,
258 path = "/suotar-health",
259 operation_id = "getSuotarHealth",
260 tag = "credit-registration-admin",
261 responses(
262 (status = 200, description = "Study registry traffic per endpoint and window", body = SuotarHealth)
263 )
264)]
265pub async fn get_suotar_health(
266 user: AuthUser,
267 pool: web::Data<PgPool>,
268) -> ControllerResult<web::Json<SuotarHealth>> {
269 let mut conn = pool.acquire().await?;
270 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
271
272 let mut by_window: HashMap<i64, Vec<SuotarEndpointWindowStats>> = HashMap::new();
273 for row in
274 suotar_api_calls::get_endpoint_stats_for_windows(&mut conn, &ENDPOINT_STATS_WINDOWS_SECS)
275 .await?
276 {
277 by_window
278 .entry(row.window_secs)
279 .or_default()
280 .push(to_endpoint_window_stats_for_window(row));
281 }
282 let windows = ENDPOINT_STATS_WINDOWS_SECS
283 .into_iter()
284 .map(|window_secs| SuotarHealthWindow {
285 window_secs,
286 endpoints: by_window.remove(&window_secs).unwrap_or_default(),
287 })
288 .collect();
289
290 token.authorized_ok(web::Json(SuotarHealth { windows }))
291}
292
293#[instrument(skip(pool, payload))]
298#[utoipa::path(
299 post,
300 path = "/phases/{phase}/pause",
301 operation_id = "adminPausePhase",
302 tag = "credit-registration-admin",
303 params(("phase" = String, Path, description = "One of the twelve canonical phase names")),
304 request_body = AdminPausePhasePayload,
305 responses(
306 (status = 200, description = "The phase's status after pausing", body = CreditRegistrationPhaseStatus),
307 (status = 422, description = "No reason given, or not one of the canonical phase names")
308 )
309)]
310pub async fn admin_pause_phase(
311 user: AuthUser,
312 pool: web::Data<PgPool>,
313 phase: web::Path<String>,
314 payload: web::Json<AdminPausePhasePayload>,
315) -> ControllerResult<web::Json<CreditRegistrationPhaseStatus>> {
316 let mut conn = pool.acquire().await?;
317 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
318
319 let phase = require_known_phase(&phase)?;
320 let reason = required_reason(&payload.reason)?;
321
322 let mut tx = conn.begin().await?;
323 credit_registration_phase_state::pause(&mut tx, phase, user.id, Some(reason)).await?;
324 models::credit_registration_admin_actions::record(
325 &mut tx,
326 &NewCreditRegistrationAdminAction {
327 target_phase: Some(phase.to_string()),
328 reason: Some(reason.to_string()),
329 ..NewCreditRegistrationAdminAction::new(
330 CreditRegistrationAdminAction::PausePhase,
331 CreditRegistrationAdminActionTarget::Phase,
332 user.id,
333 GLOBAL_ADMIN_ROLE,
334 )
335 },
336 )
337 .await?;
338 tx.commit().await?;
339
340 token.authorized_ok(web::Json(one_phase_status(&mut conn, phase).await?))
341}
342
343#[instrument(skip(pool, payload))]
348#[utoipa::path(
349 post,
350 path = "/phases/{phase}/resume",
351 operation_id = "adminResumePhase",
352 tag = "credit-registration-admin",
353 params(("phase" = String, Path, description = "One of the twelve canonical phase names")),
354 request_body = AdminPhaseActionPayload,
355 responses(
356 (status = 200, description = "The phase's status after resuming", body = CreditRegistrationPhaseStatus),
357 (status = 422, description = "Not one of the canonical phase names")
358 )
359)]
360pub async fn admin_resume_phase(
361 user: AuthUser,
362 pool: web::Data<PgPool>,
363 phase: web::Path<String>,
364 payload: web::Json<AdminPhaseActionPayload>,
365) -> ControllerResult<web::Json<CreditRegistrationPhaseStatus>> {
366 let mut conn = pool.acquire().await?;
367 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
368
369 let phase = require_known_phase(&phase)?;
370
371 let mut tx = conn.begin().await?;
372 credit_registration_phase_state::resume(&mut tx, phase).await?;
373 models::credit_registration_admin_actions::record(
374 &mut tx,
375 &NewCreditRegistrationAdminAction {
376 target_phase: Some(phase.to_string()),
377 reason: payload.reason.clone(),
378 ..NewCreditRegistrationAdminAction::new(
379 CreditRegistrationAdminAction::ResumePhase,
380 CreditRegistrationAdminActionTarget::Phase,
381 user.id,
382 GLOBAL_ADMIN_ROLE,
383 )
384 },
385 )
386 .await?;
387 tx.commit().await?;
388
389 token.authorized_ok(web::Json(one_phase_status(&mut conn, phase).await?))
390}
391
392#[instrument(skip(pool, payload))]
397#[utoipa::path(
398 post,
399 path = "/phases/{phase}/run-now",
400 operation_id = "adminRunPhaseNow",
401 tag = "credit-registration-admin",
402 params(("phase" = String, Path, description = "One of the twelve canonical phase names")),
403 request_body = AdminPhaseActionPayload,
404 responses(
405 (status = 200, description = "The phase's status after being made due", body = CreditRegistrationPhaseStatus),
406 (status = 422, description = "Not one of the canonical phase names")
407 )
408)]
409pub async fn admin_run_phase_now(
410 user: AuthUser,
411 pool: web::Data<PgPool>,
412 phase: web::Path<String>,
413 payload: web::Json<AdminPhaseActionPayload>,
414) -> ControllerResult<web::Json<CreditRegistrationPhaseStatus>> {
415 let mut conn = pool.acquire().await?;
416 let token = authorize_credit_registration_admin(&mut conn, user.id).await?;
417
418 let phase = require_known_phase(&phase)?;
419
420 let mut tx = conn.begin().await?;
421 credit_registration_phase_state::run_now(&mut tx, phase).await?;
422 models::credit_registration_admin_actions::record(
423 &mut tx,
424 &NewCreditRegistrationAdminAction {
425 target_phase: Some(phase.to_string()),
426 reason: payload.reason.clone(),
427 ..NewCreditRegistrationAdminAction::new(
428 CreditRegistrationAdminAction::RunPhaseNow,
429 CreditRegistrationAdminActionTarget::Phase,
430 user.id,
431 GLOBAL_ADMIN_ROLE,
432 )
433 },
434 )
435 .await?;
436 tx.commit().await?;
437
438 token.authorized_ok(web::Json(one_phase_status(&mut conn, phase).await?))
439}
440
441fn require_known_phase(phase: &str) -> Result<&'static str, ControllerError> {
444 CreditRegistrationPhase::from_phase_name(phase)
445 .map(CreditRegistrationPhase::as_str)
446 .ok_or_else(|| {
447 controller_err!(
448 BadRequest,
449 "Not one of the canonical phase names.".to_string()
450 )
451 })
452}
453
454async fn one_phase_status(
456 conn: &mut PgConnection,
457 phase: &str,
458) -> Result<CreditRegistrationPhaseStatus, ControllerError> {
459 let row = credit_registration_phase_state::get_by_phase(conn, phase).await?;
460 Ok(to_phase_status(row, Utc::now()))
461}
462
463fn to_phase_status(
464 row: credit_registration_phase_state::CreditRegistrationPhaseState,
465 now: DateTime<Utc>,
466) -> CreditRegistrationPhaseStatus {
467 let seconds_since_heartbeat = row.last_heartbeat_at.map(|at| (now - at).num_seconds());
468 let heartbeat_late = is_heartbeat_late(
469 row.last_heartbeat_at,
470 row.expected_interval_secs,
471 row.paused_at,
472 now,
473 );
474 CreditRegistrationPhaseStatus {
475 implemented: CreditRegistrationPhase::from_phase_name(&row.phase).is_some(),
476 phase: row.phase,
477 process_name: row.process_name,
478 expected_interval_secs: row.expected_interval_secs,
479 last_heartbeat_at: row.last_heartbeat_at,
480 last_success_at: row.last_success_at,
481 last_run_finished_at: row.last_run_finished_at,
482 items_processed_last_run: row.items_processed_last_run,
483 items_failed_last_run: row.items_failed_last_run,
484 consecutive_failures: row.consecutive_failures,
485 paused_at: row.paused_at,
486 pause_reason: row.pause_reason,
487 seconds_since_heartbeat,
488 heartbeat_late,
489 }
490}
491
492fn circuit_breaker_state() -> CreditRegistrationCircuitBreakerState {
493 let state = snapshot(&ScopeKey::Global);
494 CreditRegistrationCircuitBreakerState {
495 open: state.open,
496 consecutive_failures: i64::from(state.consecutive_failures),
497 open_for_secs: state.open_for_secs.map(|secs| secs as i64),
498 trips_after_consecutive_failures: i64::from(MAX_CONSECUTIVE_SUOTAR_FAILURES),
499 }
500}
501
502fn to_error_code_total(row: CreditRegistrationErrorCodeCount) -> CreditRegistrationErrorCodeTotal {
503 CreditRegistrationErrorCodeTotal {
504 error_code: row.error_code,
505 in_flight_count: row.in_flight_count,
506 terminal_failure_count: row.terminal_failure_count,
507 }
508}
509
510fn to_oldest_non_terminal(
511 row: OldestNonTerminalRegistration,
512 now: DateTime<Utc>,
513) -> CreditRegistrationOldestNonTerminal {
514 CreditRegistrationOldestNonTerminal {
515 credit_registration_id: row.id,
516 state: row.state,
517 seconds_in_state: (now - row.state_entered_at).num_seconds(),
518 state_entered_at: row.state_entered_at,
519 }
520}
521
522fn to_stuck_total(row: StuckRegistrationCount) -> CreditRegistrationStuckTotal {
523 CreditRegistrationStuckTotal {
524 state: row.state,
525 count: row.count,
526 severely_stuck_count: row.severely_stuck_count,
527 oldest_state_entered_at: row.oldest_state_entered_at,
528 }
529}
530
531fn to_endpoint_standing(row: SuotarEndpointStandingRow) -> SuotarEndpointStanding {
532 SuotarEndpointStanding {
533 endpoint: row.endpoint,
534 last_success_at: row.last_success_at,
535 last_failure_at: row.last_failure_at,
536 consecutive_failures: row.consecutive_failures,
537 }
538}
539
540fn to_endpoint_window_stats_for_window(
541 row: SuotarEndpointStatsForWindow,
542) -> SuotarEndpointWindowStats {
543 SuotarEndpointWindowStats {
544 endpoint: row.endpoint,
545 call_count: row.call_count,
546 failed_call_count: row.failed_call_count,
547 in_flight_count: row.in_flight_count,
548 ok_item_count: row.ok_item_count,
549 error_item_count: row.error_item_count,
550 p50_duration_ms: row.p50_duration_ms,
551 p95_duration_ms: row.p95_duration_ms,
552 last_success_at: row.last_success_at,
553 last_failure_at: row.last_failure_at,
554 last_request_level_error_code: row.last_request_level_error_code,
555 }
556}
557
558pub fn _add_routes(cfg: &mut ServiceConfig) {
559 cfg.route("/overview", web::get().to(get_credit_registration_overview))
560 .route("/suotar-health", web::get().to(get_suotar_health))
561 .route("/phases/{phase}/pause", web::post().to(admin_pause_phase))
562 .route("/phases/{phase}/resume", web::post().to(admin_resume_phase))
563 .route(
564 "/phases/{phase}/run-now",
565 web::post().to(admin_run_phase_now),
566 );
567}