headless_lms_credit_registration/runtime/suotar/
mod.rs1mod 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
44pub fn reset_rate_limits(scope: &RegistrationScope) {
46 rate_limit::reset(&ScopeKey::of(scope));
47}
48
49pub(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
62fn 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
74pub 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
87pub fn max_study_registry_wait(phase: CreditRegistrationPhase) -> std::time::Duration {
89 study_registry_endpoints(phase)
90 .map(SuotarEndpoint::request_timeout)
91 .sum()
92}
93
94fn 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
105pub(super) struct SuotarStudyRegistry<'a> {
108 client: &'a SuotarClient,
109 worker_name: String,
111 gate: StudyRegistryGate,
112 #[cfg(test)]
114 request_timeout: Option<std::time::Duration>,
115}
116
117impl<'a> SuotarStudyRegistry<'a> {
118 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 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
241pub(super) struct InteractiveSuotar<'a> {
244 client: &'a SuotarClient,
245 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}