Skip to main content

headless_lms_credit_registration/workflow/
counts.rs

1//! What a phase iteration did, as the phase-state row and the batch summaries report it.
2
3use uuid::Uuid;
4
5use super::decision::Applied;
6
7/// What a phase body did. Composite phases add up their flows' counts with `+=`.
8///
9/// `failed` and `waiting` are disjoint parts of `processed`: each is counted only with the row, or
10/// module, it describes.
11#[derive(Debug, Default)]
12pub(crate) struct Counts {
13    processed: i32,
14    /// Rows written with an answer that only means "not yet", such as an enrolment not made.
15    waiting: i32,
16    failed: i32,
17    /// Rows another writer moved on before their answer could be written, which are not processed.
18    moved_on: i32,
19    finding: Option<String>,
20}
21
22impl Counts {
23    /// A clean iteration that moved `count` rows; saturating, so an over-large sweep never reaches
24    /// the dashboard as negative throughput.
25    pub(crate) fn processed(count: i64) -> Self {
26        Self {
27            processed: count.try_into().unwrap_or(i32::MAX),
28            ..Self::default()
29        }
30    }
31
32    /// `failed` of the `processed` rows, or modules, ended up carrying an error.
33    pub(crate) fn processed_with_failures(processed: i32, failed: i32) -> Self {
34        debug_assert!(failed <= processed, "{failed} failed of {processed}");
35        Self {
36            processed,
37            failed,
38            ..Self::default()
39        }
40    }
41
42    pub(crate) fn all_failed(count: i32) -> Self {
43        Self::processed_with_failures(count, count)
44    }
45
46    /// Something the phase found wrong that failed no row, such as a missing mail template: the
47    /// iteration's error when no request failed.
48    pub(crate) fn with_finding(self, finding: Option<String>) -> Self {
49        Self { finding, ..self }
50    }
51
52    /// One row a decision was written for without asking the registry.
53    pub(crate) fn record_decided(&mut self, is_failure: bool) {
54        self.record_verdict(is_failure, false);
55    }
56
57    fn record_verdict(&mut self, is_failure: bool, is_waiting: bool) {
58        self.processed += 1;
59        self.failed += i32::from(is_failure);
60        self.waiting += i32::from(is_waiting);
61    }
62
63    /// One answered or refused row's write. A row that had already moved on is only logged at debug:
64    /// the batch summary reports it, and a study registry outage can make it routine.
65    pub(crate) fn record_applied(&mut self, registration_id: Uuid, applied: Applied) {
66        match applied {
67            Applied::Written {
68                is_failure,
69                is_waiting,
70            } => self.record_verdict(is_failure, is_waiting),
71            Applied::MovedOn { found } => {
72                self.moved_on += 1;
73                debug!(
74                    credit_registration_id = %registration_id,
75                    found_state = ?found,
76                    "Credit registration moved on while the study registry answered; leaving it"
77                );
78            }
79        }
80    }
81
82    /// The rows, or modules, the iteration wrote a decision for.
83    pub(crate) fn processed_count(&self) -> i32 {
84        self.processed
85    }
86
87    /// How many of the processed ones ended up waiting for something, which is no failure.
88    pub(crate) fn waiting_count(&self) -> i32 {
89        self.waiting
90    }
91
92    /// How many of the processed ones ended up carrying an error code other than a waiting one.
93    pub(crate) fn failed_count(&self) -> i32 {
94        self.failed
95    }
96
97    pub(crate) fn moved_on_count(&self) -> i32 {
98        self.moved_on
99    }
100
101    pub(crate) fn into_finding(self) -> Option<String> {
102        self.finding
103    }
104}
105
106impl std::ops::AddAssign for Counts {
107    fn add_assign(&mut self, other: Self) {
108        self.processed += other.processed;
109        self.waiting += other.waiting;
110        self.failed += other.failed;
111        self.moved_on += other.moved_on;
112        self.finding = self.finding.take().or(other.finding);
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use headless_lms_models::credit_registrations::CreditRegistrationState as State;
119
120    use super::*;
121
122    fn written(is_failure: bool) -> Applied {
123        Applied::Written {
124            is_failure,
125            is_waiting: false,
126        }
127    }
128
129    const MOVED_ON: Applied = Applied::MovedOn {
130        found: State::Registered,
131    };
132
133    #[test]
134    fn a_moved_on_row_is_counted_apart_from_the_processed_ones() {
135        let mut counts = Counts::default();
136        for applied in [written(false), written(true), MOVED_ON, MOVED_ON] {
137            counts.record_applied(Uuid::new_v4(), applied);
138        }
139        assert_eq!(counts.processed_count(), 2);
140        assert_eq!(counts.failed_count(), 1);
141        assert_eq!(counts.moved_on_count(), 2);
142    }
143
144    #[test]
145    fn failures_never_outnumber_processed_rows() {
146        let steps = [Some(true), None, Some(false), Some(true), None, Some(true)];
147        let mut counts = Counts::default();
148        for step in steps {
149            match step {
150                Some(is_failure) => counts.record_decided(is_failure),
151                None => counts.record_applied(Uuid::new_v4(), MOVED_ON),
152            }
153            assert!(counts.failed_count() <= counts.processed_count());
154        }
155        assert_eq!(counts.processed_count(), 4);
156        assert_eq!(counts.failed_count(), 3);
157    }
158
159    #[test]
160    fn adding_counts_sums_them_and_keeps_the_first_finding() {
161        let mut counts = Counts::processed_with_failures(3, 1).with_finding(None);
162        let mut moved = Counts::all_failed(2).with_finding(Some("second".to_string()));
163        moved.record_applied(Uuid::new_v4(), MOVED_ON);
164        counts += moved;
165        counts += Counts::processed(4).with_finding(Some("third".to_string()));
166        assert_eq!(counts.processed_count(), 9);
167        assert_eq!(counts.failed_count(), 3);
168        assert_eq!(counts.moved_on_count(), 1);
169        assert_eq!(counts.into_finding().as_deref(), Some("second"));
170    }
171
172    #[test]
173    fn an_oversized_sweep_saturates_instead_of_going_negative() {
174        assert_eq!(Counts::processed(i64::MAX).processed_count(), i32::MAX);
175    }
176}