Skip to main content

fakecloud_timestream/
persistence.rs

1//! Snapshot save/load for Amazon Timestream state.
2
3use std::sync::Arc;
4
5use tokio::sync::Mutex as AsyncMutex;
6
7use fakecloud_persistence::SnapshotStore;
8
9use crate::state::{SharedTimestreamState, TimestreamSnapshot, TIMESTREAM_SNAPSHOT_SCHEMA_VERSION};
10
11#[derive(Debug, PartialEq, Eq)]
12pub enum LoadOutcome {
13    Empty,
14    Loaded(usize),
15}
16
17#[derive(Debug, thiserror::Error)]
18pub enum LoadError {
19    #[error("failed to read timestream persistence snapshot: {0}")]
20    Io(String),
21    #[error("failed to parse timestream persistence snapshot: {0}")]
22    Parse(String),
23    #[error("timestream persistence schema too new: on-disk={on_disk}, max supported={supported}")]
24    SchemaTooNew { on_disk: u32, supported: u32 },
25}
26
27pub fn load_into(
28    store: &dyn SnapshotStore,
29    state: &SharedTimestreamState,
30) -> Result<LoadOutcome, LoadError> {
31    let Some(bytes) = store.load().map_err(|e| LoadError::Io(e.to_string()))? else {
32        return Ok(LoadOutcome::Empty);
33    };
34    let snapshot: TimestreamSnapshot =
35        serde_json::from_slice(&bytes).map_err(|e| LoadError::Parse(e.to_string()))?;
36    if snapshot.schema_version > TIMESTREAM_SNAPSHOT_SCHEMA_VERSION {
37        return Err(LoadError::SchemaTooNew {
38            on_disk: snapshot.schema_version,
39            supported: TIMESTREAM_SNAPSHOT_SCHEMA_VERSION,
40        });
41    }
42    let accounts = snapshot.accounts.account_count();
43    *state.write() = snapshot.accounts;
44    Ok(LoadOutcome::Loaded(accounts))
45}
46
47pub async fn save_snapshot(
48    state: &SharedTimestreamState,
49    store: Option<Arc<dyn SnapshotStore>>,
50    lock: &AsyncMutex<()>,
51) {
52    let Some(store) = store else {
53        return;
54    };
55    let _guard = lock.lock().await;
56    let snapshot = TimestreamSnapshot {
57        schema_version: TIMESTREAM_SNAPSHOT_SCHEMA_VERSION,
58        accounts: state.read().clone(),
59    };
60    let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
61        let bytes = serde_json::to_vec(&snapshot)
62            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
63        store.save(&bytes)
64    })
65    .await;
66    match join {
67        Ok(Ok(())) => {}
68        Ok(Err(err)) => tracing::error!(%err, "failed to write timestream snapshot"),
69        Err(err) => tracing::error!(%err, "timestream snapshot task panicked"),
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::state::{Database, TimestreamData};
77    use fakecloud_core::multi_account::MultiAccountState;
78    use parking_lot::RwLock;
79    use std::sync::Mutex;
80
81    struct MemStore(Mutex<Option<Vec<u8>>>);
82    impl SnapshotStore for MemStore {
83        fn load(&self) -> std::io::Result<Option<Vec<u8>>> {
84            Ok(self.0.lock().unwrap().clone())
85        }
86        fn save(&self, bytes: &[u8]) -> std::io::Result<()> {
87            *self.0.lock().unwrap() = Some(bytes.to_vec());
88            Ok(())
89        }
90    }
91
92    fn state() -> SharedTimestreamState {
93        Arc::new(RwLock::new(MultiAccountState::new(
94            "000000000000",
95            "us-east-1",
96            "",
97        )))
98    }
99
100    #[test]
101    fn empty_store_is_empty() {
102        assert_eq!(
103            load_into(&MemStore(Mutex::new(None)), &state()).unwrap(),
104            LoadOutcome::Empty
105        );
106    }
107
108    #[test]
109    fn round_trip_restores_databases() {
110        let mut accounts: MultiAccountState<TimestreamData> =
111            MultiAccountState::new("000000000000", "us-east-1", "");
112        let data = accounts.get_or_create("111122223333");
113        data.databases.insert(
114            "metrics".into(),
115            Database {
116                name: "metrics".into(),
117                arn: "arn:aws:timestream:us-east-1:111122223333:database/metrics".into(),
118                kms_key_id: None,
119                table_count: 0,
120                creation_time: 1.0,
121                last_updated_time: 1.0,
122            },
123        );
124        let snap = TimestreamSnapshot {
125            schema_version: TIMESTREAM_SNAPSHOT_SCHEMA_VERSION,
126            accounts,
127        };
128        let store = MemStore(Mutex::new(Some(serde_json::to_vec(&snap).unwrap())));
129        let restored = state();
130        assert_eq!(
131            load_into(&store, &restored).unwrap(),
132            LoadOutcome::Loaded(2)
133        );
134        let guard = restored.read();
135        assert_eq!(
136            guard.get("111122223333").unwrap().databases["metrics"].table_count,
137            0
138        );
139    }
140
141    #[test]
142    fn rejects_future_schema() {
143        let accounts: MultiAccountState<TimestreamData> =
144            MultiAccountState::new("000000000000", "us-east-1", "");
145        let bytes = serde_json::to_vec(&serde_json::json!({
146            "schema_version": TIMESTREAM_SNAPSHOT_SCHEMA_VERSION + 1,
147            "accounts": accounts,
148        }))
149        .unwrap();
150        let store = MemStore(Mutex::new(Some(bytes)));
151        assert!(matches!(
152            load_into(&store, &state()),
153            Err(LoadError::SchemaTooNew { .. })
154        ));
155    }
156}