Skip to main content

agentd/store/
memory.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The in-process store — `store.kind: memory`. Keeps per-key
3//! history (so `get(key, seq)` works like a history-keeping server), enforces
4//! the seq CAS, supports `list`/`delete`, and offers **fault injection** for
5//! tests: fail the next N operations, add latency, or refuse a specific key.
6//! Not durable across the process (dev/test only — the loader warns).
7
8use super::{KeySeq, PutOutcome, Store, StoreError};
9use serde_json::Value;
10use std::collections::BTreeMap;
11use std::sync::Mutex;
12use std::time::Duration;
13
14#[derive(Default)]
15struct Inner {
16    /// key → seq → envelope
17    data: BTreeMap<String, BTreeMap<u64, Value>>,
18    /// Fault injection: remaining operations to fail with `Io`.
19    fail_next: u32,
20    /// Latency added to every operation.
21    latency: Duration,
22    /// Every operation performed (op, key) — for assertions.
23    log: Vec<(String, String)>,
24}
25
26pub struct MemoryStore {
27    inner: Mutex<Inner>,
28}
29
30impl Default for MemoryStore {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl MemoryStore {
37    pub fn new() -> MemoryStore {
38        MemoryStore {
39            inner: Mutex::new(Inner::default()),
40        }
41    }
42
43    /// Fail the next `n` operations with `StoreError::Io`.
44    pub fn fail_next(&self, n: u32) {
45        self.lock().fail_next = n;
46    }
47
48    /// Add `latency` to every operation.
49    pub fn set_latency(&self, latency: Duration) {
50        self.lock().latency = latency;
51    }
52
53    /// The operations performed so far (op, key).
54    pub fn ops(&self) -> Vec<(String, String)> {
55        self.lock().log.clone()
56    }
57
58    /// The number of stored keys (live, i.e. non-tombstone latest record).
59    pub fn len(&self) -> usize {
60        self.lock()
61            .data
62            .values()
63            .filter(|h| {
64                h.values()
65                    .next_back()
66                    .is_some_and(|v| !v.get("state").is_some_and(Value::is_null))
67            })
68            .count()
69    }
70
71    pub fn is_empty(&self) -> bool {
72        self.len() == 0
73    }
74
75    /// Every stored key with its latest seq (test helper — includes tombstones).
76    pub fn dump(&self) -> Vec<(String, u64)> {
77        self.lock()
78            .data
79            .iter()
80            .filter_map(|(k, h)| h.keys().next_back().map(|s| (k.clone(), *s)))
81            .collect()
82    }
83
84    fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
85        self.inner.lock().unwrap_or_else(|e| e.into_inner())
86    }
87
88    fn begin(&self, op: &str, key: &str) -> Result<std::sync::MutexGuard<'_, Inner>, StoreError> {
89        let mut g = self.lock();
90        g.log.push((op.to_string(), key.to_string()));
91        if g.fail_next > 0 {
92            g.fail_next -= 1;
93            return Err(StoreError::Io(format!("injected failure on {op} {key}")));
94        }
95        if !g.latency.is_zero() {
96            let d = g.latency;
97            drop(g);
98            std::thread::sleep(d);
99            g = self.lock();
100        }
101        Ok(g)
102    }
103}
104
105impl Store for MemoryStore {
106    fn put(&self, key: &str, seq: u64, envelope: &Value) -> Result<PutOutcome, StoreError> {
107        let mut g = self.begin("put", key)?;
108        let hist = g.data.entry(key.to_string()).or_default();
109        let latest = hist.keys().next_back().copied();
110        if let Some(l) = latest
111            && seq <= l
112        {
113            return Ok(PutOutcome::Conflict {
114                latest_seq: Some(l),
115            });
116        }
117        hist.insert(seq, envelope.clone());
118        Ok(PutOutcome::Ok)
119    }
120
121    fn get(&self, key: &str, seq: Option<u64>) -> Result<Option<Value>, StoreError> {
122        let g = self.begin("get", key)?;
123        let Some(hist) = g.data.get(key) else {
124            return Ok(None);
125        };
126        let picked = match seq {
127            Some(s) => hist.get(&s),
128            None => hist.values().next_back(),
129        };
130        // A tombstone (latest state null) reads as absent.
131        Ok(picked
132            .filter(|v| !v.get("state").is_some_and(Value::is_null))
133            .cloned())
134    }
135
136    fn list(&self, prefix: &str) -> Result<Vec<KeySeq>, StoreError> {
137        let g = self.begin("list", prefix)?;
138        Ok(g.data
139            .iter()
140            .filter(|(k, h)| {
141                k.starts_with(prefix)
142                    && h.values()
143                        .next_back()
144                        .is_some_and(|v| !v.get("state").is_some_and(Value::is_null))
145            })
146            .map(|(k, h)| KeySeq {
147                key: k.clone(),
148                seq: h.keys().next_back().copied(),
149            })
150            .collect())
151    }
152
153    fn delete(&self, key: &str) -> Result<(), StoreError> {
154        let mut g = self.begin("delete", key)?;
155        g.data.remove(key);
156        Ok(())
157    }
158
159    fn kind(&self) -> &'static str {
160        "memory"
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use serde_json::json;
168
169    #[test]
170    fn cas_history_list_delete_and_faults() {
171        let s = MemoryStore::new();
172        assert_eq!(
173            s.put("a/k", 1, &json!({"state": 1})).unwrap(),
174            PutOutcome::Ok
175        );
176        assert_eq!(
177            s.put("a/k", 2, &json!({"state": 2})).unwrap(),
178            PutOutcome::Ok
179        );
180        // CAS: an equal or lower seq conflicts, naming the latest.
181        assert_eq!(
182            s.put("a/k", 2, &json!({"state": 9})).unwrap(),
183            PutOutcome::Conflict {
184                latest_seq: Some(2)
185            }
186        );
187        assert_eq!(
188            s.put("a/k", 1, &json!({"state": 9})).unwrap(),
189            PutOutcome::Conflict {
190                latest_seq: Some(2)
191            }
192        );
193        // Latest and pinned reads.
194        assert_eq!(s.get("a/k", None).unwrap(), Some(json!({"state": 2})));
195        assert_eq!(s.get("a/k", Some(1)).unwrap(), Some(json!({"state": 1})));
196        assert_eq!(s.get("a/k", Some(5)).unwrap(), None);
197        assert_eq!(s.get("a/none", None).unwrap(), None);
198        // list by prefix with latest seq.
199        s.put("a/j", 1, &json!({"state": 0})).unwrap();
200        s.put("b/x", 1, &json!({"state": 0})).unwrap();
201        let l = s.list("a/").unwrap();
202        assert_eq!(l.len(), 2);
203        assert!(l.iter().any(|e| e.key == "a/k" && e.seq == Some(2)));
204        // A tombstone reads as absent and is not listed.
205        s.put("a/j", 2, &json!({"state": null})).unwrap();
206        assert_eq!(s.get("a/j", None).unwrap(), None);
207        assert_eq!(s.list("a/").unwrap().len(), 1);
208        // delete removes history.
209        s.delete("a/k").unwrap();
210        assert_eq!(s.get("a/k", None).unwrap(), None);
211        assert_eq!(
212            s.put("a/k", 1, &json!({"state": "again"})).unwrap(),
213            PutOutcome::Ok
214        );
215        // Fault injection.
216        s.fail_next(2);
217        assert!(matches!(s.get("a/k", None), Err(StoreError::Io(_))));
218        assert!(matches!(
219            s.put("a/k", 5, &json!({})),
220            Err(StoreError::Io(_))
221        ));
222        assert!(s.get("a/k", None).is_ok());
223        assert!(s.ops().iter().any(|(op, k)| op == "delete" && k == "a/k"));
224    }
225}