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.
156        for ep in self.endpoints.values_mut() {
157            let desired = ep.get("DesiredInferenceUnits").cloned().unwrap_or(json!(1));
158            if settle_status(
159                ep,
160                "Status",
161                &["CREATING", "UPDATING"],
162                "IN_SERVICE",
163                &[
164                    ("CurrentInferenceUnits", desired),
165                    ("LastModifiedTime", json!(now)),
166                ],
167            ) {
168                changed = true;
169            }
170        }
171
172        // Flywheels -> ACTIVE.
173        for fw in self.flywheels.values_mut() {
174            if settle_status(
175                fw,
176                "Status",
177                &["CREATING", "UPDATING"],
178                "ACTIVE",
179                &[("LastModifiedTime", json!(now))],
180            ) {
181                changed = true;
182            }
183        }
184
185        // Datasets -> COMPLETED.
186        for ds in self.datasets.values_mut() {
187            if settle_status(
188                ds,
189                "Status",
190                &["CREATING"],
191                "COMPLETED",
192                &[("EndTime", json!(now)), ("NumberOfDocuments", json!(0))],
193            ) {
194                changed = true;
195            }
196        }
197
198        // Flywheel iterations -> COMPLETED (or STOPPED).
199        for it in self.flywheel_iterations.values_mut() {
200            if settle_status(
201                it,
202                "Status",
203                &["TRAINING", "EVALUATING"],
204                "COMPLETED",
205                &[("EndTime", json!(now))],
206            ) || settle_status(
207                it,
208                "Status",
209                &["STOP_REQUESTED"],
210                "STOPPED",
211                &[("EndTime", json!(now))],
212            ) {
213                changed = true;
214            }
215        }
216
217        changed
218    }
219}
220
221/// Settle a resource's `status_field` from any of `from` to `to`, stamping each
222/// `(key, value)` in `stamp`. Returns `true` if it fired.
223fn settle_status(
224    res: &mut Value,
225    status_field: &str,
226    from: &[&str],
227    to: &str,
228    stamp: &[(&str, Value)],
229) -> bool {
230    let Some(obj) = res.as_object_mut() else {
231        return false;
232    };
233    let cur = obj.get(status_field).and_then(Value::as_str).unwrap_or("");
234    if !from.contains(&cur) {
235        return false;
236    }
237    obj.insert(status_field.to_string(), json!(to));
238    for (k, v) in stamp {
239        obj.insert((*k).to_string(), v.clone());
240    }
241    true
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn data() -> ComprehendData {
249        ComprehendData::new_for_account("000000000000", "us-east-1", "")
250    }
251
252    #[test]
253    fn job_settles_to_completed() {
254        let mut d = data();
255        d.jobs.insert(
256            "j1".into(),
257            json!({ "JobId": "j1", "JobStatus": "SUBMITTED" }),
258        );
259        d.job_families.insert("j1".into(), "sentiment".into());
260        assert!(d.reconcile());
261        assert_eq!(d.jobs["j1"]["JobStatus"], "COMPLETED");
262        assert!(d.jobs["j1"].get("EndTime").is_some());
263        assert!(!d.reconcile());
264    }
265
266    #[test]
267    fn stop_requested_job_settles_to_stopped() {
268        let mut d = data();
269        d.jobs.insert(
270            "j1".into(),
271            json!({ "JobId": "j1", "JobStatus": "STOP_REQUESTED" }),
272        );
273        assert!(d.reconcile());
274        assert_eq!(d.jobs["j1"]["JobStatus"], "STOPPED");
275    }
276
277    #[test]
278    fn classifier_settles_to_trained() {
279        let mut d = data();
280        d.document_classifiers
281            .insert("arn".into(), json!({ "Status": "SUBMITTED" }));
282        assert!(d.reconcile());
283        assert_eq!(d.document_classifiers["arn"]["Status"], "TRAINED");
284    }
285
286    #[test]
287    fn endpoint_settles_in_service() {
288        let mut d = data();
289        d.endpoints.insert(
290            "arn".into(),
291            json!({ "Status": "CREATING", "DesiredInferenceUnits": 2 }),
292        );
293        assert!(d.reconcile());
294        assert_eq!(d.endpoints["arn"]["Status"], "IN_SERVICE");
295        assert_eq!(d.endpoints["arn"]["CurrentInferenceUnits"], 2);
296    }
297
298    #[test]
299    fn flywheel_and_dataset_settle() {
300        let mut d = data();
301        d.flywheels
302            .insert("arn".into(), json!({ "Status": "CREATING" }));
303        d.datasets
304            .insert("arn".into(), json!({ "Status": "CREATING" }));
305        assert!(d.reconcile());
306        assert_eq!(d.flywheels["arn"]["Status"], "ACTIVE");
307        assert_eq!(d.datasets["arn"]["Status"], "COMPLETED");
308    }
309}