mongreldb_server/sessions.rs
1//! Server-side session store enabling cross-request interactive transactions
2//! over the daemon (PLAN.md Phase 6 #10).
3//!
4//! Each session holds a long-lived [`MongrelSession`] whose `sql_txn` staging
5//! survives across `run()` calls. Because `BEGIN`/`COMMIT` only stage ops
6//! logically (the core `Transaction` is opened at `COMMIT`, not `BEGIN`), an
7//! idle session with an open transaction does **not** pin an MVCC epoch — so
8//! abandoned sessions cost only the staged-ops memory until the idle reaper
9//! evicts them.
10//!
11//! ## Safety rails
12//! - **Auth-bound ownership**: a session is owned by the principal that created
13//! it; lookups by a different principal return `None` (treated as 404 to
14//! avoid confirming a session's existence to a non-owner).
15//! - **Per-session serialization**: a `tokio::sync::Mutex` guards each session
16//! so two concurrent requests on the same token cannot interleave a
17//! `BEGIN`/`INSERT`/`COMMIT` sequence.
18//! - **Bounded capacity**: `max_sessions` rejects new sessions with 503 once
19//! full.
20//! - **Idle reaper**: [`SessionStore::sweep_idle`] drops sessions whose
21//! `last_used` exceeds the configured timeout, discarding any staged
22//! transaction (effective rollback).
23
24use std::collections::HashMap;
25use std::sync::atomic::{AtomicBool, Ordering};
26use std::sync::Arc;
27use std::time::{Duration, Instant};
28
29use mongreldb_query::MongrelSession;
30
31/// One pooled session plus its ownership and liveness metadata.
32pub struct SessionEntry {
33 /// The live query session — reused across requests via `X-Session-ID`.
34 pub session: Arc<MongrelSession>,
35 /// Principal that created the session (`username`, `"token"`, or
36 /// `"anonymous"`). Requests from any other principal are rejected.
37 pub owner: String,
38 last_used: std::sync::Mutex<Instant>,
39 /// Held for the duration of a request to serialize per-session access.
40 pub lock: tokio::sync::Mutex<()>,
41 /// Set when the session is closed/evicted. `get()` rejects closed entries,
42 /// and request handlers re-check it after acquiring the lock so an in-flight
43 /// request that obtained an `Arc` just before eviction aborts rather than
44 /// committing into a closed session.
45 closed: AtomicBool,
46}
47
48impl SessionEntry {
49 pub(crate) fn touch(&self) {
50 if let Ok(mut t) = self.last_used.lock() {
51 *t = Instant::now();
52 }
53 }
54
55 /// Whether this entry has been closed/evicted and must reject new work.
56 pub(crate) fn is_closed(&self) -> bool {
57 self.closed.load(Ordering::Acquire)
58 }
59
60 fn mark_closed(&self) {
61 self.closed.store(true, Ordering::Release);
62 }
63
64 fn idle_for_at_least(&self, timeout: Duration) -> bool {
65 self.last_used
66 .lock()
67 .map(|t| t.elapsed() >= timeout)
68 .unwrap_or(false)
69 }
70}
71
72/// Token-keyed pool of live sessions. Threaded through `AppState` as
73/// `Arc<SessionStore>` so the idle reaper (a detached thread) shares the same
74/// map as request handlers.
75pub struct SessionStore {
76 sessions: std::sync::Mutex<HashMap<String, Arc<SessionEntry>>>,
77 max_sessions: usize,
78 idle_timeout: Duration,
79}
80
81impl SessionStore {
82 /// New store allowing up to `max_sessions` concurrent sessions, each evicted
83 /// after `idle_timeout` of inactivity.
84 pub fn new(max_sessions: usize, idle_timeout: Duration) -> Self {
85 Self {
86 sessions: std::sync::Mutex::new(HashMap::new()),
87 // Always allow at least one session so the feature is usable when
88 // a caller passes a zero/negative-feeling cap.
89 max_sessions: max_sessions.max(1),
90 idle_timeout,
91 }
92 }
93
94 /// Register a new session under a fresh opaque token. Returns the token, or
95 /// `None` if the store is at capacity (caller maps this to HTTP 503).
96 pub fn create(&self, session: MongrelSession, owner: String) -> Option<String> {
97 let mut guard = self.sessions.lock().ok()?;
98 if guard.len() >= self.max_sessions {
99 return None;
100 }
101 let token = random_token()?;
102 guard.insert(
103 token.clone(),
104 Arc::new(SessionEntry {
105 session: Arc::new(session),
106 owner,
107 last_used: std::sync::Mutex::new(Instant::now()),
108 lock: tokio::sync::Mutex::new(()),
109 closed: AtomicBool::new(false),
110 }),
111 );
112 Some(token)
113 }
114
115 /// Look up a session by token, verifying the caller owns it and the session
116 /// is not closed. Returns a cloned `Arc<SessionEntry>` (the store lock is
117 /// released immediately; the caller holds the entry's per-session lock for
118 /// the request duration, and must re-check `is_closed()` after locking).
119 /// Returns `None` for an unknown token, an ownership mismatch, or a closed
120 /// session.
121 pub fn get(&self, token: &str, owner: &str) -> Option<Arc<SessionEntry>> {
122 let guard = self.sessions.lock().ok()?;
123 let entry = guard.get(token)?;
124 if entry.owner != owner || entry.is_closed() {
125 return None;
126 }
127 Some(Arc::clone(entry))
128 }
129
130 /// Remove and drop a session, marking it closed first so any request that
131 /// already holds an `Arc` aborts after acquiring the lock. Returns whether a
132 /// session was removed. Rejects non-owners.
133 pub fn close(&self, token: &str, owner: &str) -> bool {
134 if let Ok(mut guard) = self.sessions.lock() {
135 if let Some(entry) = guard.get(token) {
136 if entry.owner != owner {
137 return false;
138 }
139 entry.mark_closed();
140 }
141 return guard.remove(token).is_some();
142 }
143 false
144 }
145
146 /// Evict every session idle for longer than the configured timeout, skipping
147 /// any session with an in-flight request (per-session lock held). Called
148 /// periodically by [`spawn_session_reaper`]. Returns the count evicted.
149 pub fn sweep_idle(&self) -> usize {
150 let Ok(mut guard) = self.sessions.lock() else {
151 return 0;
152 };
153 let timeout = self.idle_timeout;
154 // Collect tokens to evict: idle AND not currently in-flight (its
155 // per-session lock is acquirable). Holding the store lock prevents new
156 // lookups while we decide; try_lock fails exactly when a request holds
157 // or is acquiring the session lock.
158 let to_evict: Vec<String> = guard
159 .iter()
160 .filter_map(|(token, entry)| {
161 if entry.idle_for_at_least(timeout) && entry.lock.try_lock().is_ok() {
162 Some(token.clone())
163 } else {
164 None
165 }
166 })
167 .collect();
168 let count = to_evict.len();
169 for token in &to_evict {
170 if let Some(entry) = guard.remove(token) {
171 entry.mark_closed();
172 }
173 }
174 count
175 }
176
177 /// Current number of live sessions (diagnostics / tests).
178 pub fn len(&self) -> usize {
179 self.sessions.lock().map(|g| g.len()).unwrap_or(0)
180 }
181
182 pub fn is_empty(&self) -> bool {
183 self.len() == 0
184 }
185}
186
187/// Background idle-session reaper. Sweeps every 30 s, evicting sessions whose
188/// `last_used` exceeds the configured timeout. Errors are logged and never
189/// abort the sweep. Mirrors `spawn_auto_compactor`'s pattern.
190pub fn spawn_session_reaper(store: Arc<SessionStore>) {
191 std::thread::Builder::new()
192 .name("mongreldb-session-reaper".into())
193 .spawn(move || loop {
194 std::thread::sleep(Duration::from_secs(30));
195 let evicted = store.sweep_idle();
196 if evicted > 0 {
197 eprintln!("[session-reaper] evicted {evicted} idle session(s)");
198 }
199 })
200 .expect("spawn session-reaper");
201}
202
203/// Generate an opaque, cryptographically-random session token. Reads 16 bytes
204/// from `/dev/urandom` (Linux/macOS) and hex-encodes them. Session tokens are
205/// bearer capabilities, so there is NO predictable fallback: if the OS RNG is
206/// unavailable, this returns `None` and the caller rejects session creation
207/// (HTTP 503) rather than handing out a guessable token.
208fn random_token() -> Option<String> {
209 let bytes = read_urandom(16)?;
210 let mut n = 0u128;
211 for &b in &bytes {
212 n = (n << 8) | b as u128;
213 }
214 Some(format!("{n:032x}"))
215}
216
217fn read_urandom(n: usize) -> Option<Vec<u8>> {
218 use std::io::Read;
219 let mut f = std::fs::File::open("/dev/urandom").ok()?;
220 let mut buf = vec![0u8; n];
221 f.read_exact(&mut buf).ok()?;
222 Some(buf)
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use mongreldb_core::Database;
229 use tempfile::tempdir;
230
231 fn make_session() -> MongrelSession {
232 let dir = tempdir().unwrap();
233 let db = Arc::new(Database::create(dir.path()).unwrap());
234 // Keep the TempDir alive for the session's lifetime by leaking it; tests
235 // are short-lived and the OS reclaims on exit.
236 std::mem::forget(dir);
237 MongrelSession::open(db).unwrap()
238 }
239
240 #[test]
241 fn create_and_get_roundtrip() {
242 let store = SessionStore::new(8, Duration::from_secs(60));
243 let token = store.create(make_session(), "alice".into()).unwrap();
244 assert!(store.get(&token, "alice").is_some());
245 // Wrong owner → None (ownership enforced).
246 assert!(store.get(&token, "eve").is_none());
247 assert_eq!(store.len(), 1);
248 }
249
250 #[test]
251 fn close_removes_session() {
252 let store = SessionStore::new(8, Duration::from_secs(60));
253 let token = store.create(make_session(), "alice".into()).unwrap();
254 assert!(store.close(&token, "alice"));
255 assert!(store.get(&token, "alice").is_none());
256 assert!(store.is_empty());
257 // Non-owner cannot close.
258 let t2 = store.create(make_session(), "bob".into()).unwrap();
259 assert!(!store.close(&t2, "alice"));
260 assert_eq!(store.len(), 1);
261 }
262
263 #[test]
264 fn capacity_limit_rejects_new_sessions() {
265 let store = SessionStore::new(1, Duration::from_secs(60));
266 assert!(store.create(make_session(), "a".into()).is_some());
267 // At capacity → None.
268 assert!(store.create(make_session(), "b".into()).is_none());
269 assert_eq!(store.len(), 1);
270 }
271
272 #[test]
273 fn sweep_idle_evicts_stale_sessions() {
274 let store = SessionStore::new(8, Duration::from_millis(1));
275 let token = store.create(make_session(), "alice".into()).unwrap();
276 assert_eq!(store.len(), 1);
277 // Sleep past the idle timeout.
278 std::thread::sleep(Duration::from_millis(20));
279 let evicted = store.sweep_idle();
280 assert_eq!(evicted, 1);
281 assert!(store.get(&token, "alice").is_none());
282 assert!(store.is_empty());
283 }
284}