Skip to main content

graph_flow/
storage.rs

1use async_trait::async_trait;
2use dashmap::DashMap;
3use serde::{Deserialize, Serialize};
4use std::sync::Arc;
5
6use crate::{Context, error::{GraphError, Result}, graph::Graph};
7
8/// Session information
9///
10/// Sessions carry an optimistic-locking `version`: storage backends reject a
11/// save whose version does not match the stored one (see
12/// [`GraphError::SessionConflict`]), so concurrent runs of the same session
13/// fail loudly instead of silently losing updates.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Session {
16    pub id: String,
17    pub graph_id: String,
18    pub current_task_id: String,
19    /// Optional status message from the last executed task
20    pub status_message: Option<String>,
21    pub context: crate::context::Context,
22    /// Optimistic-locking version, incremented by storage on every successful
23    /// save. Defaults to 0 for new / pre-0.6 sessions.
24    #[serde(default)]
25    pub version: u64,
26}
27
28impl Session {
29    /// Create a new session positioned at the given task.
30    ///
31    /// `graph_id` defaults to `"default"`; use [`Session::with_graph_id`] if
32    /// you store multiple graphs.
33    pub fn new_from_task(sid: String, task_name: &str) -> Self {
34        Self {
35            id: sid,
36            graph_id: "default".to_string(),
37            current_task_id: task_name.to_string(),
38            status_message: None,
39            context: Context::new(),
40            version: 0,
41        }
42    }
43
44    /// Set the graph this session belongs to.
45    pub fn with_graph_id(mut self, graph_id: impl Into<String>) -> Self {
46        self.graph_id = graph_id.into();
47        self
48    }
49}
50
51/// Trait for storing and retrieving graphs
52#[async_trait]
53pub trait GraphStorage: Send + Sync {
54    async fn save(&self, id: String, graph: Arc<Graph>) -> Result<()>;
55    async fn get(&self, id: &str) -> Result<Option<Arc<Graph>>>;
56    async fn delete(&self, id: &str) -> Result<()>;
57}
58
59/// Trait for storing and retrieving sessions.
60///
61/// Implementations must enforce optimistic locking: `save` succeeds only when
62/// the incoming session's `version` matches the stored version (or the
63/// session does not exist yet), and increments the stored version on success.
64/// A mismatch returns [`GraphError::SessionConflict`].
65#[async_trait]
66pub trait SessionStorage: Send + Sync {
67    async fn save(&self, session: Session) -> Result<()>;
68    async fn get(&self, id: &str) -> Result<Option<Session>>;
69    async fn delete(&self, id: &str) -> Result<()>;
70}
71
72/// In-memory implementation of GraphStorage
73pub struct InMemoryGraphStorage {
74    graphs: DashMap<String, Arc<Graph>>,
75}
76
77impl Default for InMemoryGraphStorage {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83impl InMemoryGraphStorage {
84    pub fn new() -> Self {
85        Self {
86            graphs: DashMap::new(),
87        }
88    }
89}
90
91#[async_trait]
92impl GraphStorage for InMemoryGraphStorage {
93    async fn save(&self, id: String, graph: Arc<Graph>) -> Result<()> {
94        self.graphs.insert(id, graph);
95        Ok(())
96    }
97
98    async fn get(&self, id: &str) -> Result<Option<Arc<Graph>>> {
99        Ok(self.graphs.get(id).map(|entry| entry.clone()))
100    }
101
102    async fn delete(&self, id: &str) -> Result<()> {
103        self.graphs.remove(id);
104        Ok(())
105    }
106}
107
108/// In-memory implementation of SessionStorage
109pub struct InMemorySessionStorage {
110    sessions: DashMap<String, Session>,
111}
112
113impl Default for InMemorySessionStorage {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119impl InMemorySessionStorage {
120    pub fn new() -> Self {
121        Self {
122            sessions: DashMap::new(),
123        }
124    }
125}
126
127#[async_trait]
128impl SessionStorage for InMemorySessionStorage {
129    async fn save(&self, mut session: Session) -> Result<()> {
130        // Optimistic locking: the entry lock makes check-and-swap atomic.
131        match self.sessions.entry(session.id.clone()) {
132            dashmap::Entry::Occupied(mut occupied) => {
133                let stored_version = occupied.get().version;
134                if stored_version != session.version {
135                    return Err(GraphError::SessionConflict(format!(
136                        "Session '{}' was modified concurrently (stored version {}, \
137                         attempted save from version {})",
138                        session.id, stored_version, session.version
139                    )));
140                }
141                session.version += 1;
142                occupied.insert(session);
143            }
144            dashmap::Entry::Vacant(vacant) => {
145                session.version += 1;
146                vacant.insert(session);
147            }
148        }
149        Ok(())
150    }
151
152    async fn get(&self, id: &str) -> Result<Option<Session>> {
153        Ok(self.sessions.get(id).map(|entry| entry.clone()))
154    }
155
156    async fn delete(&self, id: &str) -> Result<()> {
157        self.sessions.remove(id);
158        Ok(())
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[tokio::test]
167    async fn test_session_version_conflict() {
168        let storage = InMemorySessionStorage::new();
169
170        let session = Session::new_from_task("s1".to_string(), "task");
171        storage.save(session).await.unwrap(); // stored version becomes 1
172
173        // Two clients load the same session (version 1)
174        let a = storage.get("s1").await.unwrap().unwrap();
175        let b = storage.get("s1").await.unwrap().unwrap();
176        assert_eq!(a.version, 1);
177
178        // First save wins...
179        storage.save(a).await.unwrap(); // stored version becomes 2
180
181        // ...second save conflicts
182        let err = storage.save(b).await.unwrap_err();
183        assert!(matches!(err, GraphError::SessionConflict(_)), "got: {err:?}");
184    }
185
186    #[tokio::test]
187    async fn test_session_save_reload_cycle() {
188        let storage = InMemorySessionStorage::new();
189
190        let session = Session::new_from_task("s1".to_string(), "task");
191        storage.save(session).await.unwrap();
192
193        // Normal load -> save cycles keep working
194        for expected_version in 1..4 {
195            let s = storage.get("s1").await.unwrap().unwrap();
196            assert_eq!(s.version, expected_version);
197            storage.save(s).await.unwrap();
198        }
199    }
200}