Skip to main content

fakecloud_comprehend/
state.rs

1//! Account-partitioned, serializable state for Amazon Comprehend (`comprehend`).
2//!
3//! Every resource is stored as its already-output-valid wire JSON object
4//! (`serde_json::Value`, PascalCase members matching the awsJson1_1 member
5//! names) so a `Describe*` echoes exactly what its `Create*` / `Start*`
6//! persisted. Storing the wire shape directly keeps nested configuration
7//! (`InputDataConfig`, `OutputDataConfig`, `VpcConfig`, `TaskConfig`, ...)
8//! round-tripping verbatim.
9//!
10//! Analysis jobs live in one `jobs` map keyed by their generated `JobId`; the
11//! job family (which `Describe*Job` / `List*Jobs` / `Stop*Job` a job belongs to)
12//! is tracked in the `job_families` side map so a family's `List` can filter and
13//! a `Describe` can reject a job id from another family. Tags live in an
14//! ARN-keyed side map so `ListTagsForResource` has a single source of truth.
15//!
16//! The asynchronous lifecycles are modelled by advancing the stored status on
17//! the next read; the same reconciliation runs on restart so an interrupted
18//! transition never wedges.
19
20use 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/// Per-account Amazon Comprehend state.
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
35pub struct ComprehendData {
36    /// The account id this partition belongs to (for ARN synthesis).
37    #[serde(default)]
38    pub account_id: String,
39    /// The region this partition belongs to (for ARN synthesis).
40    #[serde(default)]
41    pub region: String,
42
43    /// Asynchronous analysis jobs keyed by `JobId`; each value is the family's
44    /// `*JobProperties` wire object.
45    #[serde(default)]
46    pub jobs: BTreeMap<String, Value>,
47    /// `JobId` -> family key (e.g. `"sentiment"`, `"topics"`). Lets a family's
48    /// `List`/`Describe`/`Stop` scope to its own jobs.
49    #[serde(default)]
50    pub job_families: BTreeMap<String, String>,
51
52    /// Custom document classifiers keyed by ARN; `DocumentClassifierProperties`.
53    #[serde(default)]
54    pub document_classifiers: BTreeMap<String, Value>,
55    /// Custom entity recognizers keyed by ARN; `EntityRecognizerProperties`.
56    #[serde(default)]
57    pub entity_recognizers: BTreeMap<String, Value>,
58    /// Real-time inference endpoints keyed by ARN; `EndpointProperties`.
59    #[serde(default)]
60    pub endpoints: BTreeMap<String, Value>,
61    /// Flywheels keyed by ARN; `FlywheelProperties`.
62    #[serde(default)]
63    pub flywheels: BTreeMap<String, Value>,
64    /// Datasets keyed by ARN; `DatasetProperties`.
65    #[serde(default)]
66    pub datasets: BTreeMap<String, Value>,
67    /// Flywheel iterations keyed by `"{flywheelArn}#{iterationId}"`;
68    /// `FlywheelIterationProperties`.
69    #[serde(default)]
70    pub flywheel_iterations: BTreeMap<String, Value>,
71
72    /// Resource policies keyed by the target resource ARN. Each value carries
73    /// `ResourcePolicy` / `PolicyRevisionId` / `CreationTime` /
74    /// `LastModifiedTime`.
75    #[serde(default)]
76    pub resource_policies: BTreeMap<String, Value>,
77
78    /// Resource tags keyed by resource ARN.
79    #[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    /// Advance every in-flight lifecycle one step to its settled state so an
103    /// interrupted transition never wedges. Used on the next read and on
104    /// restart. Returns `true` if anything changed.
105    pub fn reconcile(&mut self) -> bool {
106        let now = now_epoch();
107        let mut changed = false;
108
109        // Analysis jobs -> COMPLETED (or STOPPED after a stop request).
110        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        // Custom classifiers / recognizers -> TRAINED (or STOPPED).
129        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        // Endpoints -> IN_SERVICE, publishing the desired inference units and
156        // promoting the desired ModelArn / DataAccessRoleArn to their active
157        // fields. Without this promotion an UpdateEndpoint settles the status but
158        // DescribeEndpoint keeps returning the pre-update ModelArn/role forever
159        // (the new values sit unread under Desired*) (bug-hunt).
160        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        // Flywheels -> ACTIVE.
188        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        // Datasets -> COMPLETED.
201        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        // Flywheel iterations -> COMPLETED (or STOPPED).
214        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
236/// Settle a resource's `status_field` from any of `from` to `to`, stamping each
237/// `(key, value)` in `stamp`. Returns `true` if it fired.
238fn 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        // An UpdateEndpoint sets Desired* fields and status UPDATING; settling
316        // must promote them to the active ModelArn / DataAccessRoleArn so a
317        // DescribeEndpoint returns the new values, not the pre-update ones.
318        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}