Skip to main content

laser_sdk/
state_store.rs

1use crate::error::LaserError;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::sync::Mutex;
5use tokio::fs;
6use tokio::io::AsyncWriteExt;
7
8// Restrict a directory to its owner, where the platform has owner permissions.
9#[cfg(unix)]
10async fn restrict_to_owner(dir: &Path) -> Result<(), LaserError> {
11    use std::os::unix::fs::PermissionsExt;
12    fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
13        .await
14        .map_err(|error| LaserError::StateStore(error.to_string()))
15}
16
17#[cfg(not(unix))]
18async fn restrict_to_owner(_dir: &Path) -> Result<(), LaserError> {
19    Ok(())
20}
21
22/// A durable key/value seam for agent state: dedup persistence, conversation
23/// checkpoints, stream-cursor offsets, arbitrary per-agent state. `get`/`set`/`delete`
24/// is the one point-store vocabulary, shared with `Kv` (which implements this trait,
25/// so the managed KV store is the durable drop-in). `InMemoryStore` and `FileStore`
26/// are the self-contained defaults. Values are owned `Vec<u8>` so the trait never
27/// leaks the `bytes` crate to implementors.
28///
29/// Backend contract: keys SHOULD be non-empty and implementations MAY reject empty
30/// keys or impose key/value size limits, so the same code can fail on one backend and
31/// not another. In particular `Kv` rejects empty keys, keys over 512 B, and values
32/// over 8 MiB (surfaced as `LaserError::Kv`), while `InMemoryStore` / `FileStore` are
33/// unbounded (and report `LaserError::StateStore`). Keep keys short and non-empty for
34/// portability across backends.
35#[trait_variant::make(StateStore: Send)]
36pub trait LocalStateStore {
37    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, LaserError>;
38    async fn set(&self, key: &str, value: Vec<u8>) -> Result<(), LaserError>;
39    async fn delete(&self, key: &str) -> Result<(), LaserError>;
40}
41
42/// An in-memory `StateStore` (a `HashMap`). Fast, not durable across restarts.
43#[derive(Default)]
44pub struct InMemoryStore {
45    entries: Mutex<HashMap<String, Vec<u8>>>,
46}
47
48impl InMemoryStore {
49    /// An empty in-memory store.
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    // A panic while another caller held the lock poisons it, but the map itself
55    // is still consistent. Recovering the inner value keeps one unrelated panic
56    // from turning every later dedup lookup into a second panic.
57    fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Vec<u8>>> {
58        self.entries
59            .lock()
60            .unwrap_or_else(|poisoned| poisoned.into_inner())
61    }
62}
63
64impl StateStore for InMemoryStore {
65    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, LaserError> {
66        Ok(self.lock().get(key).cloned())
67    }
68
69    async fn set(&self, key: &str, value: Vec<u8>) -> Result<(), LaserError> {
70        self.lock().insert(key.to_owned(), value);
71        Ok(())
72    }
73
74    async fn delete(&self, key: &str) -> Result<(), LaserError> {
75        self.lock().remove(key);
76        Ok(())
77    }
78}
79
80/// File-backed store under a single directory. Keys are hex-encoded into file
81/// names, so any key is safe and no path traversal is possible. Suitable for the
82/// on-box disk the platform mounts onto a deployment.
83pub struct FileStore {
84    root: PathBuf,
85}
86
87impl FileStore {
88    /// A file-backed store rooted at `root` (keys are hex-encoded into file names).
89    pub fn new(root: impl Into<PathBuf>) -> Self {
90        Self { root: root.into() }
91    }
92
93    fn path_for(&self, key: &str) -> PathBuf {
94        let mut name = String::with_capacity(key.len() * 2);
95        for byte in key.bytes() {
96            name.push_str(&format!("{byte:02x}"));
97        }
98        self.root.join(name)
99    }
100}
101
102impl StateStore for FileStore {
103    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, LaserError> {
104        match fs::read(self.path_for(key)).await {
105            Ok(payload) => Ok(Some(payload)),
106            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
107            Err(error) => Err(LaserError::StateStore(error.to_string())),
108        }
109    }
110
111    async fn set(&self, key: &str, value: Vec<u8>) -> Result<(), LaserError> {
112        fs::create_dir_all(&self.root)
113            .await
114            .map_err(|error| LaserError::StateStore(error.to_string()))?;
115        // Dedup keys, cursors, and conversation state are this store's content,
116        // so the directory is restricted to its owner rather than left at the
117        // process umask.
118        restrict_to_owner(&self.root).await?;
119        // Atomic write: stage in a `<file>.<ulid>.tmp` file then `rename` into
120        // place. A crash partway through `fs::write` would otherwise leave a
121        // partial or zero-length file that the next `get` returns as if it
122        // were valid state. The Ulid suffix avoids a race between two
123        // concurrent `set(k, ...)` callers on the same key clobbering each
124        // other's staging file (rename order is then defined by the OS, and
125        // each rename atomically replaces the previous one).
126        let final_path = self.path_for(key);
127        let tmp_path = final_path.with_extension(format!("{}.tmp", ulid::Ulid::generate()));
128        let cleanup_on_err = |error: std::io::Error| {
129            let _ = std::fs::remove_file(&tmp_path);
130            LaserError::StateStore(error.to_string())
131        };
132        // The staged bytes are flushed to disk before the rename. Without the
133        // sync, a crash after the rename can still surface a zero-length file,
134        // because the rename may reach disk before the data does.
135        let staged = fs::File::create(&tmp_path).await.map_err(cleanup_on_err)?;
136        let mut staged = staged;
137        staged.write_all(&value).await.map_err(cleanup_on_err)?;
138        staged.sync_all().await.map_err(cleanup_on_err)?;
139        drop(staged);
140        fs::rename(&tmp_path, &final_path)
141            .await
142            .map_err(|error| LaserError::StateStore(error.to_string()))?;
143        // Fsync the directory so the rename itself is durable.
144        if let Ok(dir) = std::fs::File::open(&self.root) {
145            let _ = dir.sync_all();
146        }
147        Ok(())
148    }
149
150    async fn delete(&self, key: &str) -> Result<(), LaserError> {
151        match fs::remove_file(self.path_for(key)).await {
152            Ok(()) => Ok(()),
153            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
154            Err(error) => Err(LaserError::StateStore(error.to_string())),
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    async fn round_trips<S: StateStore>(store: S) {
164        assert_eq!(store.get("missing").await.expect("get"), None);
165        store
166            .set("k", b"v".to_vec())
167            .await
168            .expect("set should succeed");
169        assert_eq!(
170            store.get("k").await.expect("get should succeed"),
171            Some(b"v".to_vec())
172        );
173        store.delete("k").await.expect("delete should succeed");
174        assert_eq!(store.get("k").await.expect("get should succeed"), None);
175    }
176
177    #[tokio::test]
178    async fn given_an_in_memory_store_when_used_then_should_round_trip() {
179        round_trips(InMemoryStore::new()).await;
180    }
181
182    #[tokio::test]
183    async fn given_a_file_store_when_used_then_should_round_trip() {
184        let dir = std::env::temp_dir().join(format!("laser-statestore-{}", std::process::id()));
185        round_trips(FileStore::new(&dir)).await;
186        let _ = fs::remove_dir_all(&dir).await;
187    }
188}