Skip to main content

headless_lms_server/domain/
credit_registration_phases.rs

1//! The twelve credit-registration pipeline phases and the one-iteration dispatcher.
2//!
3//! Phases, not worker processes, are the unit of observation and control: the dashboard lists them,
4//! `credit_registration_phase_state` heartbeats them, and the system tests tick them individually.
5//! Both the worker loops and the test tick endpoint go through [`run_phase_once`], so a phase cannot
6//! behave differently depending on who ran it.
7
8use headless_lms_models::credit_registration_phase_state::PhaseRunOutcome;
9use sqlx::PgPool;
10
11/// A pipeline phase. The string forms are canonical: `credit_registration_phase_state.phase`, the
12/// tick endpoint's `?phase=`, the dashboard's Workers tab labels and the audit log's `target_phase`.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
14pub enum CreditRegistrationPhase {
15    Materialize,
16    Preconditions,
17    ResolveEnrolments,
18    Import,
19    Verify,
20    LegacyMirror,
21    StudentNotifications,
22    EnrolmentDiscovery,
23    LinkEmails,
24    ProductTokenRefresh,
25    ConfigValidation,
26    RetentionSweep,
27}
28
29impl CreditRegistrationPhase {
30    /// Every phase, in pipeline order.
31    pub const ALL: [Self; 12] = [
32        Self::Materialize,
33        Self::Preconditions,
34        Self::ResolveEnrolments,
35        Self::Import,
36        Self::Verify,
37        Self::LegacyMirror,
38        Self::StudentNotifications,
39        Self::EnrolmentDiscovery,
40        Self::LinkEmails,
41        Self::ProductTokenRefresh,
42        Self::ConfigValidation,
43        Self::RetentionSweep,
44    ];
45
46    /// The phases `run-registrar-tick` runs, in pipeline order.
47    ///
48    /// Not every `credit-registrar` phase: `legacy-mirror` and `student-notifications` are
49    /// after-effects a spec drives explicitly when it cares about them.
50    pub const REGISTRAR_TICK_SEQUENCE: [Self; 5] = [
51        Self::Materialize,
52        Self::Preconditions,
53        Self::ResolveEnrolments,
54        Self::Import,
55        Self::Verify,
56    ];
57
58    pub fn as_str(self) -> &'static str {
59        match self {
60            Self::Materialize => "materialize",
61            Self::Preconditions => "preconditions",
62            Self::ResolveEnrolments => "resolve-enrolments",
63            Self::Import => "import",
64            Self::Verify => "verify",
65            Self::LegacyMirror => "legacy-mirror",
66            Self::StudentNotifications => "student-notifications",
67            Self::EnrolmentDiscovery => "enrolment-discovery",
68            Self::LinkEmails => "link-emails",
69            Self::ProductTokenRefresh => "product-token-refresh",
70            Self::ConfigValidation => "config-validation",
71            Self::RetentionSweep => "retention-sweep",
72        }
73    }
74
75    pub fn from_phase_name(name: &str) -> Option<Self> {
76        Self::ALL.into_iter().find(|phase| phase.as_str() == name)
77    }
78
79    /// Which worker process owns the phase's loop.
80    pub fn process_name(self) -> &'static str {
81        match self {
82            Self::Materialize
83            | Self::Preconditions
84            | Self::ResolveEnrolments
85            | Self::Import
86            | Self::Verify
87            | Self::LegacyMirror
88            | Self::StudentNotifications => "credit-registrar",
89            Self::EnrolmentDiscovery
90            | Self::LinkEmails
91            | Self::ProductTokenRefresh
92            | Self::ConfigValidation
93            | Self::RetentionSweep => "suotar-syncer",
94        }
95    }
96}
97
98/// What one dispatch attempt did.
99#[derive(Debug, Clone, PartialEq)]
100pub enum PhaseTick {
101    Ran(PhaseRunOutcome),
102    NotImplemented,
103}
104
105/// Runs exactly one iteration of one phase.
106///
107/// The match below is the single place a phase implementation is registered: the tick endpoint, the
108/// worker loops and the dashboard all run phases through here.
109///
110/// Takes the pool rather than a connection so an unimplemented phase costs no connection.
111pub async fn run_phase_once(
112    _pool: &PgPool,
113    phase: CreditRegistrationPhase,
114) -> anyhow::Result<PhaseTick> {
115    match phase {
116        CreditRegistrationPhase::Materialize
117        | CreditRegistrationPhase::Preconditions
118        | CreditRegistrationPhase::ResolveEnrolments
119        | CreditRegistrationPhase::Import
120        | CreditRegistrationPhase::Verify
121        | CreditRegistrationPhase::LegacyMirror
122        | CreditRegistrationPhase::StudentNotifications
123        | CreditRegistrationPhase::EnrolmentDiscovery
124        | CreditRegistrationPhase::LinkEmails
125        | CreditRegistrationPhase::ProductTokenRefresh
126        | CreditRegistrationPhase::ConfigValidation
127        | CreditRegistrationPhase::RetentionSweep => Ok(PhaseTick::NotImplemented),
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use headless_lms_models::credit_registration_phase_state::PHASES;
134
135    use super::*;
136
137    /// A mismatch with the rows the migration seeded into `credit_registration_phase_state` makes a
138    /// tick or a heartbeat silently target a row that does not exist.
139    #[test]
140    fn phase_names_match_the_seeded_rows() {
141        let from_enum: Vec<&str> = CreditRegistrationPhase::ALL
142            .iter()
143            .map(|phase| phase.as_str())
144            .collect();
145        assert_eq!(from_enum, PHASES);
146        assert_eq!(from_enum.len(), 12);
147    }
148
149    #[test]
150    fn phase_names_round_trip() {
151        for phase in CreditRegistrationPhase::ALL {
152            assert_eq!(
153                CreditRegistrationPhase::from_phase_name(phase.as_str()),
154                Some(phase)
155            );
156        }
157        assert_eq!(
158            CreditRegistrationPhase::from_phase_name("materialise"),
159            None
160        );
161        assert_eq!(CreditRegistrationPhase::from_phase_name(""), None);
162    }
163}