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), aligned with the canonical session
3//! model of `mongreldb-protocol` (spec section 10.4, S1D-004).
4//!
5//! Each session holds a long-lived [`MongrelSession`] whose `sql_txn` staging
6//! survives across `run()` calls. Because `BEGIN`/`COMMIT` only stage ops
7//! logically (the core `Transaction` is opened at `COMMIT`, not `BEGIN`), an
8//! idle session with an open transaction does **not** pin an MVCC epoch — so
9//! abandoned sessions cost only the staged-ops memory until the idle reaper
10//! evicts them.
11//!
12//! ## Canonical session record (S1D-004)
13//!
14//! Every entry also carries the protocol crate's [`Session`] record: principal
15//! identity, current database, transaction state, prepared statements,
16//! session settings, read-your-writes token, and last activity. The record is
17//! data only — sessions stay lightweight and own no storage; the
18//! [`MongrelSession`] remains the execution handle the record keys off. The
19//! record is updated best-effort by request handlers
20//! ([`SessionEntry::sync_record_after_request`]) and read back for
21//! diagnostics/tests via [`SessionEntry::protocol_record`].
22//!
23//! ## Safety rails
24//! - **Auth-bound ownership**: a session is owned by the principal that created
25//!   it; lookups by a different principal return `None` (treated as 404 to
26//!   avoid confirming a session's existence to a non-owner).
27//! - **Per-session serialization**: a `tokio::sync::Mutex` guards each session
28//!   so two concurrent requests on the same token cannot interleave a
29//!   `BEGIN`/`INSERT`/`COMMIT` sequence.
30//! - **Bounded capacity**: `max_sessions` rejects new sessions with 503 once
31//!   full.
32//! - **Idle reaper**: [`SessionStore::sweep_idle`] drops sessions whose
33//!   `last_used` exceeds the configured timeout, discarding any staged
34//!   transaction (effective rollback).
35
36use std::collections::{BTreeMap, HashMap};
37use std::str::FromStr;
38use std::sync::atomic::{AtomicBool, Ordering};
39use std::sync::Arc;
40use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
41
42use mongreldb_protocol::prepared::{PreparedStatementBinding, StatementId};
43use mongreldb_protocol::request::{AuthenticatedIdentity, IsolationLevel, SessionId};
44use mongreldb_protocol::session::{Session, TransactionState};
45use mongreldb_query::MongrelSession;
46use mongreldb_types::hlc::HlcTimestamp;
47use mongreldb_types::ids::{DatabaseId, TransactionId};
48
49/// One pooled session plus its ownership and liveness metadata.
50pub struct SessionEntry {
51    /// The live query session — reused across requests via `X-Session-ID`.
52    /// Behind a lock so the S1D-005 replan path can swap in a freshly opened
53    /// session (current catalog registrations) while a request holds the
54    /// per-session lock.
55    session: std::sync::RwLock<Arc<MongrelSession>>,
56    /// Principal that created the session (`username`, `"token"`, or
57    /// `"anonymous"`). Requests from any other principal are rejected.
58    pub owner: String,
59    last_used: std::sync::Mutex<Instant>,
60    /// Held for the duration of a request to serialize per-session access.
61    pub lock: tokio::sync::Mutex<()>,
62    /// Set when the session is closed/evicted. `get()` rejects closed entries,
63    /// and request handlers re-check it after acquiring the lock so an in-flight
64    /// request that obtained an `Arc` just before eviction aborts rather than
65    /// committing into a closed session.
66    closed: AtomicBool,
67    /// The canonical S1D-004 session record: protocol-facing identity,
68    /// transaction state, prepared-statement bindings, settings,
69    /// read-your-writes token, and last-activity timestamp. Data only — the
70    /// execution handle above remains the owner of execution state.
71    record: std::sync::Mutex<Session>,
72    /// Server-side prepared-statement name → protocol statement id. The plan
73    /// itself lives in the `MongrelSession`; the binding record it is
74    /// validated against lives in `record.prepared_statements` (S1D-005).
75    prepared_names: std::sync::Mutex<BTreeMap<String, StatementId>>,
76}
77
78impl SessionEntry {
79    /// The live query session handle, cloned per use. Callers that must
80    /// observe a replan-driven session swap re-read it through this accessor.
81    pub fn session(&self) -> Arc<MongrelSession> {
82        self.session
83            .read()
84            .unwrap_or_else(|error| error.into_inner())
85            .clone()
86    }
87
88    /// Swap in a freshly opened query session (S1D-005): the old session's
89    /// DataFusion registrations snapshot the catalog at open, so replanning a
90    /// prepared statement after a cross-session catalog change requires a
91    /// session built against the current catalog. Callers hold the
92    /// per-session lock, so no request is mid-flight on the old session, and
93    /// must only swap when no transaction is staged on the old one.
94    pub(crate) fn replace_session(&self, session: MongrelSession) {
95        *self
96            .session
97            .write()
98            .unwrap_or_else(|error| error.into_inner()) = Arc::new(session);
99    }
100
101    pub(crate) fn touch(&self) {
102        if let Ok(mut t) = self.last_used.lock() {
103            *t = Instant::now();
104        }
105        if let Ok(mut record) = self.record.lock() {
106            record.last_activity_unix_micros = now_unix_micros();
107        }
108    }
109
110    /// Whether this entry has been closed/evicted and must reject new work.
111    pub(crate) fn is_closed(&self) -> bool {
112        self.closed.load(Ordering::Acquire)
113    }
114
115    fn mark_closed(&self) {
116        self.closed.store(true, Ordering::Release);
117    }
118
119    fn idle_for_at_least(&self, timeout: Duration) -> bool {
120        self.last_used
121            .lock()
122            .map(|t| t.elapsed() >= timeout)
123            .unwrap_or(false)
124    }
125
126    /// A consistent copy of the canonical S1D-004 session record
127    /// (diagnostics and tests).
128    pub fn protocol_record(&self) -> Session {
129        self.record
130            .lock()
131            .map(|record| record.clone())
132            .unwrap_or_else(|error| error.into_inner().clone())
133    }
134
135    /// Refresh the canonical record after a request completed on this
136    /// session: last activity, transaction state (derived from the session's
137    /// staged-ops staging), and the read-your-writes token when the request
138    /// durably committed. Callers hold the per-session lock, so the record
139    /// cannot race another request on this session.
140    ///
141    /// `commit` is `Some` exactly when the request committed. The token is
142    /// the literal HLC commit-timestamp lineage of the write when one is
143    /// known: the core commit log's `CommitReceipt.commit_ts` recorded for an
144    /// idempotent commit (the S1B-005 idempotency path), else the exact
145    /// commit timestamp the query layer sourced into the durable outcome,
146    /// else the per-open epoch→commit-ts ledger
147    /// (`Database::commit_ts_for_epoch`). When none of those resolve it is
148    /// the node's HLC timestamp captured at a fresh `begin` after the commit
149    /// became visible — the single HLC authority orders it after the write's
150    /// commit timestamp (spec §8.2), so any later read at this token observes
151    /// the session's own write.
152    pub(crate) fn sync_record_after_request(&self, commit: Option<HlcTimestamp>) {
153        let Ok(mut record) = self.record.lock() else {
154            return;
155        };
156        record.last_activity_unix_micros = now_unix_micros();
157        let staging = self.session().staged_sql_operation_count().is_some();
158        match (&record.transaction_state, staging) {
159            (TransactionState::Idle, true) => {
160                record.transaction_state = TransactionState::Active {
161                    transaction_id: TransactionId::new_random(),
162                    isolation: IsolationLevel::Snapshot,
163                };
164            }
165            (TransactionState::Active { .. }, false) => {
166                record.transaction_state = TransactionState::Idle;
167            }
168            _ => {}
169        }
170        if let Some(commit_ts) = commit {
171            record.read_your_writes_token = Some(commit_ts);
172        }
173    }
174
175    /// Allocate the next session-scoped prepared-statement id. Callers hold
176    /// the per-session lock, so id allocation cannot race.
177    pub(crate) fn allocate_statement_id(&self) -> StatementId {
178        let record = self
179            .record
180            .lock()
181            .unwrap_or_else(|error| error.into_inner());
182        let next = record
183            .prepared_statements
184            .keys()
185            .next_back()
186            .map_or(1, |id| id.get().saturating_add(1));
187        StatementId::new(next)
188    }
189
190    /// The binding recorded for a server-side statement name, if the name was
191    /// prepared through the registry-tracked prepare endpoint.
192    pub(crate) fn prepared_binding(
193        &self,
194        name: &str,
195    ) -> Option<(StatementId, PreparedStatementBinding)> {
196        let names = self
197            .prepared_names
198            .lock()
199            .unwrap_or_else(|error| error.into_inner());
200        let statement_id = names.get(name).copied()?;
201        let record = self
202            .record
203            .lock()
204            .unwrap_or_else(|error| error.into_inner());
205        record
206            .prepared_statements
207            .get(&statement_id)
208            .cloned()
209            .map(|binding| (statement_id, binding))
210    }
211
212    #[allow(dead_code)] // native RPC service surface (native-rpc feature)
213    pub(crate) fn prepared_binding_by_id(
214        &self,
215        statement_id: u64,
216    ) -> Option<(String, PreparedStatementBinding)> {
217        let statement_id = StatementId::new(statement_id);
218        let name = self
219            .prepared_names
220            .lock()
221            .unwrap_or_else(|error| error.into_inner())
222            .iter()
223            .find_map(|(name, id)| (*id == statement_id).then(|| name.clone()))?;
224        let binding = self
225            .record
226            .lock()
227            .unwrap_or_else(|error| error.into_inner())
228            .prepared_statements
229            .get(&statement_id)
230            .cloned()?;
231        Some((name, binding))
232    }
233
234    /// Record (or replace) a prepared-statement binding under a server-side
235    /// statement name (S1D-005).
236    pub(crate) fn insert_prepared_binding(&self, name: String, binding: PreparedStatementBinding) {
237        self.prepared_names
238            .lock()
239            .unwrap_or_else(|error| error.into_inner())
240            .insert(name, binding.statement_id);
241        self.record
242            .lock()
243            .unwrap_or_else(|error| error.into_inner())
244            .prepared_statements
245            .insert(binding.statement_id, binding);
246    }
247
248    /// Drop a prepared-statement binding by server-side name (DEALLOCATE).
249    pub(crate) fn remove_prepared_binding(&self, name: &str) -> Option<StatementId> {
250        let statement_id = self
251            .prepared_names
252            .lock()
253            .unwrap_or_else(|error| error.into_inner())
254            .remove(name)?;
255        self.record
256            .lock()
257            .unwrap_or_else(|error| error.into_inner())
258            .prepared_statements
259            .remove(&statement_id);
260        Some(statement_id)
261    }
262
263    /// Number of registry-tracked prepared statements on this session.
264    #[cfg(test)]
265    pub(crate) fn prepared_statement_count(&self) -> usize {
266        self.record
267            .lock()
268            .map(|record| record.prepared_statements.len())
269            .unwrap_or(0)
270    }
271}
272
273/// Wall-clock microseconds since the Unix epoch — the same time base as the
274/// canonical model's `deadline_unix_micros` / `last_activity_unix_micros`.
275pub(crate) fn now_unix_micros() -> u64 {
276    SystemTime::now()
277        .duration_since(UNIX_EPOCH)
278        .unwrap_or_default()
279        .as_micros()
280        .min(u128::from(u64::MAX)) as u64
281}
282
283/// Token-keyed pool of live sessions. Threaded through `AppState` as
284/// `Arc<SessionStore>` so the idle reaper (a detached thread) shares the same
285/// map as request handlers.
286pub struct SessionStore {
287    sessions: std::sync::Mutex<HashMap<String, Arc<SessionEntry>>>,
288    max_sessions: usize,
289    idle_timeout: Duration,
290    /// Logical database the sessions of this store resolve against
291    /// (S1D-004). Process-local: sessions are in-memory and die with the
292    /// process, so the id is drawn at store construction. Catalog-allocated
293    /// cluster-wide database ids land with the distributed waves.
294    database_id: DatabaseId,
295}
296
297impl SessionStore {
298    /// New store allowing up to `max_sessions` concurrent sessions, each evicted
299    /// after `idle_timeout` of inactivity.
300    pub fn new(max_sessions: usize, idle_timeout: Duration) -> Self {
301        Self::new_with_database_id(max_sessions, idle_timeout, DatabaseId::new_random())
302    }
303
304    /// [`Self::new`] with an explicit logical database id stamped onto every
305    /// session record.
306    pub fn new_with_database_id(
307        max_sessions: usize,
308        idle_timeout: Duration,
309        database_id: DatabaseId,
310    ) -> Self {
311        Self {
312            sessions: std::sync::Mutex::new(HashMap::new()),
313            // Always allow at least one session so the feature is usable when
314            // a caller passes a zero/negative-feeling cap.
315            max_sessions: max_sessions.max(1),
316            idle_timeout,
317            database_id,
318        }
319    }
320
321    /// The logical database id stamped onto this store's session records.
322    pub fn database_id(&self) -> DatabaseId {
323        self.database_id
324    }
325
326    /// Register a new session under a fresh opaque token. Returns the token, or
327    /// `None` if the store is at capacity (caller maps this to HTTP 503).
328    ///
329    /// The session record carries [`AuthenticatedIdentity::Credentialless`];
330    /// authenticated daemons use [`Self::create_with_identity`].
331    pub fn create(&self, session: MongrelSession, owner: String) -> Option<String> {
332        self.create_with_identity(session, owner, AuthenticatedIdentity::Credentialless)
333    }
334
335    /// [`Self::create`] with the authenticated identity the session acts as
336    /// (S1D-004): fixed at session open and carried by the canonical record.
337    pub fn create_with_identity(
338        &self,
339        session: MongrelSession,
340        owner: String,
341        principal: AuthenticatedIdentity,
342    ) -> Option<String> {
343        let mut guard = self.sessions.lock().ok()?;
344        if guard.len() >= self.max_sessions {
345            return None;
346        }
347        let token = random_token()?;
348        // `random_token` emits exactly 32 lowercase hex digits, the canonical
349        // `SessionId` text form, so this parse cannot fail.
350        let session_id = SessionId::from_str(&token).unwrap_or(SessionId::ZERO);
351        let record = Session::new(session_id, principal, self.database_id, now_unix_micros());
352        guard.insert(
353            token.clone(),
354            Arc::new(SessionEntry {
355                session: std::sync::RwLock::new(Arc::new(session)),
356                owner,
357                last_used: std::sync::Mutex::new(Instant::now()),
358                lock: tokio::sync::Mutex::new(()),
359                closed: AtomicBool::new(false),
360                record: std::sync::Mutex::new(record),
361                prepared_names: std::sync::Mutex::new(BTreeMap::new()),
362            }),
363        );
364        Some(token)
365    }
366
367    /// Look up a session by token, verifying the caller owns it and the session
368    /// is not closed. Returns a cloned `Arc<SessionEntry>` (the store lock is
369    /// released immediately; the caller holds the entry's per-session lock for
370    /// the request duration, and must re-check `is_closed()` after locking).
371    /// Returns `None` for an unknown token, an ownership mismatch, or a closed
372    /// session.
373    pub fn get(&self, token: &str, owner: &str) -> Option<Arc<SessionEntry>> {
374        let guard = self.sessions.lock().ok()?;
375        let entry = guard.get(token)?;
376        if entry.owner != owner || entry.is_closed() {
377            return None;
378        }
379        Some(Arc::clone(entry))
380    }
381
382    /// Native RPC session ids are bearer capabilities delivered only over
383    /// TLS. The authenticated identity was fixed when the session was
384    /// created, so native requests do not accept a caller-supplied owner.
385    #[allow(dead_code)] // native RPC service surface (native-rpc feature)
386    pub(crate) fn get_by_token(&self, token: &str) -> Option<Arc<SessionEntry>> {
387        let guard = self.sessions.lock().ok()?;
388        let entry = guard.get(token)?;
389        (!entry.is_closed()).then(|| Arc::clone(entry))
390    }
391
392    #[allow(dead_code)] // native RPC service surface (native-rpc feature)
393    pub(crate) fn close_by_token(&self, token: &str) -> bool {
394        let Ok(mut guard) = self.sessions.lock() else {
395            return false;
396        };
397        if let Some(entry) = guard.remove(token) {
398            entry.mark_closed();
399            true
400        } else {
401            false
402        }
403    }
404
405    /// Remove and drop a session, marking it closed first so any request that
406    /// already holds an `Arc` aborts after acquiring the lock. Returns whether a
407    /// session was removed. Rejects non-owners.
408    pub fn close(&self, token: &str, owner: &str) -> bool {
409        self.take_for_close(token, owner).is_some()
410    }
411
412    /// Mark closing and remove from new lookups while returning the live entry
413    /// so the caller can cancel its queries and wait a bounded grace period.
414    pub(crate) fn take_for_close(&self, token: &str, owner: &str) -> Option<Arc<SessionEntry>> {
415        if let Ok(mut guard) = self.sessions.lock() {
416            if let Some(entry) = guard.get(token) {
417                if entry.owner != owner {
418                    return None;
419                }
420                entry.mark_closed();
421            }
422            return guard.remove(token);
423        }
424        None
425    }
426
427    /// Evict every session idle for longer than the configured timeout, skipping
428    /// any session with an in-flight request (per-session lock held). Called
429    /// periodically by [`spawn_session_reaper`]. Returns the count evicted.
430    pub fn sweep_idle(&self) -> usize {
431        let Ok(mut guard) = self.sessions.lock() else {
432            return 0;
433        };
434        let timeout = self.idle_timeout;
435        // Collect tokens to evict: idle AND not currently in-flight (its
436        // per-session lock is acquirable). Holding the store lock prevents new
437        // lookups while we decide; try_lock fails exactly when a request holds
438        // or is acquiring the session lock.
439        let to_evict: Vec<String> = guard
440            .iter()
441            .filter_map(|(token, entry)| {
442                if entry.idle_for_at_least(timeout)
443                    && entry.session().query_registry().active_for_session(token) == 0
444                    && entry.lock.try_lock().is_ok()
445                {
446                    Some(token.clone())
447                } else {
448                    None
449                }
450            })
451            .collect();
452        let count = to_evict.len();
453        for token in &to_evict {
454            if let Some(entry) = guard.remove(token) {
455                entry.mark_closed();
456            }
457        }
458        count
459    }
460
461    /// Current number of live sessions (diagnostics / tests).
462    pub fn len(&self) -> usize {
463        self.sessions.lock().map(|g| g.len()).unwrap_or(0)
464    }
465
466    pub fn is_empty(&self) -> bool {
467        self.len() == 0
468    }
469
470    /// Mark every session closed and remove it from the pool during shutdown.
471    pub(crate) fn close_all(&self) {
472        if let Ok(mut guard) = self.sessions.lock() {
473            for entry in guard.values() {
474                entry.mark_closed();
475            }
476            guard.clear();
477        }
478    }
479}
480
481/// Background idle-session reaper. Sweeps every 30 s, evicting sessions whose
482/// `last_used` exceeds the configured timeout. Errors are logged and never
483/// abort the sweep. Mirrors `spawn_auto_compactor`'s pattern.
484pub fn spawn_session_reaper(store: Arc<SessionStore>) {
485    std::thread::Builder::new()
486        .name("mongreldb-session-reaper".into())
487        .spawn(move || loop {
488            std::thread::sleep(Duration::from_secs(30));
489            let evicted = store.sweep_idle();
490            if evicted > 0 {
491                eprintln!("[session-reaper] evicted {evicted} idle session(s)");
492            }
493        })
494        .expect("spawn session-reaper");
495}
496
497/// Generate an opaque, cryptographically-random session token. Reads 16 bytes
498/// from `/dev/urandom` (Linux/macOS) and hex-encodes them. Session tokens are
499/// bearer capabilities, so there is NO predictable fallback: if the OS RNG is
500/// unavailable, this returns `None` and the caller rejects session creation
501/// (HTTP 503) rather than handing out a guessable token.
502fn random_token() -> Option<String> {
503    let bytes = read_urandom(16)?;
504    let mut n = 0u128;
505    for &b in &bytes {
506        n = (n << 8) | b as u128;
507    }
508    Some(format!("{n:032x}"))
509}
510
511fn read_urandom(n: usize) -> Option<Vec<u8>> {
512    use std::io::Read;
513    let mut f = std::fs::File::open("/dev/urandom").ok()?;
514    let mut buf = vec![0u8; n];
515    f.read_exact(&mut buf).ok()?;
516    Some(buf)
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use mongreldb_core::Database;
523    use mongreldb_query::{RegisteredQueryGuard, SqlQueryOptions};
524    use tempfile::tempdir;
525
526    fn make_session() -> MongrelSession {
527        let dir = tempdir().unwrap();
528        let db = Arc::new(Database::create(dir.path()).unwrap());
529        // Keep the TempDir alive for the session's lifetime by leaking it; tests
530        // are short-lived and the OS reclaims on exit.
531        std::mem::forget(dir);
532        MongrelSession::open(db).unwrap()
533    }
534
535    #[test]
536    fn create_and_get_roundtrip() {
537        let store = SessionStore::new(8, Duration::from_secs(60));
538        let token = store.create(make_session(), "alice".into()).unwrap();
539        assert!(store.get(&token, "alice").is_some());
540        // Wrong owner → None (ownership enforced).
541        assert!(store.get(&token, "eve").is_none());
542        assert_eq!(store.len(), 1);
543    }
544
545    #[test]
546    fn close_removes_session() {
547        let store = SessionStore::new(8, Duration::from_secs(60));
548        let token = store.create(make_session(), "alice".into()).unwrap();
549        assert!(store.close(&token, "alice"));
550        assert!(store.get(&token, "alice").is_none());
551        assert!(store.is_empty());
552        // Non-owner cannot close.
553        let t2 = store.create(make_session(), "bob".into()).unwrap();
554        assert!(!store.close(&t2, "alice"));
555        assert_eq!(store.len(), 1);
556    }
557
558    #[test]
559    fn capacity_limit_rejects_new_sessions() {
560        let store = SessionStore::new(1, Duration::from_secs(60));
561        assert!(store.create(make_session(), "a".into()).is_some());
562        // At capacity → None.
563        assert!(store.create(make_session(), "b".into()).is_none());
564        assert_eq!(store.len(), 1);
565    }
566
567    #[test]
568    fn sweep_idle_evicts_stale_sessions() {
569        let store = SessionStore::new(8, Duration::from_millis(1));
570        let token = store.create(make_session(), "alice".into()).unwrap();
571        assert_eq!(store.len(), 1);
572        // Sleep past the idle timeout.
573        std::thread::sleep(Duration::from_millis(20));
574        let evicted = store.sweep_idle();
575        assert_eq!(evicted, 1);
576        assert!(store.get(&token, "alice").is_none());
577        assert!(store.is_empty());
578    }
579
580    #[test]
581    fn sweep_idle_keeps_active_queries() {
582        let store = SessionStore::new(8, Duration::from_millis(1));
583        let token = store.create(make_session(), "alice".into()).unwrap();
584        let entry = store.get(&token, "alice").unwrap();
585        let query = entry
586            .session()
587            .register_query(SqlQueryOptions {
588                session_id: Some(token.clone()),
589                ..SqlQueryOptions::default()
590            })
591            .unwrap();
592        let query = RegisteredQueryGuard::new(query);
593        std::thread::sleep(Duration::from_millis(20));
594
595        assert_eq!(store.sweep_idle(), 0);
596        assert!(store.get(&token, "alice").is_some());
597
598        drop(query);
599        assert_eq!(store.sweep_idle(), 1);
600        assert!(store.is_empty());
601    }
602
603    #[test]
604    fn protocol_record_carries_identity_database_and_session_id() {
605        let database_id = DatabaseId::new_random();
606        let store = SessionStore::new_with_database_id(8, Duration::from_secs(60), database_id);
607        let identity = AuthenticatedIdentity::CatalogUser {
608            username: "alice".to_owned(),
609            user_id: 42,
610            created_version: 7,
611        };
612        let token = store
613            .create_with_identity(make_session(), "alice".into(), identity.clone())
614            .unwrap();
615        let entry = store.get(&token, "alice").unwrap();
616        let record = entry.protocol_record();
617        assert_eq!(record.session_id, SessionId::from_str(&token).unwrap());
618        assert_eq!(record.principal, identity);
619        assert_eq!(record.current_database, database_id);
620        assert_eq!(record.transaction_state, TransactionState::Idle);
621        assert!(record.prepared_statements.is_empty());
622        assert!(record.settings.is_empty());
623        assert_eq!(record.read_your_writes_token, None);
624        assert!(record.last_activity_unix_micros > 0);
625        assert_eq!(store.database_id(), database_id);
626    }
627
628    #[test]
629    fn sync_record_tracks_commit_and_activity() {
630        let store = SessionStore::new(8, Duration::from_secs(60));
631        let token = store.create(make_session(), "alice".into()).unwrap();
632        let entry = store.get(&token, "alice").unwrap();
633        let before = entry.protocol_record().last_activity_unix_micros;
634        std::thread::sleep(Duration::from_millis(2));
635
636        let commit_ts = HlcTimestamp {
637            physical_micros: now_unix_micros().saturating_sub(1_000),
638            logical: 3,
639            node_tiebreaker: 0,
640        };
641        entry.sync_record_after_request(Some(commit_ts));
642        let record = entry.protocol_record();
643        assert!(record.last_activity_unix_micros >= before);
644        assert_eq!(
645            record.read_your_writes_token,
646            Some(commit_ts),
647            "a committed request must advance the read-your-writes token to the commit timestamp"
648        );
649
650        // Without a staged transaction the state stays Idle.
651        assert_eq!(record.transaction_state, TransactionState::Idle);
652
653        // A non-committed request never moves the token.
654        entry.sync_record_after_request(None);
655        assert_eq!(
656            entry.protocol_record().read_your_writes_token,
657            Some(commit_ts)
658        );
659    }
660
661    #[test]
662    fn prepared_bindings_are_tracked_by_name_and_id() {
663        let store = SessionStore::new(8, Duration::from_secs(60));
664        let token = store.create(make_session(), "alice".into()).unwrap();
665        let entry = store.get(&token, "alice").unwrap();
666
667        let first = entry.allocate_statement_id();
668        assert_eq!(first, StatementId::new(1));
669        let mut binding = PreparedStatementBinding {
670            statement_id: first,
671            sql: "SELECT 1".to_owned(),
672            parameter_types: vec![],
673            catalog_version: mongreldb_types::ids::MetadataVersion::new(3),
674            schema_versions: BTreeMap::new(),
675            feature_set: Default::default(),
676        };
677        entry.insert_prepared_binding("stmt_a".to_owned(), binding.clone());
678        assert_eq!(entry.prepared_statement_count(), 1);
679        assert_eq!(
680            entry.prepared_binding("stmt_a"),
681            Some((first, binding.clone()))
682        );
683
684        // Ids allocate monotonically from the greatest live id.
685        let second = entry.allocate_statement_id();
686        assert_eq!(second, StatementId::new(2));
687        binding.statement_id = second;
688        entry.insert_prepared_binding("stmt_b".to_owned(), binding);
689
690        assert_eq!(entry.remove_prepared_binding("stmt_a"), Some(first));
691        assert_eq!(entry.prepared_binding("stmt_a"), None);
692        assert_eq!(entry.prepared_statement_count(), 1);
693        assert_eq!(entry.protocol_record().prepared_statements.len(), 1);
694        // Removing an unknown name is a no-op.
695        assert_eq!(entry.remove_prepared_binding("stmt_a"), None);
696    }
697}