1use std::collections::BTreeMap;
21use std::sync::Arc;
22
23use parking_lot::RwLock;
24use serde::{Deserialize, Serialize};
25use serde_json::{json, Value};
26
27use fakecloud_core::multi_account::{AccountState, MultiAccountState};
28
29use crate::shared::now_epoch;
30
31pub const COMPREHEND_SNAPSHOT_SCHEMA_VERSION: u32 = 1;
32
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35pub struct ComprehendData {
36 #[serde(default)]
38 pub account_id: String,
39 #[serde(default)]
41 pub region: String,
42
43 #[serde(default)]
46 pub jobs: BTreeMap<String, Value>,
47 #[serde(default)]
50 pub job_families: BTreeMap<String, String>,
51
52 #[serde(default)]
54 pub document_classifiers: BTreeMap<String, Value>,
55 #[serde(default)]
57 pub entity_recognizers: BTreeMap<String, Value>,
58 #[serde(default)]
60 pub endpoints: BTreeMap<String, Value>,
61 #[serde(default)]
63 pub flywheels: BTreeMap<String, Value>,
64 #[serde(default)]
66 pub datasets: BTreeMap<String, Value>,
67 #[serde(default)]
70 pub flywheel_iterations: BTreeMap<String, Value>,
71
72 #[serde(default)]
76 pub resource_policies: BTreeMap<String, Value>,
77
78 #[serde(default)]
80 pub tags: BTreeMap<String, BTreeMap<String, String>>,
81}
82
83impl AccountState for ComprehendData {
84 fn new_for_account(account_id: &str, region: &str, _endpoint: &str) -> Self {
85 Self {
86 account_id: account_id.to_string(),
87 region: region.to_string(),
88 ..Default::default()
89 }
90 }
91}
92
93pub type SharedComprehendState = Arc<RwLock<MultiAccountState<ComprehendData>>>;
94
95#[derive(Debug, Serialize, Deserialize)]
96pub struct ComprehendSnapshot {
97 pub schema_version: u32,
98 pub accounts: MultiAccountState<ComprehendData>,
99}
100
101impl ComprehendData {
102 pub fn reconcile(&mut self) -> bool {
106 let now = now_epoch();
107 let mut changed = false;
108
109 for job in self.jobs.values_mut() {
111 if settle_status(
112 job,
113 "JobStatus",
114 &["SUBMITTED", "IN_PROGRESS"],
115 "COMPLETED",
116 &[("EndTime", json!(now))],
117 ) || settle_status(
118 job,
119 "JobStatus",
120 &["STOP_REQUESTED"],
121 "STOPPED",
122 &[("EndTime", json!(now))],
123 ) {
124 changed = true;
125 }
126 }
127
128 for model in self
130 .document_classifiers
131 .values_mut()
132 .chain(self.entity_recognizers.values_mut())
133 {
134 if settle_status(
135 model,
136 "Status",
137 &["SUBMITTED", "TRAINING"],
138 "TRAINED",
139 &[
140 ("TrainingStartTime", json!(now)),
141 ("TrainingEndTime", json!(now)),
142 ("EndTime", json!(now)),
143 ],
144 ) || settle_status(
145 model,
146 "Status",
147 &["STOP_REQUESTED"],
148 "STOPPED",
149 &[("EndTime", json!(now))],
150 ) {
151 changed = true;
152 }
153 }
154
155 for ep in self.endpoints.values_mut() {
161 let desired = ep.get("DesiredInferenceUnits").cloned().unwrap_or(json!(1));
162 let mut stamp: Vec<(&str, Value)> = vec![
163 ("CurrentInferenceUnits", desired),
164 ("LastModifiedTime", json!(now)),
165 ];
166 if let Some(m) = ep.get("DesiredModelArn").filter(|v| !v.is_null()).cloned() {
167 stamp.push(("ModelArn", m));
168 }
169 if let Some(r) = ep
170 .get("DesiredDataAccessRoleArn")
171 .filter(|v| !v.is_null())
172 .cloned()
173 {
174 stamp.push(("DataAccessRoleArn", r));
175 }
176 if settle_status(
177 ep,
178 "Status",
179 &["CREATING", "UPDATING"],
180 "IN_SERVICE",
181 &stamp,
182 ) {
183 changed = true;
184 }
185 }
186
187 for fw in self.flywheels.values_mut() {
189 if settle_status(
190 fw,
191 "Status",
192 &["CREATING", "UPDATING"],
193 "ACTIVE",
194 &[("LastModifiedTime", json!(now))],
195 ) {
196 changed = true;
197 }
198 }
199
200 for ds in self.datasets.values_mut() {
202 if settle_status(
203 ds,
204 "Status",
205 &["CREATING"],
206 "COMPLETED",
207 &[("EndTime", json!(now)), ("NumberOfDocuments", json!(0))],
208 ) {
209 changed = true;
210 }
211 }
212
213 for it in self.flywheel_iterations.values_mut() {
215 if settle_status(
216 it,
217 "Status",
218 &["TRAINING", "EVALUATING"],
219 "COMPLETED",
220 &[("EndTime", json!(now))],
221 ) || settle_status(
222 it,
223 "Status",
224 &["STOP_REQUESTED"],
225 "STOPPED",
226 &[("EndTime", json!(now))],
227 ) {
228 changed = true;
229 }
230 }
231
232 changed
233 }
234}
235
236fn settle_status(
239 res: &mut Value,
240 status_field: &str,
241 from: &[&str],
242 to: &str,
243 stamp: &[(&str, Value)],
244) -> bool {
245 let Some(obj) = res.as_object_mut() else {
246 return false;
247 };
248 let cur = obj.get(status_field).and_then(Value::as_str).unwrap_or("");
249 if !from.contains(&cur) {
250 return false;
251 }
252 obj.insert(status_field.to_string(), json!(to));
253 for (k, v) in stamp {
254 obj.insert((*k).to_string(), v.clone());
255 }
256 true
257}
258
259#[cfg(test)]
260mod tests {
261 use super::*;
262
263 fn data() -> ComprehendData {
264 ComprehendData::new_for_account("000000000000", "us-east-1", "")
265 }
266
267 #[test]
268 fn job_settles_to_completed() {
269 let mut d = data();
270 d.jobs.insert(
271 "j1".into(),
272 json!({ "JobId": "j1", "JobStatus": "SUBMITTED" }),
273 );
274 d.job_families.insert("j1".into(), "sentiment".into());
275 assert!(d.reconcile());
276 assert_eq!(d.jobs["j1"]["JobStatus"], "COMPLETED");
277 assert!(d.jobs["j1"].get("EndTime").is_some());
278 assert!(!d.reconcile());
279 }
280
281 #[test]
282 fn stop_requested_job_settles_to_stopped() {
283 let mut d = data();
284 d.jobs.insert(
285 "j1".into(),
286 json!({ "JobId": "j1", "JobStatus": "STOP_REQUESTED" }),
287 );
288 assert!(d.reconcile());
289 assert_eq!(d.jobs["j1"]["JobStatus"], "STOPPED");
290 }
291
292 #[test]
293 fn classifier_settles_to_trained() {
294 let mut d = data();
295 d.document_classifiers
296 .insert("arn".into(), json!({ "Status": "SUBMITTED" }));
297 assert!(d.reconcile());
298 assert_eq!(d.document_classifiers["arn"]["Status"], "TRAINED");
299 }
300
301 #[test]
302 fn endpoint_settles_in_service() {
303 let mut d = data();
304 d.endpoints.insert(
305 "arn".into(),
306 json!({ "Status": "CREATING", "DesiredInferenceUnits": 2 }),
307 );
308 assert!(d.reconcile());
309 assert_eq!(d.endpoints["arn"]["Status"], "IN_SERVICE");
310 assert_eq!(d.endpoints["arn"]["CurrentInferenceUnits"], 2);
311 }
312
313 #[test]
314 fn updating_endpoint_promotes_desired_model_and_role() {
315 let mut d = data();
319 d.endpoints.insert(
320 "arn".into(),
321 json!({
322 "Status": "UPDATING",
323 "ModelArn": "arn:old-model",
324 "DataAccessRoleArn": "arn:old-role",
325 "DesiredInferenceUnits": 3,
326 "DesiredModelArn": "arn:new-model",
327 "DesiredDataAccessRoleArn": "arn:new-role"
328 }),
329 );
330 assert!(d.reconcile());
331 let ep = &d.endpoints["arn"];
332 assert_eq!(ep["Status"], "IN_SERVICE");
333 assert_eq!(ep["CurrentInferenceUnits"], 3);
334 assert_eq!(ep["ModelArn"], "arn:new-model");
335 assert_eq!(ep["DataAccessRoleArn"], "arn:new-role");
336 }
337
338 #[test]
339 fn flywheel_and_dataset_settle() {
340 let mut d = data();
341 d.flywheels
342 .insert("arn".into(), json!({ "Status": "CREATING" }));
343 d.datasets
344 .insert("arn".into(), json!({ "Status": "CREATING" }));
345 assert!(d.reconcile());
346 assert_eq!(d.flywheels["arn"]["Status"], "ACTIVE");
347 assert_eq!(d.datasets["arn"]["Status"], "COMPLETED");
348 }
349}