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;