Skip to main content

headless_lms_credit_registration/
phase.rs

1//! Which phases exist, which process runs each, and what each may be narrowed on.
2
3use headless_lms_models::credit_registrations::CreditRegistrationState;
4use headless_lms_models::credit_registrations::RegistrationScope;
5use headless_lms_models::library::credit_registration::study_registry::RegistryOperation;
6use headless_lms_models::suotar_circuit_breakers::BreakerTarget;
7
8/// A pipeline phase. [`CreditRegistrationPhase::as_str`] is canonical: it is
9/// `credit_registration_phase_state.phase`, the tick endpoint's `?phase=` and the audit log's
10/// `target_phase`.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum CreditRegistrationPhase {
13    Materialize,
14    Preconditions,
15    ResolveEnrolments,
16    Import,
17    Verify,
18    LegacyMirror,
19    StudentNotifications,
20    EnrolmentDiscovery,
21    LinkEmails,
22    ConfigValidation,
23    RetentionSweep,
24    LedgerSnapshot,
25}
26
27/// The worker process that runs a phase's loop. The two are separate OS processes, each with its
28/// own circuit breakers and limiter.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum WorkerProcess {
31    /// Owns the ledger.
32    CreditRegistrar,
33    /// Owns everything about credit registration except the ledger.
34    SuotarSyncer,
35}
36
37impl WorkerProcess {
38    pub const ALL: [Self; 2] = [Self::CreditRegistrar, Self::SuotarSyncer];
39
40    /// `credit_registration_phase_state.process_name`, and the caller the audit log names.
41    pub fn as_str(self) -> &'static str {
42        match self {
43            Self::CreditRegistrar => "credit-registrar",
44            Self::SuotarSyncer => "suotar-syncer",
45        }
46    }
47}
48
49/// Everything fixed about one phase, declared in one place.
50#[derive(Debug)]
51pub struct PhaseSpec {
52    /// See [`CreditRegistrationPhase::as_str`].
53    pub name: &'static str,
54    pub process: WorkerProcess,
55    pub scope: ScopeSupport,
56    /// What one iteration asks of the study registry, one after the other. Empty for a
57    /// database-only phase, which no breaker ever holds back.
58    /// `study_registry_endpoints` names them as Suotar endpoints.
59    pub operations: &'static [RegistryOperation],
60    /// The circuit breakers that pause the phase.
61    pub breakers: &'static [BreakerTarget],
62    /// The ledger states this phase is the one to move a row out of; see
63    /// [`CreditRegistrationPhase::owned_states`].
64    pub owned_states: &'static [CreditRegistrationState],
65    /// The phase does nothing but account linking, and so is skipped while the deployment has
66    /// linking switched off. `enrolment-discovery` is not one: with linking off it still wakes
67    /// linked students' registrations and only leaves out the mails.
68    pub is_account_linking_only: bool,
69    /// Sends attainments, so sits out the nightly
70    /// [`sisu_day_gap`](headless_lms_models::library::credit_registration::sisu_day_gap).
71    pub waits_out_sisu_day_gap: bool,
72}
73
74/// What a spec below leaves as it is: a `credit-registrar` phase over ledger rows that calls no
75/// study registry.
76const DEFAULTS: PhaseSpec = PhaseSpec {
77    name: "",
78    process: WorkerProcess::CreditRegistrar,
79    scope: ScopeSupport::LEDGER,
80    operations: &[],
81    breakers: &[],
82    owned_states: &[],
83    is_account_linking_only: false,
84    waits_out_sisu_day_gap: false,
85};
86
87const STUDY_REGISTRY: &[BreakerTarget] = &[BreakerTarget::StudyRegistry];
88
89/// These reach their rows through the course module, which has no user dimension: a roster and a
90/// module configuration are facts about a course, not about one of our accounts.
91const COURSE_MODULES: ScopeSupport = ScopeSupport {
92    course: true,
93    user: false,
94    registration_ids: false,
95};
96
97const MATERIALIZE: PhaseSpec = PhaseSpec {
98    name: "materialize",
99    // No ledger row exists yet, so there is no registration id to narrow on.
100    scope: ScopeSupport {
101        course: true,
102        user: true,
103        registration_ids: false,
104    },
105    ..DEFAULTS
106};
107const PRECONDITIONS: PhaseSpec = PhaseSpec {
108    name: "preconditions",
109    owned_states: &[
110        CreditRegistrationState::Pending,
111        CreditRegistrationState::FailedRetryable,
112        CreditRegistrationState::Blocked,
113    ],
114    ..DEFAULTS
115};
116const RESOLVE_ENROLMENTS: PhaseSpec = PhaseSpec {
117    name: "resolve-enrolments",
118    // The person lookup for links that lack one, then the enrolment lookup.
119    operations: &[
120        RegistryOperation::ResolvePersons,
121        RegistryOperation::ResolveEnrolments,
122    ],
123    breakers: STUDY_REGISTRY,
124    owned_states: &[
125        CreditRegistrationState::ReadyToSubmit,
126        CreditRegistrationState::ResolvingEnrolment,
127        CreditRegistrationState::NoUsableEnrolment,
128    ],
129    ..DEFAULTS
130};
131const IMPORT: PhaseSpec = PhaseSpec {
132    name: "import",
133    operations: &[RegistryOperation::ImportAttainments],
134    // Sisu timing out on submissions says nothing about the rest of Suotar, so only this phase
135    // stops for it.
136    breakers: &[BreakerTarget::StudyRegistry, BreakerTarget::SisuSubmissions],
137    owned_states: &[
138        CreditRegistrationState::CheckingEnrolment,
139        CreditRegistrationState::Submitting,
140    ],
141    waits_out_sisu_day_gap: true,
142    ..DEFAULTS
143};
144const VERIFY: PhaseSpec = PhaseSpec {
145    name: "verify",
146    // The poll, then the recovery lookup for rows with nothing to poll by.
147    operations: &[
148        RegistryOperation::VerifyAttainments,
149        RegistryOperation::ResolveEnrolments,
150    ],
151    breakers: STUDY_REGISTRY,
152    owned_states: &[
153        CreditRegistrationState::AwaitingVerification,
154        CreditRegistrationState::PartiallyRegistered,
155        CreditRegistrationState::SubmissionUncertain,
156    ],
157    ..DEFAULTS
158};
159const LEGACY_MIRROR: PhaseSpec = PhaseSpec {
160    name: "legacy-mirror",
161    ..DEFAULTS
162};
163const STUDENT_NOTIFICATIONS: PhaseSpec = PhaseSpec {
164    name: "student-notifications",
165    ..DEFAULTS
166};
167const ENROLMENT_DISCOVERY: PhaseSpec = PhaseSpec {
168    name: "enrolment-discovery",
169    process: WorkerProcess::SuotarSyncer,
170    scope: COURSE_MODULES,
171    operations: &[RegistryOperation::ListCourseRoster],
172    breakers: STUDY_REGISTRY,
173    ..DEFAULTS
174};
175const LINK_EMAILS: PhaseSpec = PhaseSpec {
176    name: "link-emails",
177    process: WorkerProcess::SuotarSyncer,
178    scope: COURSE_MODULES,
179    is_account_linking_only: true,
180    ..DEFAULTS
181};
182const CONFIG_VALIDATION: PhaseSpec = PhaseSpec {
183    name: "config-validation",
184    process: WorkerProcess::SuotarSyncer,
185    scope: COURSE_MODULES,
186    operations: &[RegistryOperation::ValidateCourseCodes],
187    breakers: STUDY_REGISTRY,
188    ..DEFAULTS
189};
190const RETENTION_SWEEP: PhaseSpec = PhaseSpec {
191    name: "retention-sweep",
192    process: WorkerProcess::SuotarSyncer,
193    // Sweeps whole tables by age; there is nothing in them to narrow on.
194    scope: ScopeSupport::NONE,
195    ..DEFAULTS
196};
197const LEDGER_SNAPSHOT: PhaseSpec = PhaseSpec {
198    name: "ledger-snapshot",
199    process: WorkerProcess::SuotarSyncer,
200    // Counts every row in the ledger for the day; a scoped run would write that as if it were
201    // everyone's snapshot.
202    scope: ScopeSupport::NONE,
203    ..DEFAULTS
204};
205
206impl CreditRegistrationPhase {
207    /// Every phase, in pipeline order.
208    pub const ALL: [Self; 12] = [
209        Self::Materialize,
210        Self::Preconditions,
211        Self::ResolveEnrolments,
212        Self::Import,
213        Self::Verify,
214        Self::LegacyMirror,
215        Self::StudentNotifications,
216        Self::EnrolmentDiscovery,
217        Self::LinkEmails,
218        Self::ConfigValidation,
219        Self::RetentionSweep,
220        Self::LedgerSnapshot,
221    ];
222
223    /// Everything fixed about the phase.
224    pub fn spec(self) -> &'static PhaseSpec {
225        match self {
226            Self::Materialize => &MATERIALIZE,
227            Self::Preconditions => &PRECONDITIONS,
228            Self::ResolveEnrolments => &RESOLVE_ENROLMENTS,
229            Self::Import => &IMPORT,
230            Self::Verify => &VERIFY,
231            Self::LegacyMirror => &LEGACY_MIRROR,
232            Self::StudentNotifications => &STUDENT_NOTIFICATIONS,
233            Self::EnrolmentDiscovery => &ENROLMENT_DISCOVERY,
234            Self::LinkEmails => &LINK_EMAILS,
235            Self::ConfigValidation => &CONFIG_VALIDATION,
236            Self::RetentionSweep => &RETENTION_SWEEP,
237            Self::LedgerSnapshot => &LEDGER_SNAPSHOT,
238        }
239    }
240
241    /// The phase's canonical name; see [`CreditRegistrationPhase`].
242    pub fn as_str(self) -> &'static str {
243        self.spec().name
244    }
245
246    /// The phase [`Self::as_str`] names `name`, if any.
247    pub fn from_phase_name(name: &str) -> Option<Self> {
248        Self::ALL.into_iter().find(|phase| phase.as_str() == name)
249    }
250
251    /// Where the phase sits in [`Self::ALL`]'s pipeline order, for sorting phases by it.
252    pub fn pipeline_index(self) -> usize {
253        Self::ALL
254            .iter()
255            .position(|phase| *phase == self)
256            .unwrap_or(Self::ALL.len())
257    }
258
259    /// The ledger states this phase is the one to move a row out of.
260    ///
261    /// Empty for the phases whose work is not a ledger state at all: `materialize`'s queue is
262    /// completions with no row yet, and the syncer's phases work on course modules. Narrower than
263    /// what `preconditions` may claim, which is every non-terminal row: these are the states nothing
264    /// else advances. How many of their rows are waiting on the phase is [`Self::queue_depth`].
265    pub fn owned_states(self) -> &'static [CreditRegistrationState] {
266        self.spec().owned_states
267    }
268
269    /// The live rows waiting on this phase: the Workers tab's "queue depth it is responsible for",
270    /// and the depth the failing-phase alert asks about before calling a quiet phase wedged.
271    ///
272    /// `depth_of` is the live count of a state. `due_enrolment_checks` stands in for
273    /// `no_usable_enrolment`, whose other rows wait for their schedule rather than for the phase.
274    pub fn queue_depth(
275        self,
276        depth_of: impl Fn(CreditRegistrationState) -> i64,
277        due_enrolment_checks: i64,
278    ) -> i64 {
279        self.owned_states()
280            .iter()
281            .map(|&state| {
282                if state == CreditRegistrationState::NoUsableEnrolment {
283                    due_enrolment_checks
284                } else {
285                    depth_of(state)
286                }
287            })
288            .sum()
289    }
290}
291
292/// Which of the scope's dimensions a phase's claim query can apply. Declared rather than assumed,
293/// so a phase added later cannot quietly ignore a scope and sweep the whole database.
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
295pub struct ScopeSupport {
296    pub course: bool,
297    pub user: bool,
298    pub registration_ids: bool,
299}
300
301impl ScopeSupport {
302    pub const NONE: Self = Self {
303        course: false,
304        user: false,
305        registration_ids: false,
306    };
307    /// The phases that claim ledger rows, which carry all three keys themselves.
308    pub const LEDGER: Self = Self {
309        course: true,
310        user: true,
311        registration_ids: true,
312    };
313
314    pub(crate) fn covers(self, scope: &RegistrationScope) -> bool {
315        let requested_unsupported = (scope.course_id.is_some() && !self.course)
316            || (scope.user_id.is_some() && !self.user)
317            || (!scope.credit_registration_ids.is_empty() && !self.registration_ids);
318        !requested_unsupported
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use headless_lms_models::credit_registration_phase_state::PHASES;
325    use uuid::Uuid;
326
327    use super::*;
328
329    /// A mismatch with the seeded `credit_registration_phase_state` rows makes a tick or a
330    /// heartbeat silently target a row that does not exist.
331    #[test]
332    fn phase_names_match_the_seeded_rows() {
333        let from_enum: Vec<&str> = CreditRegistrationPhase::ALL
334            .iter()
335            .map(|phase| phase.as_str())
336            .collect();
337        assert_eq!(from_enum, PHASES);
338        assert_eq!(from_enum.len(), 12);
339    }
340
341    /// A phase that cannot honour the narrowing it was handed has to say so, or a caller that
342    /// believes it narrowed the run gets a silently wrong answer.
343    #[test]
344    fn a_phase_refuses_a_scope_it_cannot_apply() {
345        let ids = RegistrationScope {
346            credit_registration_ids: vec![Uuid::new_v4()],
347            ..RegistrationScope::default()
348        };
349        assert!(
350            !CreditRegistrationPhase::Materialize
351                .spec()
352                .scope
353                .covers(&ids)
354        );
355        assert!(CreditRegistrationPhase::Import.spec().scope.covers(&ids));
356        assert!(
357            !CreditRegistrationPhase::RetentionSweep
358                .spec()
359                .scope
360                .covers(&RegistrationScope::for_course(Uuid::new_v4()))
361        );
362    }
363}