Skip to main content

trusty_memory/
session_store_cache.rs

1//! LRU-bounded cache of per-palace `ChatSessionStore` handles (issue #4639).
2//!
3//! Why: `AppState::session_stores` was a plain `DashMap<String,
4//! Arc<ChatSessionStore>>` with no `remove`, no TTL, and no cap — every palace
5//! the daemon ever touched leaked one `chat_sessions.redb` file descriptor for
6//! the process lifetime. A live daemon was measured holding 844 such handles
7//! (all 844 pointing at files already unlinked from disk) against an 8 192 fd
8//! ceiling, growing ~250-300/day. `PalaceRegistry` already solved exactly this
9//! failure class for kg/usearch/recall via an LRU (issue #463); this module
10//! applies the same shape to the one file type that registry never tracked.
11//!
12//! What: [`SessionStoreCache`] — a `parking_lot::Mutex<LruCache<..>>` (opened
13//! unbounded, trimmed manually to [`SessionStoreCache::capacity`]) that opens a
14//! store on miss, promotes on hit, and evicts from the cold end once resident
15//! entries exceed the cap. Dropping the last `Arc` closes the redb `Database`
16//! and releases its fd; the next request reopens from disk transparently.
17//!
18//! Two invariants make eviction safe, both enforced under the single cache
19//! mutex:
20//!   1. **Never evict a store a caller still holds.** redb takes an exclusive
21//!      `flock`, and `ChatSessionStore::open` has no snapshot fallback, so a
22//!      second open of a file that is still open *in this same process* fails
23//!      hard with `Database already open. Cannot acquire lock.` (reproduced
24//!      directly while diagnosing #4639). The chat streaming handler holds an
25//!      `Arc` across a whole SSE response (`chat::handler`), so unconditional
26//!      LRU eviction would turn a leak into an outage. Eviction therefore skips
27//!      any entry whose `Arc::strong_count() > 1` and overshoots the cap rather
28//!      than closing a store out from under its user.
29//!   2. **Exactly one store per palace.** The open runs while the cache mutex
30//!      is held, so two concurrent callers for the same id cannot both open the
31//!      file (the `DatabaseAlreadyOpen` path `PalaceRegistry` guards with
32//!      per-id mutexes). The critical section is pure blocking I/O with no
33//!      `.await`, so `parking_lot::Mutex` is safe here.
34//!
35//! Test: `tests::open_handles_are_bounded_by_cap`,
36//! `session_store_fd_count_is_bounded_by_cap` (in
37//! `tests/session_store_fd_bound.rs` — needs its own process to measure real
38//! fds; see that file's header),
39//! `tests::evicted_store_reopens_with_data_intact`,
40//! `tests::in_use_store_is_never_evicted`,
41//! `tests::concurrent_callers_share_one_store`,
42//! `tests::remove_drops_cached_handle`.
43
44use anyhow::Result;
45use lru::LruCache;
46use parking_lot::Mutex;
47use std::path::Path;
48use std::sync::Arc;
49use trusty_common::memory_core::store::ChatSessionStore;
50
51/// Environment variable overriding the resident chat-session-store cap.
52///
53/// Why: mirrors `TRUSTY_MEMORY_MAX_OPEN_PALACES` so an operator close to the fd
54/// ceiling can shrink the chat cache — or a host with a high limit can raise
55/// it — without a rebuild.
56/// What: parsed by [`max_open_session_stores_from_env`].
57/// Test: `tests::env_override_is_honoured`.
58pub const MAX_OPEN_SESSION_STORES_ENV: &str = "TRUSTY_MEMORY_MAX_OPEN_SESSION_STORES";
59
60/// Default maximum number of `chat_sessions.redb` handles held open at once.
61///
62/// Why: deliberately half of `DEFAULT_MAX_OPEN_PALACES` (64), for two reasons.
63/// (a) Access pattern: chat is a far colder path than KG recall — the measured
64/// production workload opens a palace's session store once, appends a short
65/// burst of turns, and never returns to it (~250-300 *distinct* palaces/day),
66/// so cache hit rate past a small working set is ~0 and a larger cap buys
67/// nothing but resident fds. (b) fd budget: 64 palaces × 3 registry files + 32
68/// chat files = 224, which still fits inside the 256-fd macOS soft limit that
69/// motivated issues #462/#463 — the fix holds even when the daemon runs outside
70/// launchd's 8 192 ceiling. 32 also leaves ample headroom over the only
71/// entries eviction cannot reclaim: concurrently-streaming chat sessions.
72/// What: a compile-time constant, overridable per-instance via
73/// [`SessionStoreCache::with_max_open`] or by [`MAX_OPEN_SESSION_STORES_ENV`].
74/// Test: `tests::open_handles_are_bounded_by_cap`.
75pub const DEFAULT_MAX_OPEN_SESSION_STORES: usize = 32;
76
77/// Resolve the effective cap from the environment.
78///
79/// Why: centralises the parse so construction and diagnostics agree on both the
80/// value and the fallback.
81/// What: reads [`MAX_OPEN_SESSION_STORES_ENV`]; returns its parsed `usize` when
82/// set to a value `>= 1`, else [`DEFAULT_MAX_OPEN_SESSION_STORES`].
83/// Test: `tests::env_override_is_honoured`.
84pub fn max_open_session_stores_from_env() -> usize {
85    std::env::var(MAX_OPEN_SESSION_STORES_ENV)
86        .ok()
87        .and_then(|v| v.trim().parse::<usize>().ok())
88        .filter(|&n| n >= 1)
89        .unwrap_or(DEFAULT_MAX_OPEN_SESSION_STORES)
90}
91
92/// LRU-bounded, thread-safe cache of open per-palace chat-session stores.
93///
94/// Why/What: see the module docs. Cloning is cheap — callers share one instance
95/// behind an `Arc` on `AppState`.
96/// Test: see the module-level test list.
97pub struct SessionStoreCache {
98    /// Opened *unbounded* and trimmed by [`SessionStoreCache::trim`] instead of
99    /// relying on `LruCache`'s own capacity, because the built-in eviction
100    /// drops the cold-end entry unconditionally — including one a caller is
101    /// still using, which is precisely the `DatabaseAlreadyOpen` hazard
102    /// invariant (1) exists to prevent.
103    inner: Mutex<LruCache<String, Arc<ChatSessionStore>>>,
104    capacity: usize,
105}
106
107impl SessionStoreCache {
108    /// Build a cache with an explicit resident-handle cap.
109    ///
110    /// Why: tests force eviction with a tiny cap; operators tune production via
111    /// [`SessionStoreCache::from_env`].
112    /// What: clamps `max_open` to at least 1 and opens an unbounded `LruCache`
113    /// that [`SessionStoreCache::trim`] holds down to that cap.
114    /// Test: `tests::open_handles_are_bounded_by_cap`.
115    pub fn with_max_open(max_open: usize) -> Self {
116        Self {
117            inner: Mutex::new(LruCache::unbounded()),
118            capacity: max_open.max(1),
119        }
120    }
121
122    /// Build a cache whose cap comes from the environment.
123    ///
124    /// Why: the daemon's construction path wants the operator-tunable value.
125    /// What: `with_max_open(max_open_session_stores_from_env())`.
126    /// Test: `tests::env_override_is_honoured`.
127    pub fn from_env() -> Self {
128        Self::with_max_open(max_open_session_stores_from_env())
129    }
130
131    /// The resident-handle cap this cache enforces.
132    ///
133    /// Test: `tests::env_override_is_honoured`.
134    pub fn capacity(&self) -> usize {
135        self.capacity
136    }
137
138    /// Number of stores currently resident (open) in the cache.
139    ///
140    /// Test: `tests::open_handles_are_bounded_by_cap`.
141    pub fn len(&self) -> usize {
142        self.inner.lock().len()
143    }
144
145    /// Whether no store is currently resident.
146    ///
147    /// Test: `tests::remove_drops_cached_handle`.
148    pub fn is_empty(&self) -> bool {
149        self.len() == 0
150    }
151
152    /// Return the cached store for `palace_id`, opening it under `dir` on miss.
153    ///
154    /// Why: the single entry point callers use, so the mutex-held open (which
155    /// enforces one-store-per-palace) cannot be bypassed.
156    /// What: promotes an existing entry to most-recently-used and returns it;
157    /// on a miss creates `dir`, opens `dir/chat_sessions.db` (rewritten by
158    /// `ChatSessionStore::open` to `chat_sessions.redb`), inserts it, then
159    /// trims the cold end back to the cap.
160    /// Test: `tests::evicted_store_reopens_with_data_intact`,
161    /// `tests::concurrent_callers_share_one_store`.
162    pub fn get_or_open(&self, palace_id: &str, dir: &Path) -> Result<Arc<ChatSessionStore>> {
163        let mut cache = self.inner.lock();
164        if let Some(existing) = cache.get(palace_id) {
165            return Ok(existing.clone());
166        }
167        std::fs::create_dir_all(dir)
168            .map_err(|e| anyhow::anyhow!("create palace dir {}: {e}", dir.display()))?;
169        let store = Arc::new(ChatSessionStore::open(&dir.join("chat_sessions.db"))?);
170        cache.put(palace_id.to_string(), store.clone());
171        Self::trim(&mut cache, self.capacity);
172        Ok(store)
173    }
174
175    /// Drop the cached handle for `palace_id`, closing its fd if unused.
176    ///
177    /// Why: palace deletion must not leave a handle pinning the inode of a
178    /// directory that has just been unlinked — the exact shape of the 844
179    /// deleted-but-open handles measured in #4639.
180    /// What: `LruCache::pop`; a no-op when the palace was never opened. If a
181    /// caller still holds an `Arc`, that caller's handle stays valid until it
182    /// drops (`Arc` semantics) — the cache simply stops handing it out.
183    /// Test: `tests::remove_drops_cached_handle`.
184    pub fn remove(&self, palace_id: &str) {
185        self.inner.lock().pop(palace_id);
186    }
187
188    /// Evict cold, unused entries until at most `capacity` remain.
189    ///
190    /// Why: enforces the bound while honouring invariant (1) — an entry whose
191    /// `Arc` escaped to a live caller is skipped, never closed underneath it.
192    /// Checking `strong_count` under the cache mutex is sound because every
193    /// clone is minted by `get_or_open`, which holds that same mutex: a count
194    /// of 1 here means the cache is provably the only owner.
195    /// What: repeatedly scans coldest-first for an entry with
196    /// `Arc::strong_count == 1` and pops it. If every resident store is in use
197    /// it stops, deliberately overshooting the cap rather than corrupting an
198    /// in-flight caller (the overshoot is bounded by live concurrency and
199    /// reclaimed on the next call once those callers drop).
200    /// Test: `tests::in_use_store_is_never_evicted`.
201    fn trim(cache: &mut LruCache<String, Arc<ChatSessionStore>>, capacity: usize) {
202        while cache.len() > capacity {
203            let victim = cache
204                .iter()
205                .rev()
206                .find(|(_, store)| Arc::strong_count(store) == 1)
207                .map(|(key, _)| key.clone());
208            match victim {
209                Some(key) => {
210                    cache.pop(&key);
211                }
212                None => break,
213            }
214        }
215    }
216}
217
218impl Default for SessionStoreCache {
219    fn default() -> Self {
220        Self::with_max_open(DEFAULT_MAX_OPEN_SESSION_STORES)
221    }
222}
223
224impl std::fmt::Debug for SessionStoreCache {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        f.debug_struct("SessionStoreCache")
227            .field("capacity", &self.capacity)
228            .field("resident", &self.len())
229            .finish()
230    }
231}
232
233#[cfg(test)]
234mod tests;