Skip to main content

headless_lms_credit_registration/runtime/suotar/
mod.rs

1//! The study registry over Suotar, for phase iterations and for manual actions: the only part of
2//! the pipeline that names Suotar's wire items and codes, or sends through its client.
3
4mod breaker;
5mod codes;
6#[cfg(test)]
7mod contract_tests;
8mod course_codes;
9mod decode;
10mod encode;
11mod executor;
12mod gate;
13mod health_report;
14mod rate_limit;
15mod rosters;
16
17pub use breaker::is_waiting_to_probe;
18pub use codes::is_waiting_item;
19pub(super) use health_report::{report_breakers, report_rate_limits};
20
21use headless_lms_models::library::credit_registration::study_registry::RosterPerson;
22use headless_lms_models::suotar_api_calls::SuotarEndpoint;
23use headless_lms_models::suotar_circuit_breakers::BreakerTarget;
24use headless_lms_utils::services::suotar::{
25    INTERACTIVE_REQUEST_TIMEOUT, SuotarCallContext, SuotarClient, endpoints, new_request_item_id,
26};
27use itertools::Itertools;
28use tracing::{Instrument, Span};
29use uuid::Uuid;
30
31use crate::phase::CreditRegistrationPhase;
32use crate::registry::{
33    AttainmentSubmission, BatchEntry, BatchOptions, BatchReply, CourseCode, CourseCodeVerdicts,
34    EnrolmentAnswer, EnrolmentLookup, ImportAnswer, InteractiveStudyRegistry, PersonAnswer,
35    PersonLookup, PersonLookupError, RegistryError, RegistryOperation, RegistryPerson, RosterCode,
36    RosterListing, RosterSearch, StudentNumber, StudyRegistry, VerificationAnswer,
37    VerificationRequest,
38};
39use crate::runtime::PhaseSkipReason;
40use crate::runtime::process_local::ScopeKey;
41use gate::StudyRegistryGate;
42use headless_lms_models::credit_registrations::RegistrationScope;
43
44/// Forgets the limiter state of `scope`, so its endpoints are back at full rate with a full burst.
45pub fn reset_rate_limits(scope: &RegistrationScope) {
46    rate_limit::reset(&ScopeKey::of(scope));
47}
48
49/// The Suotar endpoint that serves `operation`, which is also what the call log, the limiter and the
50/// dashboard key on.
51pub(super) fn endpoint_of(operation: RegistryOperation) -> SuotarEndpoint {
52    match operation {
53        RegistryOperation::ResolvePersons => SuotarEndpoint::ResolvePersons,
54        RegistryOperation::ResolveEnrolments => SuotarEndpoint::ResolveEnrolments,
55        RegistryOperation::ImportAttainments => SuotarEndpoint::ImportAttainments,
56        RegistryOperation::VerifyAttainments => SuotarEndpoint::VerifyAttainments,
57        RegistryOperation::ListCourseRoster => SuotarEndpoint::ListByCourse,
58        RegistryOperation::ValidateCourseCodes => SuotarEndpoint::ValidateCourseCodes,
59    }
60}
61
62/// The Suotar endpoints one iteration of `phase` calls, in order: its
63/// [`crate::PhaseSpec::operations`] as the call log and the dashboard name them.
64fn study_registry_endpoints(
65    phase: CreditRegistrationPhase,
66) -> impl Iterator<Item = SuotarEndpoint> {
67    phase
68        .spec()
69        .operations
70        .iter()
71        .map(|&operation| endpoint_of(operation))
72}
73
74/// The Suotar endpoints whose phases `process_name`'s `target` breaker pauses, each once.
75pub fn endpoints_paused_by(process_name: &str, target: BreakerTarget) -> Vec<SuotarEndpoint> {
76    CreditRegistrationPhase::ALL
77        .into_iter()
78        .filter(|phase| {
79            let spec = phase.spec();
80            spec.process.as_str() == process_name && spec.breakers.contains(&target)
81        })
82        .flat_map(study_registry_endpoints)
83        .unique()
84        .collect()
85}
86
87/// The longest one iteration of `phase` may wait on the study registry before its calls time out.
88pub fn max_study_registry_wait(phase: CreditRegistrationPhase) -> std::time::Duration {
89    study_registry_endpoints(phase)
90        .map(SuotarEndpoint::request_timeout)
91        .sum()
92}
93
94/// The span every request to `endpoint` runs in, whichever of the adapter's call sites sends it.
95/// A resent half records `resent_half` on it.
96fn request_span(endpoint: SuotarEndpoint, items: usize) -> Span {
97    debug_span!(
98        "study_registry_request",
99        ?endpoint,
100        items,
101        resent_half = false
102    )
103}
104
105/// The [`StudyRegistry`] over Suotar for one phase iteration: every request is spent through, and
106/// recorded in, the iteration's gate.
107pub(super) struct SuotarStudyRegistry<'a> {
108    client: &'a SuotarClient,
109    /// The audit log's `worker_name` for every call.
110    worker_name: String,
111    gate: StudyRegistryGate,
112    /// Replaces the endpoints' own timeouts, which run to minutes.
113    #[cfg(test)]
114    request_timeout: Option<std::time::Duration>,
115}
116
117impl<'a> SuotarStudyRegistry<'a> {
118    /// The registry one phase iteration sends through, or why the iteration waits.
119    pub(super) fn admit(
120        client: &'a SuotarClient,
121        worker_name: String,
122        phase: CreditRegistrationPhase,
123        scope: &RegistrationScope,
124        test_mode: bool,
125    ) -> Result<Self, PhaseSkipReason> {
126        Ok(Self {
127            client,
128            worker_name,
129            gate: StudyRegistryGate::admit(phase, scope, test_mode)?,
130            #[cfg(test)]
131            request_timeout: None,
132        })
133    }
134
135    /// Applies what the iteration's calls said to the breakers and the limiter, and returns the
136    /// iteration's error, if any.
137    pub(super) fn finish(self) -> Option<String> {
138        self.gate.settle()
139    }
140
141    fn call_context(&self, registration_ids: Vec<Uuid>) -> SuotarCallContext {
142        SuotarCallContext {
143            worker_name: self.worker_name.clone(),
144            credit_registration_ids: registration_ids,
145            #[cfg(test)]
146            request_timeout: self.request_timeout,
147            #[cfg(not(test))]
148            request_timeout: None,
149        }
150    }
151}
152
153impl StudyRegistry for SuotarStudyRegistry<'_> {
154    fn allowance(&self, operation: RegistryOperation) -> usize {
155        self.gate.allowance(endpoint_of(operation))
156    }
157
158    fn roster_request_size(&self) -> usize {
159        if self.gate.is_probe() {
160            1
161        } else {
162            SuotarEndpoint::ListByCourse.max_batch_size()
163        }
164    }
165
166    async fn resolve_persons<K>(
167        &mut self,
168        entries: Vec<BatchEntry<K, PersonLookup>>,
169        options: BatchOptions,
170    ) -> BatchReply<K, PersonLookup, PersonAnswer> {
171        executor::send_batch::<endpoints::ResolvePersons, _, _, _>(
172            self,
173            entries,
174            options,
175            encode::person_lookup_item,
176            decode::person_answer,
177        )
178        .await
179    }
180
181    async fn resolve_enrolments<K>(
182        &mut self,
183        entries: Vec<BatchEntry<K, EnrolmentLookup>>,
184        options: BatchOptions,
185    ) -> BatchReply<K, EnrolmentLookup, EnrolmentAnswer> {
186        executor::send_batch::<endpoints::ResolveEnrolments, _, _, _>(
187            self,
188            entries,
189            options,
190            encode::enrolment_item,
191            decode::enrolment_answer,
192        )
193        .await
194    }
195
196    async fn import_attainments<K>(
197        &mut self,
198        entries: Vec<BatchEntry<K, AttainmentSubmission>>,
199        options: BatchOptions,
200    ) -> BatchReply<K, AttainmentSubmission, ImportAnswer> {
201        executor::send_batch::<endpoints::ImportAttainments, _, _, _>(
202            self,
203            entries,
204            options,
205            encode::import_item,
206            decode::import_answer,
207        )
208        .await
209    }
210
211    async fn verify_attainments<K>(
212        &mut self,
213        entries: Vec<BatchEntry<K, VerificationRequest>>,
214        options: BatchOptions,
215    ) -> BatchReply<K, VerificationRequest, VerificationAnswer> {
216        executor::send_batch::<endpoints::VerifyAttainments, _, _, _>(
217            self,
218            entries,
219            options,
220            encode::verify_item,
221            decode::verification_answer,
222        )
223        .await
224    }
225
226    async fn list_course_roster(
227        &mut self,
228        request: &[RosterCode],
229    ) -> Result<RosterListing, RegistryError> {
230        rosters::list(self, request).await
231    }
232
233    async fn validate_course_codes(
234        &mut self,
235        codes: &[CourseCode],
236    ) -> Result<CourseCodeVerdicts, RegistryError> {
237        course_codes::validate(self, codes).await
238    }
239}
240
241/// The [`InteractiveStudyRegistry`] over Suotar, for one manual action: the interactive timeout,
242/// and no gate.
243pub(super) struct InteractiveSuotar<'a> {
244    client: &'a SuotarClient,
245    /// The audit log's `worker_name` for every call.
246    worker_name: String,
247}
248
249impl<'a> InteractiveSuotar<'a> {
250    pub(super) fn new(client: &'a SuotarClient, worker_name: String) -> Self {
251        Self {
252            client,
253            worker_name,
254        }
255    }
256
257    fn call_context(&self) -> SuotarCallContext {
258        SuotarCallContext {
259            worker_name: self.worker_name.clone(),
260            credit_registration_ids: Vec::new(),
261            request_timeout: Some(INTERACTIVE_REQUEST_TIMEOUT),
262        }
263    }
264}
265
266impl InteractiveStudyRegistry for InteractiveSuotar<'_> {
267    async fn look_up_person(
268        &self,
269        student_number: &StudentNumber,
270    ) -> Result<Option<RegistryPerson>, PersonLookupError> {
271        let request_item_id = new_request_item_id();
272        let item = encode::person_item(student_number, request_item_id.clone());
273        let span = request_span(SuotarEndpoint::ResolvePersons, 1);
274        let response = self
275            .client
276            .post::<endpoints::ResolvePersons>(self.call_context(), vec![item])
277            .instrument(span)
278            .await
279            .map_err(|error| {
280                warn!(error = %error, "Could not resolve a student number in the study registry");
281                PersonLookupError::StudyRegistryUnavailable
282            })?;
283        match response.item(&request_item_id) {
284            Some(item) => decode::person_lookup(item),
285            None => Err(PersonLookupError::ItemMissingFromResponse),
286        }
287    }
288
289    async fn fetch_course_roster(&self, code: &CourseCode) -> Option<Vec<RosterPerson>> {
290        rosters::fetch_one(self, code).await
291    }
292
293    async fn search_course_rosters(
294        &self,
295        codes: &[CourseCode],
296        student_number: &StudentNumber,
297    ) -> RosterSearch {
298        rosters::search(self, codes, student_number).await
299    }
300}