Skip to main content

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        self.take_for_close(token, owner).is_some()
135    }
136
137    /// Mark closing and remove from new lookups while returning the live entry
138    /// so the caller can cancel its queries and wait a bounded grace period.
139    pub(crate) fn take_for_close(&self, token: &str, owner: &str) -> Option<Arc<SessionEntry>> {
140        if let Ok(mut guard) = self.sessions.lock() {
141            if let Some(entry) = guard.get(token) {
142                if entry.owner != owner {
143                    return None;
144                }
145                entry.mark_closed();
146            }
147            return guard.remove(token);
148        }
149        None
150    }
151
152    /// Evict every session idle for longer than the configured timeout, skipping
153    /// any session with an in-flight request (per-session lock held). Called
154    /// periodically by [`spawn_session_reaper`]. Returns the count evicted.
155    pub fn sweep_idle(&self) -> usize {
156        let Ok(mut guard) = self.sessions.lock() else {
157            return 0;
158        };
159        let timeout = self.idle_timeout;
160        // Collect tokens to evict: idle AND not currently in-flight (its
161        // per-session lock is acquirable). Holding the store lock prevents new
162        // lookups while we decide; try_lock fails exactly when a request holds
163        // or is acquiring the session lock.
164        let to_evict: Vec<String> = guard
165            .iter()
166            .filter_map(|(token, entry)| {
167                if entry.idle_for_at_least(timeout)
168                    && entry.session.query_registry().active_for_session(token) == 0
169                    && entry.lock.try_lock().is_ok()
170                {
171                    Some(token.clone())
172                } else {
173                    None
174                }
175            })
176            .collect();
177        let count = to_evict.len();
178        for token in &to_evict {
179            if let Some(entry) = guard.remove(token) {
180                entry.mark_closed();
181            }
182        }
183        count
184    }
185
186    /// Current number of live sessions (diagnostics / tests).
187    pub fn len(&self) -> usize {
188        self.sessions.lock().map(|g| g.len()).unwrap_or(0)
189    }
190
191    pub fn is_empty(&self) -> bool {
192        self.len() == 0
193    }
194
195    /// Mark every session closed and remove it from the pool during shutdown.
196    pub(crate) fn close_all(&self) {
197        if let Ok(mut guard) = self.sessions.lock() {
198            for entry in guard.values() {
199                entry.mark_closed();
200            }
201            guard.clear();
202        }
203    }
204}
205
206/// Background idle-session reaper. Sweeps every 30 s, evicting sessions whose
207/// `last_used` exceeds the configured timeout. Errors are logged and never
208/// abort the sweep. Mirrors `spawn_auto_compactor`'s pattern.
209pub fn spawn_session_reaper(store: Arc<SessionStore>) {
210    std::thread::Builder::new()
211        .name("mongreldb-session-reaper".into())
212        .spawn(move || loop {
213            std::thread::sleep(Duration::from_secs(30));
214            let evicted = store.sweep_idle();
215            if evicted > 0 {
216                eprintln!("[session-reaper] evicted {evicted} idle session(s)");
217            }
218        })
219        .expect("spawn session-reaper");
220}
221
222/// Generate an opaque, cryptographically-random session token. Reads 16 bytes
223/// from `/dev/urandom` (Linux/macOS) and hex-encodes them. Session tokens are
224/// bearer capabilities, so there is NO predictable fallback: if the OS RNG is
225/// unavailable, this returns `None` and the caller rejects session creation
226/// (HTTP 503) rather than handing out a guessable token.
227fn random_token() -> Option<String> {
228    let bytes = read_urandom(16)?;
229    let mut n = 0u128;
230    for &b in &bytes {
231        n = (n << 8) | b as u128;
232    }
233    Some(format!("{n:032x}"))
234}
235
236fn read_urandom(n: usize) -> Option<Vec<u8>> {
237    use std::io::Read;
238    let mut f = std::fs::File::open("/dev/urandom").ok()?;
239    let mut buf = vec![0u8; n];
240    f.read_exact(&mut buf).ok()?;
241    Some(buf)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use mongreldb_core::Database;
248    use mongreldb_query::{RegisteredQueryGuard, SqlQueryOptions};
249    use tempfile::tempdir;
250
251    fn make_session() -> MongrelSession {
252        let dir = tempdir().unwrap();
253        let db = Arc::new(Database::create(dir.path()).unwrap());
254        // Keep the TempDir alive for the session's lifetime by leaking it; tests
255        // are short-lived and the OS reclaims on exit.
256        std::mem::forget(dir);
257        MongrelSession::open(db).unwrap()
258    }
259
260    #[test]
261    fn create_and_get_roundtrip() {
262        let store = SessionStore::new(8, Duration::from_secs(60));
263        let token = store.create(make_session(), "alice".into()).unwrap();
264        assert!(store.get(&token, "alice").is_some());
265        // Wrong owner → None (ownership enforced).
266        assert!(store.get(&token, "eve").is_none());
267        assert_eq!(store.len(), 1);
268    }
269
270    #[test]
271    fn close_removes_session() {
272        let store = SessionStore::new(8, Duration::from_secs(60));
273        let token = store.create(make_session(), "alice".into()).unwrap();
274        assert!(store.close(&token, "alice"));
275        assert!(store.get(&token, "alice").is_none());
276        assert!(store.is_empty());
277        // Non-owner cannot close.
278        let t2 = store.create(make_session(), "bob".into()).unwrap();
279        assert!(!store.close(&t2, "alice"));
280        assert_eq!(store.len(), 1);
281    }
282
283    #[test]
284    fn capacity_limit_rejects_new_sessions() {
285        let store = SessionStore::new(1, Duration::from_secs(60));
286        assert!(store.create(make_session(), "a".into()).is_some());
287        // At capacity → None.
288        assert!(store.create(make_session(), "b".into()).is_none());
289        assert_eq!(store.len(), 1);
290    }
291
292    #[test]
293    fn sweep_idle_evicts_stale_sessions() {
294        let store = SessionStore::new(8, Duration::from_millis(1));
295        let token = store.create(make_session(), "alice".into()).unwrap();
296        assert_eq!(store.len(), 1);
297        // Sleep past the idle timeout.
298        std::thread::sleep(Duration::from_millis(20));
299        let evicted = store.sweep_idle();
300        assert_eq!(evicted, 1);
301        assert!(store.get(&token, "alice").is_none());
302        assert!(store.is_empty());
303    }
304
305    #[test]
306    fn sweep_idle_keeps_active_queries() {
307        let store = SessionStore::new(8, Duration::from_millis(1));
308        let token = store.create(make_session(), "alice".into()).unwrap();
309        let entry = store.get(&token, "alice").unwrap();
310        let query = entry
311            .session
312            .register_query(SqlQueryOptions {
313                session_id: Some(token.clone()),
314                ..SqlQueryOptions::default()
315            })
316            .unwrap();
317        let query = RegisteredQueryGuard::new(query);
318        std::thread::sleep(Duration::from_millis(20));
319
320        assert_eq!(store.sweep_idle(), 0);
321        assert!(store.get(&token, "alice").is_some());
322
323        drop(query);
324        assert_eq!(store.sweep_idle(), 1);
325        assert!(store.is_empty());
326    }
327}