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