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    /// Returns a session to its slot with the context now applied to it.
382    pub fn checkin(&self, mut checkout: Checkout<S>, current: QueryContext) {
383        checkout.done = true;
384        let session = checkout.session.take();
385        {
386            let mut slots = self.lock_slots();
387            if let Some(slot) = slots.iter_mut().find(|slot| slot.id == checkout.id) {
388                slot.current = current;
389                slot.last_used = Utc::now();
390                slot.running = None;
391                slot.session = session;
392            }
393        }
394        // Wake a waiter so it can reuse this session instead of authenticating.
395        self.idle_notify.notify_waiters();
396        // `_permit` drops with `checkout`, freeing a concurrency slot.
397    }
398
399    /// Records that a checked-out member has started running `sql` (a truncated
400    /// preview), so menus/status can show what each busy session is doing.
401    pub fn start_query(&self, member_id: u64, sql: String) {
402        let mut slots = self.lock_slots();
403        if let Some(slot) = slots.iter_mut().find(|slot| slot.id == member_id) {
404            slot.query_count += 1;
405            slot.running = Some(RunningQuery {
406                sql,
407                started_at: Utc::now(),
408            });
409        }
410    }
411
412    /// Discards a session — e.g. after expiry — removing its slot (releases the
413    /// permit and frees its capacity for a fresh auth).
414    pub fn discard(&self, mut checkout: Checkout<S>) {
415        checkout.done = true;
416        self.lock_slots().retain(|slot| slot.id != checkout.id);
417        drop(checkout);
418    }
419
420    /// Records that the pool served a query.
421    pub fn touch(&self) {
422        let mut meta = self.lock_meta();
423        meta.last_used = Utc::now();
424        meta.query_count += 1;
425    }
426
427    /// The pool id.
428    #[must_use]
429    pub fn id(&self) -> u64 {
430        self.id
431    }
432
433    /// The number of live sessions (idle + checked out).
434    #[must_use]
435    pub fn live(&self) -> usize {
436        self.lock_slots().len()
437    }
438
439    /// A serializable snapshot of the pool, including each member.
440    #[must_use]
441    pub fn info(&self) -> SessionInfo {
442        let members: Vec<MemberInfo> = {
443            let slots = self.lock_slots();
444            let mut members: Vec<MemberInfo> = slots
445                .iter()
446                .map(|slot| MemberInfo {
447                    id: slot.id,
448                    busy: slot.session.is_none(),
449                    context: slot.current.clone(),
450                    last_used: slot.last_used,
451                    query_count: slot.query_count,
452                    running: slot.running.clone(),
453                })
454                .collect();
455            members.sort_by_key(|m| m.id);
456            members
457        };
458        let meta = self.lock_meta();
459        SessionInfo {
460            id: self.id,
461            account: self.key.account.clone(),
462            user: self.key.user.clone(),
463            created_at: meta.created_at,
464            last_used: meta.last_used,
465            query_count: meta.query_count,
466            sessions: members.len(),
467            max_sessions: self.max,
468            members,
469        }
470    }
471
472    fn lock_slots(&self) -> MutexGuard<'_, Vec<Slot<S>>> {
473        self.slots
474            .lock()
475            .unwrap_or_else(std::sync::PoisonError::into_inner)
476    }
477
478    fn lock_meta(&self) -> MutexGuard<'_, PoolMeta> {
479        self.meta
480            .lock()
481            .unwrap_or_else(std::sync::PoisonError::into_inner)
482    }
483}
484
485/// The account-agnostic registry of session pools, keyed by `(account, user)`.
486///
487/// Cheap to clone (everything is behind `Arc`). The map mutex is std (never held
488/// across `.await`); the shared auth gate serializes browser auths across pools.
489#[derive(Clone)]
490pub struct PoolRegistry {
491    map: Arc<StdMutex<HashMap<SessionKey, Arc<SessionPool>>>>,
492    auth_gate: Arc<TokioMutex<()>>,
493    next_pool_id: Arc<AtomicU64>,
494}
495
496impl Default for PoolRegistry {
497    fn default() -> Self {
498        Self::new()
499    }
500}
501
502impl PoolRegistry {
503    /// Builds an empty registry.
504    #[must_use]
505    pub fn new() -> Self {
506        Self {
507            map: Arc::new(StdMutex::new(HashMap::new())),
508            auth_gate: Arc::new(TokioMutex::new(())),
509            next_pool_id: Arc::new(AtomicU64::new(1)),
510        }
511    }
512
513    /// Returns the pool for `key`, creating an empty one (no auth) if absent.
514    /// New pools share the registry's auth gate, so all browser auths across all
515    /// pools are serialized to one at a time.
516    pub fn get_or_create(&self, key: &SessionKey, max: usize) -> Arc<SessionPool> {
517        let mut map = self.lock();
518        if let Some(pool) = map.get(key) {
519            return Arc::clone(pool);
520        }
521        let id = self.next_pool_id.fetch_add(1, Ordering::Relaxed);
522        let pool = Arc::new(SessionPool::new(
523            id,
524            key.clone(),
525            max,
526            Utc::now(),
527            Arc::clone(&self.auth_gate),
528        ));
529        map.insert(key.clone(), Arc::clone(&pool));
530        pool
531    }
532
533    /// Removes the pool for `key`. Returns it if present.
534    pub fn remove(&self, key: &SessionKey) -> Option<Arc<SessionPool>> {
535        self.lock().remove(key)
536    }
537
538    /// Removes the pool with the given id. Returns it if present.
539    pub fn remove_by_id(&self, id: u64) -> Option<Arc<SessionPool>> {
540        let key = {
541            let map = self.lock();
542            map.iter()
543                .find(|(_, pool)| pool.id() == id)
544                .map(|(key, _)| key.clone())
545        };
546        key.and_then(|key| self.remove(&key))
547    }
548
549    /// Drains and returns every pool.
550    pub fn take_all(&self) -> Vec<Arc<SessionPool>> {
551        self.lock().drain().map(|(_, pool)| pool).collect()
552    }
553
554    /// A snapshot of every pool, ordered by id. Sync; no awaits.
555    #[must_use]
556    pub fn snapshot(&self) -> Vec<SessionInfo> {
557        let mut infos: Vec<SessionInfo> = self.lock().values().map(|pool| pool.info()).collect();
558        infos.sort_by_key(|info| info.id);
559        infos
560    }
561
562    /// The number of pools.
563    #[must_use]
564    pub fn len(&self) -> usize {
565        self.lock().len()
566    }
567
568    /// Whether the registry holds no pools.
569    #[must_use]
570    pub fn is_empty(&self) -> bool {
571        self.lock().is_empty()
572    }
573
574    fn lock(&self) -> MutexGuard<'_, HashMap<SessionKey, Arc<SessionPool>>> {
575        self.map
576            .lock()
577            .unwrap_or_else(std::sync::PoisonError::into_inner)
578    }
579}
580
581#[cfg(test)]
582#[allow(clippy::unwrap_used, clippy::expect_used)]
583mod tests {
584    use std::sync::atomic::AtomicU32;
585
586    use super::*;
587
588    fn ctx() -> QueryContext {
589        QueryContext::default()
590    }
591
592    fn fake_pool(max: usize) -> (SessionPool<u32>, Arc<AtomicU32>) {
593        let pool = SessionPool::<u32>::new(
594            1,
595            SessionKey::new("ACCT", "user"),
596            max,
597            Utc::now(),
598            Arc::new(TokioMutex::new(())),
599        );
600        (pool, Arc::new(AtomicU32::new(0)))
601    }
602
603    async fn fake_create(
604        counter: &AtomicU32,
605    ) -> std::result::Result<(u32, QueryContext), std::convert::Infallible> {
606        Ok((counter.fetch_add(1, Ordering::Relaxed), ctx()))
607    }
608
609    #[tokio::test]
610    async fn start_query_records_running_then_checkin_clears_it() {
611        let (pool, calls) = fake_pool(2);
612        let c = pool.checkout(|| fake_create(&calls)).await.unwrap();
613        pool.start_query(c.id(), "SELECT 1".to_string());
614
615        let member = pool.info().members[0].clone();
616        assert!(member.busy);
617        assert_eq!(member.query_count, 1);
618        assert_eq!(
619            member.running.as_ref().map(|r| r.sql.as_str()),
620            Some("SELECT 1")
621        );
622
623        pool.checkin(c, ctx());
624        let member = pool.info().members[0].clone();
625        assert!(!member.busy);
626        assert!(member.running.is_none(), "running cleared on checkin");
627        assert_eq!(member.query_count, 1, "count persists after checkin");
628    }
629
630    #[test]
631    fn overlay_lets_overrides_win() {
632        let base = QueryContext {
633            warehouse: Some("WH".into()),
634            role: Some("R".into()),
635            database: Some("DB".into()),
636            schema: Some("S".into()),
637        };
638        let overrides = QueryContext {
639            warehouse: Some("OTHER_WH".into()),
640            ..QueryContext::default()
641        };
642        let eff = base.overlay(&overrides);
643        assert_eq!(eff.warehouse.as_deref(), Some("OTHER_WH"));
644        assert_eq!(eff.role.as_deref(), Some("R")); // unchanged falls back to base
645        assert_eq!(eff.database.as_deref(), Some("DB"));
646    }
647
648    #[test]
649    fn summary_renders_set_dimensions_or_default() {
650        assert_eq!(QueryContext::default().summary(), "(default)");
651        let c = QueryContext {
652            warehouse: Some("WH".into()),
653            role: Some("R".into()),
654            ..QueryContext::default()
655        };
656        assert_eq!(c.summary(), "WH/R");
657    }
658
659    #[tokio::test]
660    async fn checkin_reuses_session_and_lists_members() {
661        let (pool, calls) = fake_pool(4);
662        let c1 = pool.checkout(|| fake_create(&calls)).await.unwrap();
663        let id1 = c1.id();
664        // While checked out the member is visible and marked busy.
665        let info = pool.info();
666        assert_eq!(info.members.len(), 1);
667        assert!(info.members[0].busy);
668        pool.checkin(c1, ctx());
669        // Now idle.
670        assert!(!pool.info().members[0].busy);
671        // Reuse the idle session: no new create, same member id.
672        let c2 = pool.checkout(|| fake_create(&calls)).await.unwrap();
673        assert_eq!(c2.id(), id1);
674        assert_eq!(calls.load(Ordering::Relaxed), 1, "should not create twice");
675        assert_eq!(pool.live(), 1);
676        pool.checkin(c2, ctx());
677    }
678
679    #[tokio::test]
680    async fn never_exceeds_capacity_and_blocks_until_checkin() {
681        let (pool, calls) = fake_pool(2);
682        let c1 = pool.checkout(|| fake_create(&calls)).await.unwrap();
683        let c2 = pool.checkout(|| fake_create(&calls)).await.unwrap();
684        assert_eq!(pool.live(), 2);
685        // Both members visible as busy.
686        assert_eq!(pool.info().members.iter().filter(|m| m.busy).count(), 2);
687        // A third checkout must block while both are held.
688        let third = tokio::time::timeout(
689            std::time::Duration::from_millis(50),
690            pool.checkout(|| fake_create(&calls)),
691        )
692        .await;
693        assert!(third.is_err(), "third checkout should block at capacity");
694        pool.checkin(c1, ctx());
695        let c3 = pool.checkout(|| fake_create(&calls)).await.unwrap();
696        assert_eq!(pool.live(), 2, "reuse, not grow");
697        assert_eq!(calls.load(Ordering::Relaxed), 2);
698        pool.checkin(c2, ctx());
699        pool.checkin(c3, ctx());
700    }
701
702    #[tokio::test]
703    async fn discard_frees_capacity_for_a_fresh_session() {
704        let (pool, calls) = fake_pool(1);
705        let c1 = pool.checkout(|| fake_create(&calls)).await.unwrap();
706        assert_eq!(pool.live(), 1);
707        pool.discard(c1); // expired session dropped
708        assert_eq!(pool.live(), 0);
709        assert!(pool.info().members.is_empty());
710        let c2 = pool.checkout(|| fake_create(&calls)).await.unwrap();
711        assert_eq!(calls.load(Ordering::Relaxed), 2, "fresh session created");
712        assert_eq!(pool.live(), 1);
713        pool.checkin(c2, ctx());
714    }
715
716    #[tokio::test]
717    async fn orphaned_checkout_frees_its_slot_on_drop() {
718        let (pool, calls) = fake_pool(2);
719        {
720            let _c = pool.checkout(|| fake_create(&calls)).await.unwrap();
721            assert_eq!(pool.live(), 1);
722            // `_c` is dropped here without checkin/discard (e.g. a panic path).
723        }
724        assert_eq!(pool.live(), 0, "the Drop guard removed the orphaned slot");
725        assert!(pool.info().members.is_empty());
726        // Capacity is freed, so a fresh checkout still works.
727        let c = pool.checkout(|| fake_create(&calls)).await.unwrap();
728        pool.checkin(c, ctx());
729    }
730
731    #[tokio::test]
732    async fn waiter_grabs_freed_session_without_waiting_out_the_in_flight_auth() {
733        let (pool, calls) = fake_pool(2);
734        let pool = Arc::new(pool);
735
736        // One live session, checked out — so there's nothing idle initially.
737        let c1 = pool.checkout(|| fake_create(&calls)).await.unwrap();
738        assert_eq!(calls.load(Ordering::Relaxed), 1);
739
740        // Hold the auth gate for the WHOLE test, simulating an in-flight auth that
741        // never completes.
742        let held = Arc::clone(&pool.auth_gate).lock_owned().await;
743
744        // A second request: acquires a permit, finds no idle, then parks in the
745        // notify-vs-gate race.
746        let pool2 = Arc::clone(&pool);
747        let calls2 = Arc::clone(&calls);
748        let waiter = tokio::spawn(async move {
749            pool2
750                .checkout(move || async move {
751                    let id = calls2.fetch_add(1, Ordering::Relaxed);
752                    Ok::<(u32, QueryContext), std::convert::Infallible>((
753                        id,
754                        QueryContext::default(),
755                    ))
756                })
757                .await
758                .unwrap()
759        });
760
761        // Let the waiter park, then free c1's session WITHOUT releasing the gate.
762        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
763        pool.checkin(c1, ctx());
764
765        // The waiter must complete by reusing the freed session even though the
766        // auth gate is still held — proving it didn't wait out the in-flight auth.
767        let c2 = tokio::time::timeout(std::time::Duration::from_secs(2), waiter)
768            .await
769            .expect("waiter must not block on the held auth gate")
770            .unwrap();
771        assert_eq!(
772            calls.load(Ordering::Relaxed),
773            1,
774            "reused the freed session — no new auth"
775        );
776        assert_eq!(pool.live(), 1, "still one session");
777
778        drop(held);
779        pool.checkin(c2, ctx());
780    }
781
782    #[test]
783    fn registry_get_or_create_is_idempotent_per_key() {
784        let registry = PoolRegistry::new();
785        assert!(registry.is_empty());
786        let key = SessionKey::new("ACCT", "user");
787        let p1 = registry.get_or_create(&key, 4);
788        let p2 = registry.get_or_create(&key, 4);
789        assert_eq!(p1.id(), p2.id());
790        assert_eq!(registry.len(), 1);
791        assert!(registry.remove(&key).is_some());
792        assert!(registry.remove(&key).is_none());
793    }
794}