Skip to main content

af_workflow/
state.rs

1//! Branch-scoped run state.
2//!
3//! Port of the `ctx.state` surface used by nodes (`append` / `set` / `get`).
4//! The trait is async because production backs it with Redis; this crate ships
5//! an in-memory implementation good for tests, single-process runs, and as the
6//! reference semantics (sliding-window `append`, TTL expiry).
7//!
8//! R6: nodes scope keys by `"<branch_id>."` prefix before calling these — the
9//! store itself is prefix-agnostic.
10
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex};
13use std::time::{Duration, Instant};
14
15use async_trait::async_trait;
16use serde_json::Value;
17
18/// Branch-scoped durable key/value state with TTL.
19#[async_trait]
20pub trait State: Send + Sync {
21    /// Append `item` to the list at `key`, keeping at most `max_len` entries
22    /// (oldest dropped first). Creates the list if absent. `ttl` bounds the
23    /// key's lifetime.
24    async fn append(&self, key: &str, item: Value, max_len: Option<usize>, ttl: Option<Duration>);
25
26    /// Set `key` to `value` (overwrite), with optional `ttl`.
27    async fn set(&self, key: &str, value: Value, ttl: Option<Duration>);
28
29    /// Read `key`, or `None` if absent/expired.
30    async fn get(&self, key: &str) -> Option<Value>;
31}
32
33/// A namespaced view over a shared state backend. Use one namespace per
34/// workflow instance so identical branch/key names cannot collide.
35pub struct NamespacedState {
36    inner: Arc<dyn State>,
37    prefix: String,
38}
39
40impl NamespacedState {
41    /// View of `inner` with every key prefixed by `namespace`.
42    pub fn new(inner: Arc<dyn State>, namespace: impl std::fmt::Display) -> Self {
43        Self {
44            inner,
45            prefix: format!("{namespace}."),
46        }
47    }
48
49    /// Key prefix.
50    pub fn prefix(&self) -> &str {
51        &self.prefix
52    }
53
54    fn key(&self, key: &str) -> String {
55        format!("{}{key}", self.prefix)
56    }
57}
58
59#[async_trait]
60impl State for NamespacedState {
61    async fn append(&self, key: &str, item: Value, max_len: Option<usize>, ttl: Option<Duration>) {
62        self.inner.append(&self.key(key), item, max_len, ttl).await;
63    }
64
65    async fn set(&self, key: &str, value: Value, ttl: Option<Duration>) {
66        self.inner.set(&self.key(key), value, ttl).await;
67    }
68
69    async fn get(&self, key: &str) -> Option<Value> {
70        self.inner.get(&self.key(key)).await
71    }
72}
73
74struct Entry {
75    value: Value,
76    expires_at: Option<Instant>,
77}
78
79impl Entry {
80    fn is_expired(&self, now: Instant) -> bool {
81        self.expires_at.map(|e| now >= e).unwrap_or(false)
82    }
83}
84
85/// In-memory [`State`]. Cheap to share behind an `Arc`.
86#[derive(Default)]
87pub struct MemoryState {
88    map: Mutex<HashMap<String, Entry>>,
89}
90
91impl MemoryState {
92    /// Empty in-memory state.
93    pub fn new() -> Self {
94        Self::default()
95    }
96
97    /// Rebuild from a persisted snapshot. TTLs are not persisted: a durable
98    /// instance's expiry is owned by its lifecycle, not by per-key timers.
99    pub fn from_snapshot(entries: serde_json::Map<String, Value>) -> Self {
100        Self {
101            map: Mutex::new(
102                entries
103                    .into_iter()
104                    .map(|(key, value)| {
105                        (
106                            key,
107                            Entry {
108                                value,
109                                expires_at: None,
110                            },
111                        )
112                    })
113                    .collect(),
114            ),
115        }
116    }
117
118    /// Every live (non-expired) key, ready to be persisted as JSON.
119    pub fn snapshot(&self) -> serde_json::Map<String, Value> {
120        let now = Instant::now();
121        self.map
122            .lock()
123            .unwrap_or_else(std::sync::PoisonError::into_inner)
124            .iter()
125            .filter(|(_, entry)| !entry.is_expired(now))
126            .map(|(key, entry)| (key.clone(), entry.value.clone()))
127            .collect()
128    }
129
130    fn expiry(ttl: Option<Duration>) -> Option<Instant> {
131        ttl.map(|d| Instant::now() + d)
132    }
133}
134
135#[async_trait]
136impl State for MemoryState {
137    async fn append(&self, key: &str, item: Value, max_len: Option<usize>, ttl: Option<Duration>) {
138        let now = Instant::now();
139        let mut map = self
140            .map
141            .lock()
142            .unwrap_or_else(std::sync::PoisonError::into_inner);
143
144        // Drop the entry if it expired, so we start a fresh window.
145        if let Some(e) = map.get(key) {
146            if e.is_expired(now) {
147                map.remove(key);
148            }
149        }
150
151        let entry = map.entry(key.to_string()).or_insert_with(|| Entry {
152            value: Value::Array(Vec::new()),
153            expires_at: Self::expiry(ttl),
154        });
155
156        if !entry.value.is_array() {
157            entry.value = Value::Array(Vec::new());
158        }
159        if let Value::Array(list) = &mut entry.value {
160            list.push(item);
161            if let Some(cap) = max_len {
162                while list.len() > cap {
163                    list.remove(0);
164                }
165            }
166        }
167        if ttl.is_some() {
168            entry.expires_at = Self::expiry(ttl);
169        }
170    }
171
172    async fn set(&self, key: &str, value: Value, ttl: Option<Duration>) {
173        let mut map = self
174            .map
175            .lock()
176            .unwrap_or_else(std::sync::PoisonError::into_inner);
177        map.insert(
178            key.to_string(),
179            Entry {
180                value,
181                expires_at: Self::expiry(ttl),
182            },
183        );
184    }
185
186    async fn get(&self, key: &str) -> Option<Value> {
187        let now = Instant::now();
188        let mut map = self
189            .map
190            .lock()
191            .unwrap_or_else(std::sync::PoisonError::into_inner);
192        match map.get(key) {
193            Some(e) if e.is_expired(now) => {
194                map.remove(key);
195                None
196            }
197            Some(e) => Some(e.value.clone()),
198            None => None,
199        }
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use serde_json::json;
207
208    #[tokio::test]
209    async fn append_respects_max_len() {
210        let s = MemoryState::new();
211        for i in 0..5 {
212            s.append("k", json!(i), Some(3), None).await;
213        }
214        let v = s.get("k").await.unwrap();
215        assert_eq!(v, json!([2, 3, 4]));
216    }
217
218    #[tokio::test]
219    async fn set_and_get() {
220        let s = MemoryState::new();
221        s.set("k", json!({"a": 1}), None).await;
222        assert_eq!(s.get("k").await.unwrap(), json!({"a": 1}));
223        assert!(s.get("missing").await.is_none());
224    }
225
226    #[tokio::test]
227    async fn namespaces_isolate_instances() {
228        let shared: Arc<dyn State> = Arc::new(MemoryState::new());
229        let first = NamespacedState::new(shared.clone(), "first");
230        let second = NamespacedState::new(shared, "second");
231        first.set("branch.value", json!(1), None).await;
232        second.set("branch.value", json!(2), None).await;
233        assert_eq!(first.get("branch.value").await, Some(json!(1)));
234        assert_eq!(second.get("branch.value").await, Some(json!(2)));
235    }
236}