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        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}