Skip to main content

omni_dev/snowflake/
session.rs

1//! Bounded session pools for concurrent, per-query-context Snowflake access.
2//!
3//! On the v1 query endpoint, statement context (`warehouse`/`role`/`database`/
4//! `schema`) is **session-global** — changed via `USE` and shared by every query
5//! on the session token. So a query that needs a specific context must hold its
6//! session exclusively for the `USE … + query`. To get concurrency *and*
7//! per-query context without re-authenticating per query, each `(account, user)`
8//! keeps a **bounded pool** of up to `max` authenticated sessions (each a
9//! separate browser auth): a query checks one out, applies any `USE` needed
10//! (skipping `USE`s already in effect), runs, and returns it.
11//!
12//! - **Concurrency is capped at `max`** by a [`tokio::sync::Semaphore`]: a permit
13//!   is held for the whole checkout, and a new session is created only while
14//!   holding a permit with no idle session available — so the live-session count
15//!   (and thus the number of browser auths) never exceeds `max`, and grows lazily
16//!   with demand.
17//! - **Every member is tracked in one slot table** (a [`std::sync::Mutex`] never
18//!   held across an `.await`): an idle slot parks its session handle; a checked-out
19//!   slot's handle lives in the [`Checkout`]. Both states stay visible to the
20//!   (sync) menu/status snapshots, which is how each individual auth is listed.
21//! - **Session creation (browser SSO) is serialized across all pools** by a
22//!   shared auth gate (held by the engine's `create` closure) so only one auth
23//!   window opens at a time.
24
25use std::collections::HashMap;
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::sync::{Arc, Mutex as StdMutex, MutexGuard, Weak};
28
29use chrono::{DateTime, Utc};
30use serde::Serialize;
31use tokio::sync::{Mutex as TokioMutex, Notify, OwnedSemaphorePermit, Semaphore};
32
33use crate::snowflake::client::SnowflakeSession;
34
35/// Identifies a pool by its `(account, user)` — one authentication identity.
36///
37/// Account-agnostic: keys come from request/config values, never a hardcoded
38/// account list.
39#[derive(Clone, Debug, PartialEq, Eq, Hash)]
40pub struct SessionKey {
41    /// The Snowflake account identifier (normalized by the engine).
42    pub account: String,
43    /// The Snowflake user the sessions authenticate as.
44    pub user: String,
45}
46
47impl SessionKey {
48    /// Builds a key from an account and user.
49    pub fn new(account: impl Into<String>, user: impl Into<String>) -> Self {
50        Self {
51            account: account.into(),
52            user: user.into(),
53        }
54    }
55}
56
57/// A concrete statement context (resolved warehouse/role/database/schema names).
58///
59/// `None` means "not set / leave at the session's current value". A session's
60/// *base* context (captured at creation) is fully concrete, so any per-query
61/// override can always be reset back to it.
62#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
63pub struct QueryContext {
64    /// Warehouse name.
65    pub warehouse: Option<String>,
66    /// Role name.
67    pub role: Option<String>,
68    /// Database name.
69    pub database: Option<String>,
70    /// Schema name.
71    pub schema: Option<String>,
72}
73
74impl QueryContext {
75    /// Returns `self` with any `Some` field of `overrides` taking precedence.
76    ///
77    /// Used as `base.overlay(&overrides)` to compute the concrete context a query
78    /// should run under.
79    #[must_use]
80    pub fn overlay(&self, overrides: &Self) -> Self {
81        Self {
82            warehouse: overrides
83                .warehouse
84                .clone()
85                .or_else(|| self.warehouse.clone()),
86            role: overrides.role.clone().or_else(|| self.role.clone()),
87            database: overrides.database.clone().or_else(|| self.database.clone()),
88            schema: overrides.schema.clone().or_else(|| self.schema.clone()),
89        }
90    }
91
92    /// A compact `wh/role/db/schema` label for menus (`(default)` when empty).
93    #[must_use]
94    pub fn summary(&self) -> String {
95        let parts: Vec<&str> = [
96            self.warehouse.as_deref(),
97            self.role.as_deref(),
98            self.database.as_deref(),
99            self.schema.as_deref(),
100        ]
101        .into_iter()
102        .flatten()
103        .collect();
104        if parts.is_empty() {
105            "(default)".to_string()
106        } else {
107            parts.join("/")
108        }
109    }
110}
111
112/// The query a member is currently running (set while checked out).
113#[derive(Clone, Debug, Serialize)]
114pub struct RunningQuery {
115    /// A single-line, truncated preview of the SQL.
116    pub sql: String,
117    /// When the query started running.
118    pub started_at: DateTime<Utc>,
119}
120
121/// One pool member: an authenticated session and its tracked state. The session
122/// handle is `Some` while idle and `None` while checked out (then it lives in the
123/// [`Checkout`]); the slot itself stays so every auth remains visible.
124struct Slot<S> {
125    id: u64,
126    base: QueryContext,
127    current: QueryContext,
128    last_used: DateTime<Utc>,
129    query_count: u64,
130    running: Option<RunningQuery>,
131    session: Option<S>,
132}
133
134/// A serializable snapshot of one pool member (one authenticated session).
135#[derive(Clone, Debug, Serialize)]
136pub struct MemberInfo {
137    /// Stable per-pool member id.
138    pub id: u64,
139    /// Whether the member is currently running a query.
140    pub busy: bool,
141    /// The context currently applied to the member.
142    pub context: QueryContext,
143    /// When the member last finished a query.
144    pub last_used: DateTime<Utc>,
145    /// How many queries this member has run.
146    pub query_count: u64,
147    /// The query currently running, when busy.
148    pub running: Option<RunningQuery>,
149}
150
151/// A session checked out of a pool.
152///
153/// Return it with [`SessionPool::checkin`] or drop it with
154/// [`SessionPool::discard`]. If it is dropped without either (a panic or early
155/// return between checkout and checkin), its [`Drop`] guard removes the orphaned
156/// slot so the member doesn't linger as phantom-busy.
157pub struct Checkout<S = SnowflakeSession> {
158    _permit: OwnedSemaphorePermit,
159    id: u64,
160    base: QueryContext,
161    current: QueryContext,
162    /// `Some` until `checkin`/`discard` takes it; `None` afterwards.
163    session: Option<S>,
164    /// Back-reference to the pool's slot table for the `Drop` guard.
165    slots: Weak<StdMutex<Vec<Slot<S>>>>,
166    /// Set by `checkin`/`discard` so the `Drop` guard becomes a no-op.
167    done: bool,
168}
169
170impl<S> Checkout<S> {
171    /// The checked-out session.
172    pub fn session(&self) -> &S {
173        self.session
174            .as_ref()
175            .unwrap_or_else(|| unreachable!("session is present until checkin/discard"))
176    }
177    /// The session's base (creation-time) context.
178    pub fn base(&self) -> &QueryContext {
179        &self.base
180    }
181    /// The context currently applied to the session.
182    pub fn current(&self) -> &QueryContext {
183        &self.current
184    }
185    /// The session member id.
186    pub fn id(&self) -> u64 {
187        self.id
188    }
189}
190
191impl<S> Drop for Checkout<S> {
192    fn drop(&mut self) {
193        if self.done {
194            return;
195        }
196        // Orphaned (not checked in/discarded): remove the slot so it isn't left
197        // permanently busy. The session handle drops with `self`.
198        if let Some(slots) = self.slots.upgrade() {
199            let mut slots = slots
200                .lock()
201                .unwrap_or_else(std::sync::PoisonError::into_inner);
202            slots.retain(|slot| slot.id != self.id);
203        }
204    }
205}
206
207/// Mutable per-pool bookkeeping for the (sync) menu/status snapshot.
208#[derive(Clone, Debug)]
209struct PoolMeta {
210    created_at: DateTime<Utc>,
211    last_used: DateTime<Utc>,
212    query_count: u64,
213}
214
215/// A serializable snapshot of one pool, used for `sessions`/`status`/menu.
216#[derive(Clone, Debug, Serialize)]
217pub struct SessionInfo {
218    /// Stable per-process pool id (used in tray `disconnect:<id>` actions).
219    pub id: u64,
220    /// The Snowflake account identifier.
221    pub account: String,
222    /// The authenticated user.
223    pub user: String,
224    /// When the pool was created (first query).
225    pub created_at: DateTime<Utc>,
226    /// When the pool last served a query.
227    pub last_used: DateTime<Utc>,
228    /// How many queries the pool has served.
229    pub query_count: u64,
230    /// Live (authenticated) sessions in the pool.
231    pub sessions: usize,
232    /// Maximum sessions / concurrency for the pool.
233    pub max_sessions: usize,
234    /// One entry per live authenticated session.
235    pub members: Vec<MemberInfo>,
236}
237
238/// A bounded pool of authenticated sessions for one `(account, user)`.
239///
240/// Generic over the session type for testability; the engine uses
241/// `SessionPool<SnowflakeSession>`.
242pub struct SessionPool<S = SnowflakeSession> {
243    id: u64,
244    key: SessionKey,
245    max: usize,
246    permits: Arc<Semaphore>,
247    /// Shared across all pools so only one browser auth runs at a time.
248    auth_gate: Arc<TokioMutex<()>>,
249    slots: Arc<StdMutex<Vec<Slot<S>>>>,
250    /// Notified whenever a session is returned to the idle set, so a waiter can
251    /// grab it immediately rather than waiting out an in-flight auth.
252    idle_notify: Notify,
253    next_member_id: AtomicU64,
254    meta: StdMutex<PoolMeta>,
255}
256
257impl<S> SessionPool<S> {
258    /// Builds an empty pool with capacity `max` (clamped to ≥ 1), sharing the
259    /// given auth gate.
260    #[must_use]
261    pub fn new(
262        id: u64,
263        key: SessionKey,
264        max: usize,
265        now: DateTime<Utc>,
266        auth_gate: Arc<TokioMutex<()>>,
267    ) -> Self {
268        let max = max.max(1);
269        Self {
270            id,
271            key,
272            max,
273            permits: Arc::new(Semaphore::new(max)),
274            auth_gate,
275            slots: Arc::new(StdMutex::new(Vec::new())),
276            idle_notify: Notify::new(),
277            next_member_id: AtomicU64::new(1),
278            meta: StdMutex::new(PoolMeta {
279                created_at: now,
280                last_used: now,
281                query_count: 0,
282            }),
283        }
284    }
285
286    /// Checks out a session: reuses the most-recently-returned idle one (LIFO,
287    /// good temporal affinity), or creates a new one via `create` if none is idle
288    /// and the pool is under capacity.
289    ///
290    /// `create` returns the session and its concrete base context. It is only
291    /// invoked while holding a permit with no idle session available, so the live
292    /// count never exceeds `max`.
293    ///
294    /// # Errors
295    ///
296    /// Propagates `create`'s error (e.g. authentication failure).
297    pub async fn checkout<F, Fut, E>(&self, create: F) -> std::result::Result<Checkout<S>, E>
298    where
299        F: FnOnce() -> Fut,
300        Fut: std::future::Future<Output = std::result::Result<(S, QueryContext), E>>,
301    {
302        let permit = Arc::clone(&self.permits)
303            .acquire_owned()
304            .await
305            .unwrap_or_else(|_| unreachable!("pool semaphore is never closed"));
306
307        // Wait for whichever happens first: an idle session is returned, or it is
308        // our turn at the auth gate. A session freed while another request is
309        // mid-auth is grabbed immediately (the notify wins) instead of waiting out
310        // that auth — so a waiter only authenticates when none becomes available.
311        let gate_guard = loop {
312            if let Some((id, base, current, session)) = self.take_idle() {
313                return Ok(self.make_checkout(permit, id, base, current, session));
314            }
315            // Arm the idle notification, then re-check, so a checkin between the
316            // check above and the wait below is never missed (lost-wakeup safe).
317            let notified = self.idle_notify.notified();
318            tokio::pin!(notified);
319            let _ = notified.as_mut().enable();
320            if let Some((id, base, current, session)) = self.take_idle() {
321                return Ok(self.make_checkout(permit, id, base, current, session));
322            }
323            tokio::select! {
324                biased;
325                () = &mut notified => {}                          // a session freed → loop & retry
326                guard = self.auth_gate.lock() => break guard,    // our turn to authenticate
327            }
328        };
329
330        // We hold the auth gate. Re-check once more (a session may have freed
331        // while we acquired it), then authenticate.
332        let _gate = gate_guard;
333        if let Some((id, base, current, session)) = self.take_idle() {
334            return Ok(self.make_checkout(permit, id, base, current, session));
335        }
336        let (session, base) = create().await?;
337        let id = self.next_member_id.fetch_add(1, Ordering::Relaxed);
338        self.lock_slots().push(Slot {
339            id,
340            base: base.clone(),
341            current: base.clone(),
342            last_used: Utc::now(),
343            query_count: 0,
344            running: None,
345            session: None, // handle is held by the returned Checkout
346        });
347        Ok(self.make_checkout(permit, id, base.clone(), base, session))
348    }
349
350    /// Removes and returns an idle member (LIFO), or `None` if all are busy.
351    fn take_idle(&self) -> Option<(u64, QueryContext, QueryContext, S)> {
352        let mut slots = self.lock_slots();
353        for slot in slots.iter_mut().rev() {
354            if let Some(session) = slot.session.take() {
355                return Some((slot.id, slot.base.clone(), slot.current.clone(), session));
356            }
357        }
358        None
359    }
360
361    /// Wraps a session into a [`Checkout`] carrying the permit and a drop guard.
362    fn make_checkout(
363        &self,
364        permit: OwnedSemaphorePermit,
365        id: u64,
366        base: QueryContext,
367        current: QueryContext,
368        session: S,
369    ) -> Checkout<S> {
370        Checkout {
371            _permit: permit,
372            id,
373            base,
374            current,
375            session: Some(session),
376            slots: Arc::downgrade(&self.slots),
377            done: false,
378        }
379    }
380
381    /// Checks out every currently-idle session without waiting or creating.
382    ///
383    /// Used by the keep-alive heartbeat: each borrowed session holds a real
384    /// permit, so a concurrent [`checkout`](Self::checkout) briefly waits for
385    /// [`restore`](Self::restore) instead of authenticating a duplicate session
386    /// (a spurious browser SSO). Busy sessions are skipped — the query path
387    /// keeps them alive itself.
388    #[must_use]
389    pub fn checkout_all_idle(&self) -> Vec<Checkout<S>> {
390        let mut checkouts = Vec::new();
391        while let Ok(permit) = Arc::clone(&self.permits).try_acquire_owned() {
392            match self.take_idle() {
393                Some((id, base, current, session)) => {
394                    checkouts.push(self.make_checkout(permit, id, base, current, session));
395                }
396                // No idle session left; the unused permit drops (released).
397                None => break,
398            }
399        }
400        checkouts
401    }
402
403    /// Returns a borrowed session to its slot untouched — unlike
404    /// [`checkin`](Self::checkin) it preserves `last_used` and the recorded
405    /// context, because a keep-alive heartbeat is not a query.
406    pub fn restore(&self, mut checkout: Checkout<S>) {
407        checkout.done = true;
408        let session = checkout.session.take();
409        {
410            let mut slots = self.lock_slots();
411            if let Some(slot) = slots.iter_mut().find(|slot| slot.id == checkout.id) {
412                slot.session = session;
413            }
414        }
415        // Wake a waiter so it can reuse this session instead of authenticating.
416        self.idle_notify.notify_waiters();
417        // `_permit` drops with `checkout`, freeing a concurrency slot.
418    }
419
420    /// Returns a session to its slot with the context now applied to it.
421    pub fn checkin(&self, mut checkout: Checkout<S>, current: QueryContext) {
422        checkout.done = true;
423        let session = checkout.session.take();
424        {
425            let mut slots = self.lock_slots();
426            if let Some(slot) = slots.iter_mut().find(|slot| slot.id == checkout.id) {
427                slot.current = current;
428                slot.last_used = Utc::now();
429                slot.running = None;
430                slot.session = session;
431            }
432        }
433        // Wake a waiter so it can reuse this session instead of authenticating.
434        self.idle_notify.notify_waiters();
435        // `_permit` drops with `checkout`, freeing a concurrency slot.
436    }
437
438    /// Records that a checked-out member has started running `sql` (a truncated
439    /// preview), so menus/status can show what each busy session is doing.
440    pub fn start_query(&self, member_id: u64, sql: String) {
441        let mut slots = self.lock_slots();
442        if let Some(slot) = slots.iter_mut().find(|slot| slot.id == member_id) {
443            slot.query_count += 1;
444            slot.running = Some(RunningQuery {
445                sql,
446                started_at: Utc::now(),
447            });
448        }
449    }
450
451    /// Discards a session — e.g. after expiry — removing its slot (releases the
452    /// permit and frees its capacity for a fresh auth).
453    pub fn discard(&self, mut checkout: Checkout<S>) {
454        checkout.done = true;
455        self.lock_slots().retain(|slot| slot.id != checkout.id);
456        drop(checkout);
457    }
458
459    /// Records that the pool served a query.
460    pub fn touch(&self) {
461        let mut meta = self.lock_meta();
462        meta.last_used = Utc::now();
463        meta.query_count += 1;
464    }
465
466    /// The pool id.
467    #[must_use]
468    pub fn id(&self) -> u64 {
469        self.id
470    }
471
472    /// The number of live sessions (idle + checked out).
473    #[must_use]
474    pub fn live(&self) -> usize {
475        self.lock_slots().len()
476    }
477
478    /// A serializable snapshot of the pool, including each member.
479    #[must_use]
480    pub fn info(&self) -> SessionInfo {
481        let members: Vec<MemberInfo> = {
482            let slots = self.lock_slots();
483            let mut members: Vec<MemberInfo> = slots
484                .iter()
485                .map(|slot| MemberInfo {
486                    id: slot.id,
487                    busy: slot.session.is_none(),
488                    context: slot.current.clone(),
489                    last_used: slot.last_used,
490                    query_count: slot.query_count,
491                    running: slot.running.clone(),
492                })
493                .collect();
494            members.sort_by_key(|m| m.id);
495            members
496        };
497        let meta = self.lock_meta();
498        SessionInfo {
499            id: self.id,
500            account: self.key.account.clone(),
501            user: self.key.user.clone(),
502            created_at: meta.created_at,
503            last_used: meta.last_used,
504            query_count: meta.query_count,
505            sessions: members.len(),
506            max_sessions: self.max,
507            members,
508        }
509    }
510
511    fn lock_slots(&self) -> MutexGuard<'_, Vec<Slot<S>>> {
512        self.slots
513            .lock()
514            .unwrap_or_else(std::sync::PoisonError::into_inner)
515    }
516
517    fn lock_meta(&self) -> MutexGuard<'_, PoolMeta> {
518        self.meta
519            .lock()
520            .unwrap_or_else(std::sync::PoisonError::into_inner)
521    }
522}
523
524/// The account-agnostic registry of session pools, keyed by `(account, user)`.
525///
526/// Cheap to clone (everything is behind `Arc`). The map mutex is std (never held
527/// across `.await`); the shared auth gate serializes browser auths across pools.
528#[derive(Clone)]
529pub struct PoolRegistry {
530    map: Arc<StdMutex<HashMap<SessionKey, Arc<SessionPool>>>>,
531    auth_gate: Arc<TokioMutex<()>>,
532    next_pool_id: Arc<AtomicU64>,
533}
534
535impl Default for PoolRegistry {
536    fn default() -> Self {
537        Self::new()
538    }
539}
540
541impl PoolRegistry {
542    /// Builds an empty registry.
543    #[must_use]
544    pub fn new() -> Self {
545        Self {
546            map: Arc::new(StdMutex::new(HashMap::new())),
547            auth_gate: Arc::new(TokioMutex::new(())),
548            next_pool_id: Arc::new(AtomicU64::new(1)),
549        }
550    }
551
552    /// Returns the pool for `key`, creating an empty one (no auth) if absent.
553    /// New pools share the registry's auth gate, so all browser auths across all
554    /// pools are serialized to one at a time.
555    pub fn get_or_create(&self, key: &SessionKey, max: usize) -> Arc<SessionPool> {
556        let mut map = self.lock();
557        if let Some(pool) = map.get(key) {
558            return Arc::clone(pool);
559        }
560        let id = self.next_pool_id.fetch_add(1, Ordering::Relaxed);
561        let pool = Arc::new(SessionPool::new(
562            id,
563            key.clone(),
564            max,
565            Utc::now(),
566            Arc::clone(&self.auth_gate),
567        ));
568        map.insert(key.clone(), Arc::clone(&pool));
569        pool
570    }
571
572    /// Removes the pool for `key`. Returns it if present.
573    pub fn remove(&self, key: &SessionKey) -> Option<Arc<SessionPool>> {
574        self.lock().remove(key)
575    }
576
577    /// Removes the pool with the given id. Returns it if present.
578    pub fn remove_by_id(&self, id: u64) -> Option<Arc<SessionPool>> {
579        let key = {
580            let map = self.lock();
581            map.iter()
582                .find(|(_, pool)| pool.id() == id)
583                .map(|(key, _)| key.clone())
584        };
585        key.and_then(|key| self.remove(&key))
586    }
587
588    /// Drains and returns every pool.
589    pub fn take_all(&self) -> Vec<Arc<SessionPool>> {
590        self.lock().drain().map(|(_, pool)| pool).collect()
591    }
592
593    /// Live handles to every pool, ordered by id, without draining the
594    /// registry. Used by the keep-alive heartbeat to visit each pool.
595    #[must_use]
596    pub fn pools(&self) -> Vec<Arc<SessionPool>> {
597        let mut pools: Vec<Arc<SessionPool>> = self.lock().values().map(Arc::clone).collect();
598        pools.sort_by_key(|pool| pool.id());
599        pools
600    }
601
602    /// A snapshot of every pool, ordered by id. Sync; no awaits.
603    #[must_use]
604    pub fn snapshot(&self) -> Vec<SessionInfo> {
605        let mut infos: Vec<SessionInfo> = self.lock().values().map(|pool| pool.info()).collect();
606        infos.sort_by_key(|info| info.id);
607        infos
608    }
609
610    /// The number of pools.
611    #[must_use]
612    pub fn len(&self) -> usize {
613        self.lock().len()
614    }
615
616    /// Whether the registry holds no pools.
617    #[must_use]
618    pub fn is_empty(&self) -> bool {
619        self.lock().is_empty()
620    }
621
622    fn lock(&self) -> MutexGuard<'_, HashMap<SessionKey, Arc<SessionPool>>> {
623        self.map
624            .lock()
625            .unwrap_or_else(std::sync::PoisonError::into_inner)
626    }
627}
628
629#[cfg(test)]
630#[allow(clippy::unwrap_used, clippy::expect_used)]
631mod tests {
632    use std::sync::atomic::AtomicU32;
633
634    use super::*;
635
636    fn ctx() -> QueryContext {
637        QueryContext::default()
638    }
639
640    fn fake_pool(max: usize) -> (SessionPool<u32>, Arc<AtomicU32>) {
641        let pool = SessionPool::<u32>::new(
642            1,
643            SessionKey::new("ACCT", "user"),
644            max,
645            Utc::now(),
646            Arc::new(TokioMutex::new(())),
647        );
648        (pool, Arc::new(AtomicU32::new(0)))
649    }
650
651    async fn fake_create(
652        counter: &AtomicU32,
653    ) -> std::result::Result<(u32, QueryContext), std::convert::Infallible> {
654        Ok((counter.fetch_add(1, Ordering::Relaxed), ctx()))
655    }
656
657    #[tokio::test]
658    async fn start_query_records_running_then_checkin_clears_it() {
659        let (pool, calls) = fake_pool(2);
660        let c = pool.checkout(|| fake_create(&calls)).await.unwrap();
661        pool.start_query(c.id(), "SELECT 1".to_string());
662
663        let member = pool.info().members[0].clone();
664        assert!(member.busy);
665        assert_eq!(member.query_count, 1);
666        assert_eq!(
667            member.running.as_ref().map(|r| r.sql.as_str()),
668            Some("SELECT 1")
669        );
670
671        pool.checkin(c, ctx());
672        let member = pool.info().members[0].clone();
673        assert!(!member.busy);
674        assert!(member.running.is_none(), "running cleared on checkin");
675        assert_eq!(member.query_count, 1, "count persists after checkin");
676    }
677
678    #[test]
679    fn overlay_lets_overrides_win() {
680        let base = QueryContext {
681            warehouse: Some("WH".into()),
682            role: Some("R".into()),
683            database: Some("DB".into()),
684            schema: Some("S".into()),
685        };
686        let overrides = QueryContext {
687            warehouse: Some("OTHER_WH".into()),
688            ..QueryContext::default()
689        };
690        let eff = base.overlay(&overrides);
691        assert_eq!(eff.warehouse.as_deref(), Some("OTHER_WH"));
692        assert_eq!(eff.role.as_deref(), Some("R")); // unchanged falls back to base
693        assert_eq!(eff.database.as_deref(), Some("DB"));
694    }
695
696    #[test]
697    fn summary_renders_set_dimensions_or_default() {
698        assert_eq!(QueryContext::default().summary(), "(default)");
699        let c = QueryContext {
700            warehouse: Some("WH".into()),
701            role: Some("R".into()),
702            ..QueryContext::default()
703        };
704        assert_eq!(c.summary(), "WH/R");
705    }
706
707    #[tokio::test]
708    async fn checkin_reuses_session_and_lists_members() {
709        let (pool, calls) = fake_pool(4);
710        let c1 = pool.checkout(|| fake_create(&calls)).await.unwrap();
711        let id1 = c1.id();
712        // While checked out the member is visible and marked busy.
713        let info = pool.info();
714        assert_eq!(info.members.len(), 1);
715        assert!(info.members[0].busy);
716        pool.checkin(c1, ctx());
717        // Now idle.
718        assert!(!pool.info().members[0].busy);
719        // Reuse the idle session: no new create, same member id.
720        let c2 = pool.checkout(|| fake_create(&calls)).await.unwrap();
721        assert_eq!(c2.id(), id1);
722        assert_eq!(calls.load(Ordering::Relaxed), 1, "should not create twice");
723        assert_eq!(pool.live(), 1);
724        pool.checkin(c2, ctx());
725    }
726
727    #[tokio::test]
728    async fn never_exceeds_capacity_and_blocks_until_checkin() {
729        let (pool, calls) = fake_pool(2);
730        let c1 = pool.checkout(|| fake_create(&calls)).await.unwrap();
731        let c2 = pool.checkout(|| fake_create(&calls)).await.unwrap();
732        assert_eq!(pool.live(), 2);
733        // Both members visible as busy.
734        assert_eq!(pool.info().members.iter().filter(|m| m.busy).count(), 2);
735        // A third checkout must block while both are held.
736        let third = tokio::time::timeout(
737            std::time::Duration::from_millis(50),
738            pool.checkout(|| fake_create(&calls)),
739        )
740        .await;
741        assert!(third.is_err(), "third checkout should block at capacity");
742        pool.checkin(c1, ctx());
743        let c3 = pool.checkout(|| fake_create(&calls)).await.unwrap();
744        assert_eq!(pool.live(), 2, "reuse, not grow");
745        assert_eq!(calls.load(Ordering::Relaxed), 2);
746        pool.checkin(c2, ctx());
747        pool.checkin(c3, ctx());
748    }
749
750    #[tokio::test]
751    async fn discard_frees_capacity_for_a_fresh_session() {
752        let (pool, calls) = fake_pool(1);
753        let c1 = pool.checkout(|| fake_create(&calls)).await.unwrap();
754        assert_eq!(pool.live(), 1);
755        pool.discard(c1); // expired session dropped
756        assert_eq!(pool.live(), 0);
757        assert!(pool.info().members.is_empty());
758        let c2 = pool.checkout(|| fake_create(&calls)).await.unwrap();
759        assert_eq!(calls.load(Ordering::Relaxed), 2, "fresh session created");
760        assert_eq!(pool.live(), 1);
761        pool.checkin(c2, ctx());
762    }
763
764    #[tokio::test]
765    async fn orphaned_checkout_frees_its_slot_on_drop() {
766        let (pool, calls) = fake_pool(2);
767        {
768            let _c = pool.checkout(|| fake_create(&calls)).await.unwrap();
769            assert_eq!(pool.live(), 1);
770            // `_c` is dropped here without checkin/discard (e.g. a panic path).
771        }
772        assert_eq!(pool.live(), 0, "the Drop guard removed the orphaned slot");
773        assert!(pool.info().members.is_empty());
774        // Capacity is freed, so a fresh checkout still works.
775        let c = pool.checkout(|| fake_create(&calls)).await.unwrap();
776        pool.checkin(c, ctx());
777    }
778
779    #[tokio::test]
780    async fn waiter_grabs_freed_session_without_waiting_out_the_in_flight_auth() {
781        let (pool, calls) = fake_pool(2);
782        let pool = Arc::new(pool);
783
784        // One live session, checked out — so there's nothing idle initially.
785        let c1 = pool.checkout(|| fake_create(&calls)).await.unwrap();
786        assert_eq!(calls.load(Ordering::Relaxed), 1);
787
788        // Hold the auth gate for the WHOLE test, simulating an in-flight auth that
789        // never completes.
790        let held = Arc::clone(&pool.auth_gate).lock_owned().await;
791
792        // A second request: acquires a permit, finds no idle, then parks in the
793        // notify-vs-gate race.
794        let pool2 = Arc::clone(&pool);
795        let calls2 = Arc::clone(&calls);
796        let waiter = tokio::spawn(async move {
797            pool2
798                .checkout(move || async move {
799                    let id = calls2.fetch_add(1, Ordering::Relaxed);
800                    Ok::<(u32, QueryContext), std::convert::Infallible>((
801                        id,
802                        QueryContext::default(),
803                    ))
804                })
805                .await
806                .unwrap()
807        });
808
809        // Let the waiter park, then free c1's session WITHOUT releasing the gate.
810        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
811        pool.checkin(c1, ctx());
812
813        // The waiter must complete by reusing the freed session even though the
814        // auth gate is still held — proving it didn't wait out the in-flight auth.
815        let c2 = tokio::time::timeout(std::time::Duration::from_secs(2), waiter)
816            .await
817            .expect("waiter must not block on the held auth gate")
818            .unwrap();
819        assert_eq!(
820            calls.load(Ordering::Relaxed),
821            1,
822            "reused the freed session — no new auth"
823        );
824        assert_eq!(pool.live(), 1, "still one session");
825
826        drop(held);
827        pool.checkin(c2, ctx());
828    }
829
830    #[tokio::test]
831    async fn checkout_all_idle_borrows_only_idle_sessions() {
832        let (pool, calls) = fake_pool(4);
833        let c1 = pool.checkout(|| fake_create(&calls)).await.unwrap();
834        let c2 = pool.checkout(|| fake_create(&calls)).await.unwrap();
835        let busy_id = c2.id();
836        pool.checkin(c1, ctx());
837
838        // One idle, one busy: only the idle session is borrowed.
839        let borrowed = pool.checkout_all_idle();
840        assert_eq!(borrowed.len(), 1);
841        assert_ne!(borrowed[0].id(), busy_id);
842        // Both slots stay visible; the borrowed one shows as busy.
843        assert_eq!(pool.live(), 2);
844        assert_eq!(pool.info().members.iter().filter(|m| m.busy).count(), 2);
845
846        for c in borrowed {
847            pool.restore(c);
848        }
849        pool.checkin(c2, ctx());
850        // Nothing was created or lost by the borrow cycle.
851        assert_eq!(calls.load(Ordering::Relaxed), 2);
852        assert_eq!(pool.live(), 2);
853    }
854
855    #[tokio::test]
856    async fn checkout_all_idle_is_empty_when_nothing_is_idle() {
857        let (pool, calls) = fake_pool(2);
858        assert!(pool.checkout_all_idle().is_empty(), "empty pool");
859        let c = pool.checkout(|| fake_create(&calls)).await.unwrap();
860        assert!(pool.checkout_all_idle().is_empty(), "all busy");
861        pool.checkin(c, ctx());
862    }
863
864    #[tokio::test]
865    async fn restore_preserves_last_used_and_context() {
866        let (pool, calls) = fake_pool(2);
867        let c = pool.checkout(|| fake_create(&calls)).await.unwrap();
868        let recorded = QueryContext {
869            warehouse: Some("WH".into()),
870            ..QueryContext::default()
871        };
872        pool.checkin(c, recorded.clone());
873        let before = pool.info().members[0].clone();
874
875        // Ensure a bookkeeping bump would produce a strictly later timestamp.
876        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
877        let borrowed = pool.checkout_all_idle();
878        assert_eq!(borrowed.len(), 1);
879        for c in borrowed {
880            pool.restore(c);
881        }
882
883        let after = pool.info().members[0].clone();
884        assert_eq!(after.last_used, before.last_used, "not a query");
885        assert_eq!(after.context, recorded, "recorded context untouched");
886        assert_eq!(after.query_count, before.query_count);
887    }
888
889    #[tokio::test]
890    async fn discarding_a_borrowed_idle_session_frees_capacity() {
891        let (pool, calls) = fake_pool(1);
892        let c = pool.checkout(|| fake_create(&calls)).await.unwrap();
893        pool.checkin(c, ctx());
894
895        let mut borrowed = pool.checkout_all_idle();
896        pool.discard(borrowed.pop().unwrap()); // dead beyond renewal
897        assert_eq!(pool.live(), 0);
898
899        // Capacity is freed, so the next checkout authenticates afresh.
900        let c = pool.checkout(|| fake_create(&calls)).await.unwrap();
901        assert_eq!(calls.load(Ordering::Relaxed), 2);
902        pool.checkin(c, ctx());
903    }
904
905    #[tokio::test]
906    async fn checkout_during_heartbeat_borrow_waits_and_reuses() {
907        let (pool, calls) = fake_pool(1);
908        let pool = Arc::new(pool);
909        let c = pool.checkout(|| fake_create(&calls)).await.unwrap();
910        let id = c.id();
911        pool.checkin(c, ctx());
912
913        // The heartbeat borrows the only session (and its only permit).
914        let mut borrowed = pool.checkout_all_idle();
915        assert_eq!(borrowed.len(), 1);
916
917        // A query arriving mid-heartbeat parks on the permit instead of
918        // authenticating a duplicate session (no spurious SSO).
919        let pool2 = Arc::clone(&pool);
920        let calls2 = Arc::clone(&calls);
921        let waiter = tokio::spawn(async move {
922            pool2
923                .checkout(move || async move {
924                    let id = calls2.fetch_add(1, Ordering::Relaxed);
925                    Ok::<(u32, QueryContext), std::convert::Infallible>((
926                        id,
927                        QueryContext::default(),
928                    ))
929                })
930                .await
931                .unwrap()
932        });
933        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
934        assert!(!waiter.is_finished(), "waiter blocks while borrowed");
935
936        pool.restore(borrowed.pop().unwrap());
937        let c = tokio::time::timeout(std::time::Duration::from_secs(2), waiter)
938            .await
939            .expect("waiter must proceed once the session is restored")
940            .unwrap();
941        assert_eq!(c.id(), id, "reused the restored session");
942        assert_eq!(calls.load(Ordering::Relaxed), 1, "no new auth");
943        pool.checkin(c, ctx());
944    }
945
946    #[test]
947    fn registry_pools_returns_handles_without_draining() {
948        let registry = PoolRegistry::new();
949        assert!(registry.pools().is_empty());
950        let p1 = registry.get_or_create(&SessionKey::new("A", "u"), 4);
951        let p2 = registry.get_or_create(&SessionKey::new("B", "u"), 4);
952        let pools = registry.pools();
953        assert_eq!(
954            pools.iter().map(|p| p.id()).collect::<Vec<_>>(),
955            vec![p1.id(), p2.id()],
956            "ordered by id"
957        );
958        assert_eq!(registry.len(), 2, "not drained");
959    }
960
961    #[test]
962    fn registry_get_or_create_is_idempotent_per_key() {
963        let registry = PoolRegistry::new();
964        assert!(registry.is_empty());
965        let key = SessionKey::new("ACCT", "user");
966        let p1 = registry.get_or_create(&key, 4);
967        let p2 = registry.get_or_create(&key, 4);
968        assert_eq!(p1.id(), p2.id());
969        assert_eq!(registry.len(), 1);
970        assert!(registry.remove(&key).is_some());
971        assert!(registry.remove(&key).is_none());
972    }
973}