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#[async_trait]
19pub trait State: Send + Sync {
20    /// Append `item` to the list at `key`, keeping at most `max_len` entries
21    /// (oldest dropped first). Creates the list if absent. `ttl` bounds the
22    /// key's lifetime.
23    async fn append(&self, key: &str, item: Value, max_len: Option<usize>, ttl: Option<Duration>);
24
25    /// Set `key` to `value` (overwrite), with optional `ttl`.
26    async fn set(&self, key: &str, value: Value, ttl: Option<Duration>);
27
28    /// Read `key`, or `None` if absent/expired.
29    async fn get(&self, key: &str) -> Option<Value>;
30}
31
32/// A namespaced view over a shared state backend. Use one namespace per
33/// workflow instance so identical branch/key names cannot collide.
34pub struct NamespacedState {
35    inner: Arc<dyn State>,
36    prefix: String,
37}
38
39impl NamespacedState {
40    pub fn new(inner: Arc<dyn State>, namespace: impl std::fmt::Display) -> Self {
41        Self {
42            inner,
43            prefix: format!("{namespace}."),
44        }
45    }
46
47    pub fn prefix(&self) -> &str {
48        &self.prefix
49    }
50
51    fn key(&self, key: &str) -> String {
52        format!("{}{key}", self.prefix)
53    }
54}
55
56#[async_trait]
57impl State for NamespacedState {
58    async fn append(&self, key: &str, item: Value, max_len: Option<usize>, ttl: Option<Duration>) {
59        self.inner.append(&self.key(key), item, max_len, ttl).await;
60    }
61
62    async fn set(&self, key: &str, value: Value, ttl: Option<Duration>) {
63        self.inner.set(&self.key(key), value, ttl).await;
64    }
65
66    async fn get(&self, key: &str) -> Option<Value> {
67        self.inner.get(&self.key(key)).await
68    }
69}
70
71struct Entry {
72    value: Value,
73    expires_at: Option<Instant>,
74}
75
76impl Entry {
77    fn is_expired(&self, now: Instant) -> bool {
78        self.expires_at.map(|e| now >= e).unwrap_or(false)
79    }
80}
81
82/// In-memory [`State`]. Cheap to share behind an `Arc`.
83#[derive(Default)]
84pub struct MemoryState {
85    map: Mutex<HashMap<String, Entry>>,
86}
87
88impl MemoryState {
89    pub fn new() -> Self {
90        Self::default()
91    }
92
93    fn expiry(ttl: Option<Duration>) -> Option<Instant> {
94        ttl.map(|d| Instant::now() + d)
95    }
96}
97
98#[async_trait]
99impl State for MemoryState {
100    async fn append(&self, key: &str, item: Value, max_len: Option<usize>, ttl: Option<Duration>) {
101        let now = Instant::now();
102        let mut map = self.map.lock().unwrap();
103
104        // Drop the entry if it expired, so we start a fresh window.
105        if let Some(e) = map.get(key) {
106            if e.is_expired(now) {
107                map.remove(key);
108            }
109        }
110
111        let entry = map.entry(key.to_string()).or_insert_with(|| Entry {
112            value: Value::Array(Vec::new()),
113            expires_at: Self::expiry(ttl),
114        });
115
116        if !entry.value.is_array() {
117            entry.value = Value::Array(Vec::new());
118        }
119        if let Value::Array(list) = &mut entry.value {
120            list.push(item);
121            if let Some(cap) = max_len {
122                while list.len() > cap {
123                    list.remove(0);
124                }
125            }
126        }
127        if ttl.is_some() {
128            entry.expires_at = Self::expiry(ttl);
129        }
130    }
131
132    async fn set(&self, key: &str, value: Value, ttl: Option<Duration>) {
133        let mut map = self.map.lock().unwrap();
134        map.insert(
135            key.to_string(),
136            Entry {
137                value,
138                expires_at: Self::expiry(ttl),
139            },
140        );
141    }
142
143    async fn get(&self, key: &str) -> Option<Value> {
144        let now = Instant::now();
145        let mut map = self.map.lock().unwrap();
146        match map.get(key) {
147            Some(e) if e.is_expired(now) => {
148                map.remove(key);
149                None
150            }
151            Some(e) => Some(e.value.clone()),
152            None => None,
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use serde_json::json;
161
162    #[tokio::test]
163    async fn append_respects_max_len() {
164        let s = MemoryState::new();
165        for i in 0..5 {
166            s.append("k", json!(i), Some(3), None).await;
167        }
168        let v = s.get("k").await.unwrap();
169        assert_eq!(v, json!([2, 3, 4]));
170    }
171
172    #[tokio::test]
173    async fn set_and_get() {
174        let s = MemoryState::new();
175        s.set("k", json!({"a": 1}), None).await;
176        assert_eq!(s.get("k").await.unwrap(), json!({"a": 1}));
177        assert!(s.get("missing").await.is_none());
178    }
179
180    #[tokio::test]
181    async fn namespaces_isolate_instances() {
182        let shared: Arc<dyn State> = Arc::new(MemoryState::new());
183        let first = NamespacedState::new(shared.clone(), "first");
184        let second = NamespacedState::new(shared, "second");
185        first.set("branch.value", json!(1), None).await;
186        second.set("branch.value", json!(2), None).await;
187        assert_eq!(first.get("branch.value").await, Some(json!(1)));
188        assert_eq!(second.get("branch.value").await, Some(json!(2)));
189    }
190}