Skip to main content

headless_lms_models/credit_registrations/
transition.rs

1//! Moving a row between states: the only writer of `state`, and the one place an edge is checked
2//! against [`CreditRegistrationState::allowed_targets`].
3
4use super::registration::CreditRegistration;
5use super::state::{
6    ADMIN_ONLY_TARGETS, CreditRegistrationErrorCode, CreditRegistrationState,
7    PendingSupersessionEffect,
8};
9use crate::credit_registration_events::{
10    CreditRegistrationEventKind, NewCreditRegistrationEvent, SuotarAnswer,
11};
12use crate::error::missing_model_error;
13use crate::library::credit_registration::timeline::TimelinePhase;
14use crate::prelude::*;
15use crate::suotar_api_calls::SuotarEndpoint;
16use chrono::TimeDelta;
17use std::collections::HashMap;
18
19/// What a move does to the row's admin-attention flag.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum AdminAttention {
22    Raise,
23    Clear,
24}
25
26impl AdminAttention {
27    /// The flag as the move leaves it.
28    pub fn is_raised(self) -> bool {
29        self == Self::Raise
30    }
31}
32
33#[derive(Debug, Clone, PartialEq)]
34pub struct Transition {
35    pub to_state: CreditRegistrationState,
36    pub error_code: Option<CreditRegistrationErrorCode>,
37    /// Scrub before passing: this is persisted.
38    pub error_message: Option<String>,
39    /// `None` leaves the flag as it was.
40    pub needs_admin_attention: Option<AdminAttention>,
41    pub event_kind: CreditRegistrationEventKind,
42    pub event_message: Option<String>,
43    pub actor_user_id: Option<Uuid>,
44    pub suotar_api_call_id: Option<Uuid>,
45    pub suotar_endpoint: Option<SuotarEndpoint>,
46    pub suotar_requested_at: Option<DateTime<Utc>>,
47    pub suotar_answered_at: Option<DateTime<Utc>>,
48    pub suotar_answer: Option<SuotarAnswer>,
49    /// Already scrubbed `{request, response}` payload for the event row.
50    pub event_details: Option<serde_json::Value>,
51    /// The requestItemId the row went out under in the call behind this move.
52    pub request_item_id: Option<String>,
53    /// Set by a caller that computed `to_state` from a row snapshot taken before an `await` (an
54    /// external call, or a gap before its own transaction) during which some other writer could
55    /// have moved the row on. `None` skips the check, for callers writing from a snapshot taken
56    /// under the same transaction's lock.
57    pub expected_from_state: Option<CreditRegistrationState>,
58    /// Which (from → to) edges this write may take; see [`TransitionPolicy`].
59    pub policy: TransitionPolicy,
60    /// When the pipeline may claim the row next. `None` takes the target state's default cadence,
61    /// which is what keeps a caller that forgets from leaving the row spinning.
62    pub next_attempt_at: Option<DateTime<Utc>>,
63    /// Leaves `enrolment_checked_at` alone on a move that would stamp it: a lookup that failed in
64    /// transit, or only found the Sisu person, did not check the enrolment.
65    pub keeps_enrolment_checked_at: bool,
66}
67
68/// Which (from → to) edges [`transition`] will write.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum TransitionPolicy {
71    /// [`CreditRegistrationState::allowed_targets`] only: what the phases and the precondition
72    /// recompute may take.
73    Pipeline,
74    /// Also [`ADMIN_ONLY_TARGETS`], for a teacher's retry or an admin's hand transition. Whether
75    /// this particular row may take the edge is
76    /// [`ResubmissionFacts::admin_transition_refusal`](super::ResubmissionFacts::admin_transition_refusal)'s
77    /// question.
78    Admin,
79    /// No edge check. Fixtures only: seeds and tests plant a row in a state the pipeline could not
80    /// have reached from where it stands in one move.
81    Planted,
82}
83
84impl TransitionPolicy {
85    fn allows(self, from: CreditRegistrationState, to: CreditRegistrationState) -> bool {
86        match self {
87            Self::Pipeline => from.allowed_targets().contains(&to),
88            Self::Admin => from.allowed_targets().contains(&to) || ADMIN_ONLY_TARGETS.contains(&to),
89            Self::Planted => true,
90        }
91    }
92}
93
94impl Transition {
95    pub fn to(to_state: CreditRegistrationState) -> Self {
96        Self {
97            to_state,
98            error_code: None,
99            error_message: None,
100            needs_admin_attention: None,
101            event_kind: CreditRegistrationEventKind::StateChanged,
102            event_message: None,
103            actor_user_id: None,
104            suotar_api_call_id: None,
105            suotar_endpoint: None,
106            suotar_requested_at: None,
107            suotar_answered_at: None,
108            suotar_answer: None,
109            event_details: None,
110            request_item_id: None,
111            expected_from_state: None,
112            policy: TransitionPolicy::Pipeline,
113            next_attempt_at: None,
114            keeps_enrolment_checked_at: false,
115        }
116    }
117
118    /// A move a human asked for, which may also take the [`ADMIN_ONLY_TARGETS`] edges.
119    pub fn by_hand(to_state: CreditRegistrationState) -> Self {
120        Self {
121            policy: TransitionPolicy::Admin,
122            ..Self::to(to_state)
123        }
124    }
125
126    /// A fixture planting a row in a state directly; see [`TransitionPolicy::Planted`].
127    pub fn planted(to_state: CreditRegistrationState) -> Self {
128        Self {
129            policy: TransitionPolicy::Planted,
130            ..Self::to(to_state)
131        }
132    }
133}
134
135/// Moves a ledger row to a new state and appends the matching audit event, atomically.
136///
137/// The only writer of `state`, and the one place (from → to) legality is decided: an edge outside
138/// the transition's [`TransitionPolicy`] is refused as `InvalidRequest` rather than written. A row
139/// no longer in `expected_from_state` is refused as `PreconditionFailed`; a caller that has to tell
140/// that apart from a real failure uses [`transition_unless_moved_on`] instead.
141///
142/// Owns the lifecycle stamps, so callers must not touch them (bar
143/// [`reset_for_resubmission`](super::reset_for_resubmission) clearing `first_failed_at`):
144/// `state_entered_at`, `state_changed_at`, `phase_started_at`, `terminal_at`, `first_failed_at`,
145/// `submit_retry_count`, `registered_at`, `submitted_at`, `enrolment_checked_at`,
146/// `enrolment_banner_dismissed_at`, `no_usable_enrolment_since` and `next_attempt_at`, which takes
147/// the target state's default cadence unless the caller names a time. Starting to wait for an
148/// enrolment clears `first_failed_at`, `submit_retry_count` and `enrolment_banner_dismissed_at`;
149/// leaving that wait clears the check schedule, but not `enrolment_check_group`. Every move clears
150/// the claim of an enrolment check. Returns the row as written.
151pub async fn transition(
152    conn: &mut PgConnection,
153    id: Uuid,
154    transition: &Transition,
155) -> ModelResult<CreditRegistration> {
156    match transition_unless_moved_on(conn, id, transition).await? {
157        Transitioned::Written(after) => Ok(*after),
158        Transitioned::MovedOn { found } => Err(model_err!(
159            PreconditionFailed,
160            format!(
161                "Credit registration {id} is in {found:?}, not the expected {}: refusing to overwrite it.",
162                transition
163                    .expected_from_state
164                    .map(|expected| format!("{expected:?}"))
165                    .unwrap_or_default()
166            )
167        )),
168    }
169}
170
171/// What [`transition_unless_moved_on`] did.
172#[derive(Debug, Clone)]
173pub enum Transitioned {
174    /// The row as written.
175    Written(Box<CreditRegistration>),
176    /// Another writer moved the row out of `expected_from_state` first, so nothing was written.
177    MovedOn { found: CreditRegistrationState },
178}
179
180/// [`transition`] for a caller deciding from a snapshot another writer may have overtaken: a row
181/// no longer in `expected_from_state` comes back as [`Transitioned::MovedOn`] rather than as an
182/// error, since the row is now that writer's and the caller carries on with the rest of its work.
183pub async fn transition_unless_moved_on(
184    conn: &mut PgConnection,
185    id: Uuid,
186    transition: &Transition,
187) -> ModelResult<Transitioned> {
188    let mut tx = conn.begin().await?;
189    let from_state = lock_for_moves(&mut tx, &[id])
190        .await?
191        .remove(&id)
192        .ok_or_else(missing_model_error(
193            ModelErrorType::RecordNotFound,
194            format!("Credit registration {id} does not exist."),
195        ))?;
196    if transition
197        .expected_from_state
198        .is_some_and(|expected| from_state != expected)
199    {
200        return Ok(Transitioned::MovedOn { found: from_state });
201    }
202    check_edge(id, from_state, transition.to_state, transition.policy)?;
203    let after = write_moves(&mut tx, &[(id, from_state, transition)])
204        .await?
205        .pop()
206        .ok_or_else(missing_model_error(
207            ModelErrorType::RecordNotFound,
208            format!("Credit registration {id} does not exist."),
209        ))?;
210    tx.commit().await?;
211    Ok(Transitioned::Written(Box::new(after)))
212}
213
214/// The states of the named rows, locked until the caller's transaction ends.
215async fn lock_for_moves(
216    conn: &mut PgConnection,
217    ids: &[Uuid],
218) -> ModelResult<HashMap<Uuid, CreditRegistrationState>> {
219    let locked = sqlx::query!(
220        r#"
221SELECT id,
222  state
223FROM credit_registrations
224WHERE id = ANY($1)
225  AND deleted_at IS NULL
226ORDER BY id FOR
227UPDATE
228        "#,
229        ids
230    )
231    .fetch_all(conn)
232    .await?;
233    Ok(locked.into_iter().map(|row| (row.id, row.state)).collect())
234}
235
236/// Writes already checked moves of locked rows, with their events, and returns the rows as written.
237async fn write_moves(
238    conn: &mut PgConnection,
239    moves: &[(Uuid, CreditRegistrationState, &Transition)],
240) -> ModelResult<Vec<CreditRegistration>> {
241    let events: Vec<NewCreditRegistrationEvent> = moves
242        .iter()
243        .map(|(id, from_state, transition)| NewCreditRegistrationEvent {
244            credit_registration_id: *id,
245            kind: transition.event_kind,
246            from_state: Some(*from_state),
247            to_state: Some(transition.to_state),
248            error_code: transition.error_code,
249            message: transition.event_message.clone(),
250            suotar_api_call_id: transition.suotar_api_call_id,
251            suotar_endpoint: transition.suotar_endpoint,
252            suotar_requested_at: transition.suotar_requested_at,
253            suotar_answered_at: transition.suotar_answered_at,
254            suotar_answer: transition.suotar_answer,
255            actor_user_id: transition.actor_user_id,
256            details: transition.event_details.clone(),
257            request_item_id: transition.request_item_id.clone(),
258        })
259        .collect();
260    let ids: Vec<Uuid> = moves.iter().map(|(id, _, _)| *id).collect();
261    let to_states: Vec<CreditRegistrationState> = moves
262        .iter()
263        .map(|(_, _, transition)| transition.to_state)
264        .collect();
265    let error_codes: Vec<Option<CreditRegistrationErrorCode>> = moves
266        .iter()
267        .map(|(_, _, transition)| transition.error_code)
268        .collect();
269    let error_messages: Vec<Option<String>> = moves
270        .iter()
271        .map(|(_, _, transition)| transition.error_message.clone())
272        .collect();
273    let needs_admin: Vec<Option<bool>> = moves
274        .iter()
275        .map(|(_, _, transition)| {
276            transition
277                .needs_admin_attention
278                .map(AdminAttention::is_raised)
279        })
280        .collect();
281    let terminal: Vec<bool> = to_states.iter().map(|state| state.is_terminal()).collect();
282    let failure: Vec<bool> = to_states
283        .iter()
284        .map(|state| state.is_failed_state())
285        .collect();
286    let next_attempts: Vec<Option<DateTime<Utc>>> = moves
287        .iter()
288        .map(|(_, _, transition)| transition.next_attempt_at)
289        .collect();
290    let default_delays: Vec<TimeDelta> = to_states
291        .iter()
292        .map(|state| state.default_attempt_delay())
293        .collect();
294    let keeps_checked_at: Vec<bool> = moves
295        .iter()
296        .map(|(_, _, transition)| transition.keeps_enrolment_checked_at)
297        .collect();
298    let keeps_schedule: Vec<bool> = to_states
299        .iter()
300        .map(|state| state.keeps_enrolment_check_schedule())
301        .collect();
302    let enters_another_phase: Vec<bool> = moves
303        .iter()
304        .map(|(_, from_state, transition)| {
305            TimelinePhase::entered_with(*from_state)
306                != TimelinePhase::entered_with(transition.to_state)
307        })
308        .collect();
309    let keeps_waiting_since: Vec<bool> = to_states
310        .iter()
311        .map(|state| {
312            state.keeps_enrolment_check_schedule()
313                && *state != CreditRegistrationState::NoUsableEnrolment
314        })
315        .collect();
316    let written = sqlx::query_as!(
317        CreditRegistration,
318        r#"
319UPDATE credit_registrations cr
320SET state = move.to_state,
321  -- clock_timestamp(), not now(): now() is the transaction timestamp, so several state changes in
322  -- one transaction would share an instant and the timeline would lose their order.
323  state_entered_at = clock_timestamp(),
324  state_changed_at = CASE
325    WHEN cr.state <> move.to_state THEN clock_timestamp()
326    ELSE cr.state_changed_at
327  END,
328  phase_started_at = CASE
329    WHEN move.enters_another_phase THEN clock_timestamp()
330    ELSE cr.phase_started_at
331  END,
332  error_code = move.error_code,
333  error_message = move.error_message,
334  needs_admin_attention = COALESCE(move.needs_admin_attention, cr.needs_admin_attention),
335  -- ELSE NULL: without it an admin retry stays invisible to every terminal_at IS NULL query.
336  terminal_at = CASE
337    WHEN move.terminal THEN COALESCE(cr.terminal_at, now())
338    ELSE NULL
339  END,
340  first_failed_at = CASE
341    WHEN move.failure THEN COALESCE(cr.first_failed_at, now())
342    WHEN move.to_state = 'no_usable_enrolment' THEN NULL
343    ELSE cr.first_failed_at
344  END,
345  submit_retry_count = CASE
346    WHEN move.to_state = 'no_usable_enrolment' THEN 0
347    ELSE cr.submit_retry_count
348  END,
349  registered_at = CASE
350    WHEN move.to_state = 'registered' THEN COALESCE(cr.registered_at, now())
351    ELSE cr.registered_at
352  END,
353  submitted_at = CASE
354    WHEN move.to_state = 'submitting' THEN now()
355    ELSE cr.submitted_at
356  END,
357  enrolment_checked_at = CASE
358    WHEN move.keeps_checked_at THEN cr.enrolment_checked_at
359    WHEN (
360      cr.state = 'resolving_enrolment'
361      OR cr.enrolment_check_claimed_until IS NOT NULL
362    )
363    AND move.to_state IN ('checking_enrolment', 'no_usable_enrolment') THEN now()
364    WHEN cr.state = 'checking_enrolment'
365    AND move.to_state <> 'checking_enrolment' THEN now()
366    ELSE cr.enrolment_checked_at
367  END,
368  -- Only on starting to wait, not on every check that finds no enrolment again.
369  enrolment_banner_dismissed_at = CASE
370    WHEN move.to_state = 'no_usable_enrolment'
371    AND cr.no_usable_enrolment_since IS NULL THEN NULL
372    ELSE cr.enrolment_banner_dismissed_at
373  END,
374  -- A retried lookup passes through the states that keep it on its way back to no_usable_enrolment.
375  no_usable_enrolment_since = CASE
376    WHEN move.to_state = 'no_usable_enrolment' THEN COALESCE(cr.no_usable_enrolment_since, now())
377    WHEN move.keeps_waiting_since THEN cr.no_usable_enrolment_since
378    ELSE NULL
379  END,
380  enrolment_check_anchor_at = CASE
381    WHEN move.keeps_schedule THEN cr.enrolment_check_anchor_at
382  END,
383  enrolment_check_step = CASE
384    WHEN move.keeps_schedule THEN cr.enrolment_check_step
385  END,
386  enrolment_check_due_at = CASE
387    WHEN move.keeps_schedule THEN cr.enrolment_check_due_at
388  END,
389  is_enrolment_check_batched = move.keeps_schedule
390  AND cr.is_enrolment_check_batched,
391  enrolment_checks_stopped_at = CASE
392    WHEN move.keeps_schedule THEN cr.enrolment_checks_stopped_at
393  END,
394  enrolment_check_source = CASE
395    WHEN move.keeps_schedule THEN cr.enrolment_check_source
396    ELSE 'schedule'
397  END,
398  enrolment_check_claimed_until = NULL,
399  next_attempt_at = COALESCE(
400    move.next_attempt_at,
401    now() + move.default_delay
402  )
403FROM UNNEST(
404    $1::uuid [],
405    $2::credit_registration_state [],
406    $3::credit_registration_error_code [],
407    $4::text [],
408    $5::boolean [],
409    $6::boolean [],
410    $7::boolean [],
411    $8::timestamptz [],
412    $9::interval [],
413    $10::boolean [],
414    $11::boolean [],
415    $12::boolean [],
416    $13::boolean []
417  ) AS move(
418    id,
419    to_state,
420    error_code,
421    error_message,
422    needs_admin_attention,
423    terminal,
424    failure,
425    next_attempt_at,
426    default_delay,
427    keeps_checked_at,
428    keeps_schedule,
429    keeps_waiting_since,
430    enters_another_phase
431  )
432WHERE cr.id = move.id
433  AND cr.deleted_at IS NULL
434RETURNING cr.*
435        "#,
436        &ids,
437        &to_states as &[CreditRegistrationState],
438        &error_codes as &[Option<CreditRegistrationErrorCode>],
439        &error_messages as &[Option<String>],
440        &needs_admin as &[Option<bool>],
441        &terminal,
442        &failure,
443        &next_attempts as &[Option<DateTime<Utc>>],
444        &default_delays as &[TimeDelta],
445        &keeps_checked_at,
446        &keeps_schedule,
447        &keeps_waiting_since,
448        &enters_another_phase,
449    )
450    .fetch_all(&mut *conn)
451    .await?;
452
453    let settled: Vec<_> = ids.iter().copied().zip(to_states.iter().copied()).collect();
454    settle_pending_supersessions(&mut *conn, &settled).await?;
455    crate::credit_registration_events::insert_batch(conn, &events).await?;
456    Ok(written)
457}
458
459/// Completes or abandons the pending supersessions of rows that were waiting on these attempts, as
460/// each attempt's new state decides.
461async fn settle_pending_supersessions(
462    conn: &mut PgConnection,
463    moves: &[(Uuid, CreditRegistrationState)],
464) -> ModelResult<()> {
465    let mut completed = Vec::new();
466    let mut abandoned = Vec::new();
467    for &(id, to_state) in moves {
468        match to_state.pending_supersession_effect() {
469            PendingSupersessionEffect::Keep => {}
470            PendingSupersessionEffect::Complete => completed.push(id),
471            PendingSupersessionEffect::Abandon => abandoned.push(id),
472        }
473    }
474    if completed.is_empty() && abandoned.is_empty() {
475        return Ok(());
476    }
477    sqlx::query!(
478        r#"
479UPDATE credit_registrations
480SET superseded_by_id = CASE
481    WHEN pending_superseded_by_id = ANY($1::uuid []) THEN pending_superseded_by_id
482    ELSE superseded_by_id
483  END,
484  superseded_at = CASE
485    WHEN pending_superseded_by_id = ANY($1::uuid []) THEN now()
486    ELSE superseded_at
487  END,
488  pending_superseded_by_id = NULL
489WHERE (
490    pending_superseded_by_id = ANY($1::uuid [])
491    OR pending_superseded_by_id = ANY($2::uuid [])
492  )
493  AND deleted_at IS NULL
494        "#,
495        &completed,
496        &abandoned,
497    )
498    .execute(conn)
499    .await?;
500    Ok(())
501}
502
503/// Refuses an edge outside the policy. The one place (from → to) legality is decided, for the
504/// single-row [`transition`] and the batched [`transition_batch`] alike.
505fn check_edge(
506    id: Uuid,
507    from: CreditRegistrationState,
508    to: CreditRegistrationState,
509    policy: TransitionPolicy,
510) -> ModelResult<()> {
511    // Staying put is not a move: the verify poller rewrites its own state on every poll.
512    if from == to || policy.allows(from, to) {
513        return Ok(());
514    }
515    Err(model_err!(
516        InvalidRequest,
517        format!("Credit registration {id} may not move from {from:?} to {to:?} under {policy:?}.")
518    ))
519}
520
521/// One row's move in a [`transition_batch`].
522#[derive(Debug, Clone, PartialEq)]
523pub struct BatchMove {
524    pub id: Uuid,
525    pub transition: Transition,
526}
527
528/// [`transition`] for a whole batch: one lock, one update, one insert of events, whatever the size.
529///
530/// For the phases that decide many rows from one query and have no per-row exchange to record.
531/// Same edge table and policy as [`transition`], and the same event rows; the one difference is
532/// that a row whose state no longer matches `expected_from_state` is left alone rather than
533/// refused, since a batch has no single caller to hand the refusal to. Returns how many moved.
534pub async fn transition_batch(conn: &mut PgConnection, moves: &[BatchMove]) -> ModelResult<i64> {
535    if moves.is_empty() {
536        return Ok(0);
537    }
538    let mut tx = conn.begin().await?;
539    let ids: Vec<Uuid> = moves.iter().map(|batch_move| batch_move.id).collect();
540    let locked = lock_for_moves(&mut tx, &ids).await?;
541    let mut writes = Vec::new();
542    for batch_move in moves {
543        let Some(&from) = locked.get(&batch_move.id) else {
544            continue;
545        };
546        if batch_move
547            .transition
548            .expected_from_state
549            .is_some_and(|expected| expected != from)
550        {
551            continue;
552        }
553        check_edge(
554            batch_move.id,
555            from,
556            batch_move.transition.to_state,
557            batch_move.transition.policy,
558        )?;
559        writes.push((batch_move.id, from, &batch_move.transition));
560    }
561    if !writes.is_empty() {
562        write_moves(&mut tx, &writes).await?;
563    }
564    tx.commit().await?;
565    Ok(i64::try_from(writes.len()).unwrap_or(i64::MAX))
566}
567
568/// States of a row with nothing sent to Sisu, provided it has no submitted attainment: a
569/// `failed_retryable` row with one resumes at verification.
570const UNSENT_STATES: [CreditRegistrationState; 7] = [
571    CreditRegistrationState::Pending,
572    CreditRegistrationState::ReadyToSubmit,
573    CreditRegistrationState::ResolvingEnrolment,
574    CreditRegistrationState::CheckingEnrolment,
575    CreditRegistrationState::NoUsableEnrolment,
576    CreditRegistrationState::Blocked,
577    CreditRegistrationState::FailedRetryable,
578];
579
580/// Cancels the module's registrations that have not been sent to Sisu, for when a teacher stops
581/// its completions being registered there. Rows Sisu may already hold are left alone. Returns how
582/// many moved.
583pub async fn cancel_unsent_for_course_module(
584    conn: &mut PgConnection,
585    course_module_id: Uuid,
586    actor_user_id: Uuid,
587) -> ModelResult<i64> {
588    let rows = sqlx::query!(
589        r#"
590SELECT id,
591  state
592FROM credit_registrations
593WHERE course_module_id = $1
594  AND state = ANY($2::credit_registration_state [])
595  AND submitted_attainment_id IS NULL
596  AND superseded_by_id IS NULL
597  AND deleted_at IS NULL
598        "#,
599        course_module_id,
600        &UNSENT_STATES as &[CreditRegistrationState],
601    )
602    .fetch_all(&mut *conn)
603    .await?;
604    let moves: Vec<BatchMove> = rows
605        .into_iter()
606        .map(|row| BatchMove {
607            id: row.id,
608            transition: Transition {
609                needs_admin_attention: Some(AdminAttention::Clear),
610                event_message: Some(
611                    "Completions of this module are no longer registered to Sisu.".to_string(),
612                ),
613                actor_user_id: Some(actor_user_id),
614                // Read without a lock: a row a phase has moved on since is that phase's.
615                expected_from_state: Some(row.state),
616                ..Transition::to(CreditRegistrationState::Cancelled)
617            },
618        })
619        .collect();
620    transition_batch(conn, &moves).await
621}