Skip to main content

fakecloud_translate/
state.rs

1//! Account-partitioned, serializable state for Amazon Translate (`translate`).
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 `Get*` echoes exactly what its `Create*` / `Import*` / `Start*`
6//! persisted. Tags live in an ARN-keyed side map so `ListTagsForResource` has a
7//! single source of truth.
8//!
9//! The asynchronous lifecycles are modelled by advancing the stored status on
10//! the next read; the same reconciliation runs on restart so an interrupted
11//! transition never wedges. Batch translation jobs settle `SUBMITTED` ->
12//! `COMPLETED` (stamping `EndTime` and a `JobDetails` count), `STOP_REQUESTED`
13//! -> `STOPPED`; parallel data settles `CREATING` -> `ACTIVE` and an update's
14//! `LatestUpdateAttemptStatus` `UPDATING` -> `ACTIVE`.
15
16use std::collections::BTreeMap;
17use std::sync::Arc;
18
19use parking_lot::RwLock;
20use serde::{Deserialize, Serialize};
21use serde_json::{json, Value};
22
23use fakecloud_core::multi_account::{AccountState, MultiAccountState};
24
25use crate::shared::now_epoch;
26
27pub const TRANSLATE_SNAPSHOT_SCHEMA_VERSION: u32 = 1;
28
29/// Per-account Amazon Translate state.
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31pub struct TranslateData {
32    /// The account id this partition belongs to (for ARN synthesis).
33    #[serde(default)]
34    pub account_id: String,
35    /// The region this partition belongs to (for ARN synthesis).
36    #[serde(default)]
37    pub region: String,
38
39    /// Custom terminologies keyed by `Name`; stores `TerminologyProperties`.
40    #[serde(default)]
41    pub terminologies: BTreeMap<String, Value>,
42    /// Parallel data keyed by `Name`; stores `ParallelDataProperties`.
43    #[serde(default)]
44    pub parallel_data: BTreeMap<String, Value>,
45    /// Batch text-translation jobs keyed by `JobId`; stores
46    /// `TextTranslationJobProperties`.
47    #[serde(default)]
48    pub text_translation_jobs: BTreeMap<String, Value>,
49
50    /// Resource tags keyed by resource ARN.
51    #[serde(default)]
52    pub tags: BTreeMap<String, BTreeMap<String, String>>,
53}
54
55impl AccountState for TranslateData {
56    fn new_for_account(account_id: &str, region: &str, _endpoint: &str) -> Self {
57        Self {
58            account_id: account_id.to_string(),
59            region: region.to_string(),
60            ..Default::default()
61        }
62    }
63}
64
65pub type SharedTranslateState = Arc<RwLock<MultiAccountState<TranslateData>>>;
66
67#[derive(Debug, Serialize, Deserialize)]
68pub struct TranslateSnapshot {
69    pub schema_version: u32,
70    pub accounts: MultiAccountState<TranslateData>,
71}
72
73impl TranslateData {
74    /// Advance every in-flight lifecycle one step to its settled state so an
75    /// interrupted transition never wedges. Used on the next read and on
76    /// restart. Returns `true` if anything changed.
77    pub fn reconcile(&mut self) -> bool {
78        let mut changed = false;
79        let now = now_epoch();
80
81        // Batch translation jobs settle straight to their terminal state.
82        for job in self.text_translation_jobs.values_mut() {
83            let Some(obj) = job.as_object_mut() else {
84                continue;
85            };
86            match obj.get("JobStatus").and_then(Value::as_str) {
87                Some("SUBMITTED") | Some("IN_PROGRESS") => {
88                    obj.insert("JobStatus".into(), json!("COMPLETED"));
89                    obj.insert("EndTime".into(), json!(now));
90                    obj.entry("JobDetails").or_insert(json!({
91                        "TranslatedDocumentsCount": 0,
92                        "DocumentsWithErrorsCount": 0,
93                        "InputDocumentsCount": 0,
94                    }));
95                    changed = true;
96                }
97                Some("STOP_REQUESTED") => {
98                    obj.insert("JobStatus".into(), json!("STOPPED"));
99                    obj.insert("EndTime".into(), json!(now));
100                    changed = true;
101                }
102                _ => {}
103            }
104        }
105
106        // Parallel data settles CREATING -> ACTIVE; a pending update's
107        // LatestUpdateAttemptStatus settles UPDATING -> ACTIVE.
108        for pd in self.parallel_data.values_mut() {
109            let Some(obj) = pd.as_object_mut() else {
110                continue;
111            };
112            if obj.get("Status").and_then(Value::as_str) == Some("CREATING") {
113                obj.insert("Status".into(), json!("ACTIVE"));
114                obj.insert("LastUpdatedAt".into(), json!(now));
115                changed = true;
116            }
117            if obj.get("LatestUpdateAttemptStatus").and_then(Value::as_str) == Some("UPDATING") {
118                obj.insert("LatestUpdateAttemptStatus".into(), json!("ACTIVE"));
119                obj.insert("Status".into(), json!("ACTIVE"));
120                obj.insert("LastUpdatedAt".into(), json!(now));
121                changed = true;
122            }
123        }
124
125        changed
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    fn data() -> TranslateData {
134        TranslateData::new_for_account("000000000000", "us-east-1", "")
135    }
136
137    #[test]
138    fn job_settles_to_completed() {
139        let mut d = data();
140        d.text_translation_jobs.insert(
141            "j1".into(),
142            json!({ "JobId": "j1", "JobStatus": "SUBMITTED" }),
143        );
144        assert!(d.reconcile());
145        let j = &d.text_translation_jobs["j1"];
146        assert_eq!(j["JobStatus"], json!("COMPLETED"));
147        assert!(j.get("EndTime").is_some());
148        assert!(j.get("JobDetails").is_some());
149        assert!(!d.reconcile());
150    }
151
152    #[test]
153    fn stop_requested_settles_to_stopped() {
154        let mut d = data();
155        d.text_translation_jobs.insert(
156            "j2".into(),
157            json!({ "JobId": "j2", "JobStatus": "STOP_REQUESTED" }),
158        );
159        assert!(d.reconcile());
160        assert_eq!(d.text_translation_jobs["j2"]["JobStatus"], json!("STOPPED"));
161    }
162
163    #[test]
164    fn parallel_data_settles_to_active() {
165        let mut d = data();
166        d.parallel_data
167            .insert("p1".into(), json!({ "Name": "p1", "Status": "CREATING" }));
168        assert!(d.reconcile());
169        assert_eq!(d.parallel_data["p1"]["Status"], json!("ACTIVE"));
170        assert!(!d.reconcile());
171    }
172
173    #[test]
174    fn parallel_data_update_settles() {
175        let mut d = data();
176        d.parallel_data.insert(
177            "p2".into(),
178            json!({ "Name": "p2", "Status": "ACTIVE", "LatestUpdateAttemptStatus": "UPDATING" }),
179        );
180        assert!(d.reconcile());
181        assert_eq!(
182            d.parallel_data["p2"]["LatestUpdateAttemptStatus"],
183            json!("ACTIVE")
184        );
185    }
186}