Skip to main content

adk_managed/
state_store.rs

1//! Where managed session state lives, and whether it survives the process.
2//!
3//! [`CheckpointManager`](crate::CheckpointManager) holds events and run state in a `Vec` and a
4//! struct field. That supports replay and resume *within* a process and nothing more: a crash
5//! loses event history, parked-tool state, sequence position, and lifecycle status even when the
6//! nested Runner has already written conversation events through the `SessionService`. A new
7//! process cannot resume a session another process started.
8//!
9//! The problem was not the in-memory implementation — it is a reasonable default — but that
10//! nothing distinguished it from a durable one. There was no seam to implement against, no way
11//! for a caller to ask what guarantee it had, and the crate described itself as durable.
12//!
13//! [`ManagedStateStore`] is that seam. [`InMemoryManagedStateStore`] is the in-memory backend,
14//! named as such, reporting [`Durability::ProcessLocal`]. A durable implementation is not
15//! shipped; a caller can now detect that instead of assuming otherwise.
16//!
17//! # Example
18//!
19//! ```rust
20//! use adk_managed::state_store::{Durability, InMemoryManagedStateStore, ManagedStateStore};
21//!
22//! let store = InMemoryManagedStateStore::new();
23//! assert_eq!(store.durability(), Durability::ProcessLocal);
24//! assert!(!store.durability().survives_process_loss());
25//! ```
26
27use serde::{Deserialize, Serialize};
28
29use crate::checkpoint::RunState;
30use crate::types::{RuntimeError, SessionEvent};
31use async_trait::async_trait;
32use std::collections::HashMap;
33use std::sync::Arc;
34use tokio::sync::RwLock;
35
36/// What a store guarantees about state after the writing process ends.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Durability {
39    /// State lives only in this process. Replay and resume work while it runs; a crash loses
40    /// everything the store held, and another process cannot resume its sessions.
41    ProcessLocal,
42    /// State is written to a backing store before the write is acknowledged, so another
43    /// process can reconstruct a session after loss.
44    CrashDurable,
45}
46
47impl Durability {
48    /// Whether state written to this store outlives the process that wrote it.
49    ///
50    /// Callers that require durability should check this at startup rather than infer it from
51    /// the presence of checkpointing.
52    pub fn survives_process_loss(&self) -> bool {
53        matches!(self, Durability::CrashDurable)
54    }
55}
56
57/// A snapshot of one managed session's state.
58///
59/// Not `PartialEq`: `SessionEvent` is `#[non_exhaustive]` and not comparable, so compare the
60/// `run_state` and event count rather than whole snapshots.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ManagedSessionState {
63    /// Events recorded for the session, in order.
64    pub events: Vec<SessionEvent>,
65    /// Sequence position, parked tool calls, and lifecycle status.
66    pub run_state: RunState,
67}
68
69/// Storage for managed session state.
70///
71/// Implementations decide the durability guarantee and must report it truthfully through
72/// [`ManagedStateStore::durability`], because that is what a caller uses to decide whether
73/// resume-after-restart is available.
74#[async_trait]
75pub trait ManagedStateStore: Send + Sync + std::fmt::Debug {
76    /// What this store guarantees after process loss.
77    fn durability(&self) -> Durability;
78
79    /// Records the state of `session_id`, replacing any previous snapshot.
80    ///
81    /// # Errors
82    ///
83    /// Returns [`RuntimeError`] when the backing store rejects the write. A durable
84    /// implementation must not acknowledge before the write is persisted, since the whole
85    /// point of the guarantee is that an acknowledged checkpoint is recoverable.
86    async fn save(&self, session_id: &str, state: ManagedSessionState) -> Result<(), RuntimeError>;
87
88    /// The recorded state for `session_id`, or `None` when nothing is stored.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`RuntimeError`] when the backing store cannot be read.
93    async fn load(&self, session_id: &str) -> Result<Option<ManagedSessionState>, RuntimeError>;
94
95    /// Removes any state for `session_id`.
96    ///
97    /// Succeeds when nothing was stored, so deletion is idempotent.
98    ///
99    /// # Errors
100    ///
101    /// Returns [`RuntimeError`] when the backing store cannot complete the removal.
102    async fn delete(&self, session_id: &str) -> Result<(), RuntimeError>;
103
104    /// The sessions this store holds state for.
105    ///
106    /// A durable implementation uses this at startup to reconstruct sessions; a process-local
107    /// one only ever reports sessions from the current process.
108    ///
109    /// # Errors
110    ///
111    /// Returns [`RuntimeError`] when the backing store cannot be enumerated.
112    async fn session_ids(&self) -> Result<Vec<String>, RuntimeError>;
113}
114
115/// The in-memory managed state store.
116///
117/// The default, and the only implementation that ships. Named explicitly so its guarantee is
118/// visible at the call site rather than implied by the absence of an alternative.
119#[derive(Debug, Default)]
120pub struct InMemoryManagedStateStore {
121    sessions: Arc<RwLock<HashMap<String, ManagedSessionState>>>,
122}
123
124impl InMemoryManagedStateStore {
125    /// Creates an empty store.
126    pub fn new() -> Self {
127        Self::default()
128    }
129}
130
131#[async_trait]
132impl ManagedStateStore for InMemoryManagedStateStore {
133    fn durability(&self) -> Durability {
134        Durability::ProcessLocal
135    }
136
137    async fn save(&self, session_id: &str, state: ManagedSessionState) -> Result<(), RuntimeError> {
138        self.sessions.write().await.insert(session_id.to_string(), state);
139        Ok(())
140    }
141
142    async fn load(&self, session_id: &str) -> Result<Option<ManagedSessionState>, RuntimeError> {
143        Ok(self.sessions.read().await.get(session_id).cloned())
144    }
145
146    async fn delete(&self, session_id: &str) -> Result<(), RuntimeError> {
147        self.sessions.write().await.remove(session_id);
148        Ok(())
149    }
150
151    async fn session_ids(&self) -> Result<Vec<String>, RuntimeError> {
152        Ok(self.sessions.read().await.keys().cloned().collect())
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::checkpoint::RunState;
160    use crate::types::SessionStatus;
161
162    fn state() -> ManagedSessionState {
163        ManagedSessionState {
164            events: Vec::new(),
165            run_state: RunState {
166                seq: 7,
167                pending_tool_ids: vec!["call-1".to_string()],
168                status: SessionStatus::Running,
169            },
170        }
171    }
172
173    #[tokio::test]
174    async fn the_in_memory_store_reports_its_own_guarantee() {
175        let store = InMemoryManagedStateStore::new();
176        assert_eq!(store.durability(), Durability::ProcessLocal);
177        assert!(
178            !store.durability().survives_process_loss(),
179            "a caller requiring resume-after-restart must be able to detect that it is absent"
180        );
181    }
182
183    #[tokio::test]
184    async fn a_saved_snapshot_round_trips() {
185        let store = InMemoryManagedStateStore::new();
186        store.save("session-1", state()).await.unwrap();
187
188        let loaded = store.load("session-1").await.unwrap().expect("saved state must load");
189        assert_eq!(loaded.run_state, state().run_state);
190        assert_eq!(loaded.events.len(), state().events.len());
191        assert_eq!(store.session_ids().await.unwrap(), vec!["session-1".to_string()]);
192    }
193
194    #[tokio::test]
195    async fn an_unknown_session_loads_as_none_and_deletes_without_error() {
196        let store = InMemoryManagedStateStore::new();
197        assert!(store.load("missing").await.unwrap().is_none());
198        assert!(store.delete("missing").await.is_ok(), "deletion is idempotent");
199    }
200
201    #[tokio::test]
202    async fn saving_twice_replaces_the_snapshot() {
203        let store = InMemoryManagedStateStore::new();
204        store.save("session-1", state()).await.unwrap();
205
206        let mut later = state();
207        later.run_state.seq = 9;
208        store.save("session-1", later.clone()).await.unwrap();
209
210        let loaded = store.load("session-1").await.unwrap().expect("state must load");
211        assert_eq!(loaded.run_state.seq, later.run_state.seq);
212        assert_eq!(store.session_ids().await.unwrap().len(), 1, "not appended twice");
213    }
214
215    #[tokio::test]
216    async fn a_new_store_shares_nothing_with_the_old_one() {
217        // This is the shape of the gap: state written by one store is invisible to another,
218        // which is what happens across a process restart. A `CrashDurable` implementation
219        // backed by shared storage would find the session here.
220        let first = InMemoryManagedStateStore::new();
221        first.save("session-1", state()).await.unwrap();
222
223        let second = InMemoryManagedStateStore::new();
224        assert!(
225            second.load("session-1").await.unwrap().is_none(),
226            "process-local state does not cross process boundaries"
227        );
228        assert!(second.session_ids().await.unwrap().is_empty());
229    }
230}
231
232/// Records each session as one JSON file under a directory.
233///
234/// The first store here that reports [`Durability::CrashDurable`]: a write is fsynced
235/// through a temporary file and a rename, so another process can reconstruct a session
236/// after loss. `InMemoryManagedStateStore` cannot, and says so.
237///
238/// One file per session rather than one file for all of them, so two sessions being
239/// checkpointed at once do not contend, and a corrupt write can only lose the session
240/// it belonged to.
241///
242/// # Example
243///
244/// ```no_run
245/// use adk_managed::state_store::{Durability, FileManagedStateStore, ManagedStateStore};
246///
247/// let store = FileManagedStateStore::new("/var/lib/adk/sessions");
248/// assert_eq!(store.durability(), Durability::CrashDurable);
249/// ```
250#[derive(Debug)]
251pub struct FileManagedStateStore {
252    root: std::path::PathBuf,
253}
254
255impl FileManagedStateStore {
256    /// Records sessions under `root`, creating it on the first write.
257    pub fn new(root: impl Into<std::path::PathBuf>) -> Self {
258        Self { root: root.into() }
259    }
260
261    /// The file holding one session.
262    ///
263    /// The id is percent-style escaped, so an id containing a path separator cannot
264    /// write outside `root`.
265    fn path_for(&self, session_id: &str) -> std::path::PathBuf {
266        let safe: String = session_id
267            .chars()
268            .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
269            .collect();
270        self.root.join(format!("{safe}.json"))
271    }
272
273    fn failed(action: &str, error: impl std::fmt::Display) -> RuntimeError {
274        RuntimeError::CheckpointFailed {
275            message: format!("could not {action} session state: {error}"),
276        }
277    }
278}
279
280#[async_trait::async_trait]
281impl ManagedStateStore for FileManagedStateStore {
282    fn durability(&self) -> Durability {
283        Durability::CrashDurable
284    }
285
286    async fn save(&self, session_id: &str, state: ManagedSessionState) -> Result<(), RuntimeError> {
287        // `std::fs` rather than `tokio::fs`: this crate does not enable tokio's `fs`
288        // feature, and relying on another crate in the workspace to enable it would
289        // break the moment this crate is built alone. A snapshot is small.
290        use std::io::Write;
291
292        let path = self.path_for(session_id);
293        let text = serde_json::to_vec_pretty(&state).map_err(|e| Self::failed("encode", e))?;
294        std::fs::create_dir_all(&self.root)
295            .map_err(|e| Self::failed("create the directory for", e))?;
296
297        // Written beside the target, synced, then renamed. The sync is what lets this
298        // store claim CrashDurable: the trait forbids acknowledging a write that is not
299        // yet persisted, and the rename means a reader sees a whole snapshot or none.
300        let temporary = path.with_extension("json.tmp");
301        let mut file = std::fs::File::create(&temporary)
302            .map_err(|e| Self::failed("open a temporary file for", e))?;
303        file.write_all(&text).map_err(|e| Self::failed("write", e))?;
304        file.sync_all().map_err(|e| Self::failed("sync", e))?;
305        drop(file);
306        std::fs::rename(&temporary, &path).map_err(|e| Self::failed("commit", e))
307    }
308
309    async fn load(&self, session_id: &str) -> Result<Option<ManagedSessionState>, RuntimeError> {
310        match std::fs::read(self.path_for(session_id)) {
311            Ok(bytes) => {
312                serde_json::from_slice(&bytes).map(Some).map_err(|e| Self::failed("decode", e))
313            }
314            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
315            Err(error) => Err(Self::failed("read", error)),
316        }
317    }
318
319    async fn delete(&self, session_id: &str) -> Result<(), RuntimeError> {
320        match std::fs::remove_file(self.path_for(session_id)) {
321            Ok(()) => Ok(()),
322            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
323            Err(error) => Err(Self::failed("delete", error)),
324        }
325    }
326
327    async fn session_ids(&self) -> Result<Vec<String>, RuntimeError> {
328        let entries = match std::fs::read_dir(&self.root) {
329            Ok(entries) => entries,
330            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
331            Err(error) => return Err(Self::failed("list", error)),
332        };
333        let mut ids = Vec::new();
334        for entry in entries {
335            let entry = entry.map_err(|e| Self::failed("list", e))?;
336            let name = entry.file_name().to_string_lossy().to_string();
337            if let Some(id) = name.strip_suffix(".json") {
338                ids.push(id.to_string());
339            }
340        }
341        ids.sort();
342        Ok(ids)
343    }
344}