Skip to main content

omni_dev/
snowflake.rs

1//! Account-agnostic Snowflake query engine hosted by the daemon.
2//!
3//! Each `(account, user)` keeps a **bounded pool** of authenticated sessions
4//! (see [`session`]). A query checks one out, applies any per-query context with
5//! `USE` (skipping `USE`s already in effect), runs concurrently with other
6//! checkouts, and returns it. This gives **concurrent queries on a single
7//! authentication identity** while still honoring per-query
8//! `warehouse`/`role`/`database`/`schema`, with the number of browser auths
9//! capped at the pool size and grown lazily.
10//!
11//! A background keep-alive heartbeat ([`SnowflakeEngine::start_heartbeat`])
12//! periodically heartbeats idle sessions so their master tokens stay valid and
13//! an idle pool never re-prompts browser SSO.
14//!
15//! This is the standalone engine, analogous to [`crate::browser`]; the daemon
16//! adapter lives in [`crate::daemon::services::snowflake`].
17
18pub mod client;
19pub mod session;
20
21use std::sync::{Mutex as StdMutex, MutexGuard};
22use std::time::Duration;
23
24use anyhow::{anyhow, bail, Context, Result};
25use chrono::TimeDelta;
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28use tokio::task::JoinHandle;
29use tokio_util::sync::CancellationToken;
30
31use crate::utils::env::EnvSource;
32use crate::utils::secret::Secret;
33use crate::utils::settings::Settings;
34use client::{
35    AuthMethod, BrowserConfig, Error as ClientError, KeyPairConfig, Row, SnowflakeClient,
36    SnowflakeClientConfig, SnowflakeSession,
37};
38use session::{PoolRegistry, QueryContext, SessionInfo, SessionKey};
39
40/// Env var (with `~/.omni-dev/settings.json` fallback) for the default account.
41const ENV_ACCOUNT: &str = "SNOWFLAKE_ACCOUNT";
42/// Env var for the default user.
43const ENV_USER: &str = "SNOWFLAKE_USER";
44/// Env var overriding the API host (verbatim), instead of deriving
45/// `<account>.snowflakecomputing.com`. Needed for AWS/Azure PrivateLink
46/// endpoints and gov/custom hosts.
47const ENV_HOST: &str = "SNOWFLAKE_HOST";
48/// Env var for the default warehouse.
49const ENV_WAREHOUSE: &str = "SNOWFLAKE_WAREHOUSE";
50/// Env var for the default role.
51const ENV_ROLE: &str = "SNOWFLAKE_ROLE";
52/// Env var for the default database.
53const ENV_DATABASE: &str = "SNOWFLAKE_DATABASE";
54/// Env var for the default schema.
55const ENV_SCHEMA: &str = "SNOWFLAKE_SCHEMA";
56/// Env var for the per-`(account, user)` pool size (max concurrent sessions).
57const ENV_POOL_SIZE: &str = "SNOWFLAKE_POOL_SIZE";
58/// Env var for the per-request HTTP timeout (seconds).
59const ENV_HTTP_TIMEOUT: &str = "SNOWFLAKE_HTTP_TIMEOUT";
60/// Env var for the overall sign-in deadline (seconds).
61const ENV_AUTH_TIMEOUT: &str = "SNOWFLAKE_AUTH_TIMEOUT";
62/// Env var for the overall per-query deadline incl. async polling (seconds).
63const ENV_QUERY_TIMEOUT: &str = "SNOWFLAKE_QUERY_TIMEOUT";
64/// Env var for the idle-session keep-alive heartbeat interval (seconds; `0`
65/// disables the heartbeat).
66const ENV_HEARTBEAT_INTERVAL: &str = "SNOWFLAKE_HEARTBEAT_INTERVAL";
67/// Env var selecting the auth method: `externalbrowser` (default; interactive
68/// SSO), `programmatic_access_token`, or `snowflake_jwt` (both non-interactive).
69const ENV_AUTHENTICATOR: &str = "SNOWFLAKE_AUTHENTICATOR";
70/// Env var for the programmatic access token (PAT auth).
71const ENV_TOKEN: &str = "SNOWFLAKE_TOKEN";
72/// Env var for the path to an unencrypted PKCS#8 PEM private key (JWT auth).
73const ENV_PRIVATE_KEY_PATH: &str = "SNOWFLAKE_PRIVATE_KEY_PATH";
74/// Env var for an inline unencrypted PKCS#8 PEM private key (alternative to the
75/// path).
76const ENV_PRIVATE_KEY: &str = "SNOWFLAKE_PRIVATE_KEY";
77/// Env var for an encrypted key's passphrase. Recognized but not yet supported;
78/// setting it with the JWT method is a clear error.
79const ENV_PRIVATE_KEY_PASSPHRASE: &str = "SNOWFLAKE_PRIVATE_KEY_PASSPHRASE";
80
81/// Default pool size when `SNOWFLAKE_POOL_SIZE` is unset: the max concurrent
82/// queries (and max browser auths) per `(account, user)`.
83const DEFAULT_POOL_SIZE: usize = 4;
84/// Default overall sign-in deadline: comfortably over the SSO callback wait so a
85/// genuine sign-in completes, but bounded so a hung auth can't hold the gate.
86const DEFAULT_AUTH_TIMEOUT: Duration = Duration::from_secs(150);
87/// Max characters of SQL shown in the "running" preview for a busy session.
88const SQL_PREVIEW_MAX: usize = 60;
89/// Default keep-alive heartbeat interval: a quarter of the default 3600s
90/// session-token validity (Snowflake's own drivers clamp their heartbeat
91/// frequency to 900–3600s), so an idle session renews comfortably before
92/// either token lapses.
93const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(900);
94/// Extra margin past the heartbeat interval when deciding to proactively renew
95/// an idle session's token before it would lapse mid-cycle.
96const HEARTBEAT_RENEW_MARGIN_SECS: i64 = 60;
97
98/// Engine defaults, resolved from environment variables and then
99/// `~/.omni-dev/settings.json` (the Atlassian credential-resolution pattern).
100///
101/// Account/user/context are optional; a request supplies its own and falls back
102/// to these. There is **no** hardcoded account list or alias map.
103#[derive(Clone, Debug)]
104pub struct SnowflakeEngineConfig {
105    /// Default account when a request omits `--account`.
106    pub default_account: Option<String>,
107    /// Default user when a request omits `--user`.
108    pub default_user: Option<String>,
109    /// Override the API host (verbatim) instead of deriving
110    /// `<account>.snowflakecomputing.com`. Set for PrivateLink / gov / custom
111    /// hosts; applies to every session this engine creates.
112    pub default_host: Option<String>,
113    /// Default warehouse applied at session creation.
114    pub default_warehouse: Option<String>,
115    /// Default role applied at session creation.
116    pub default_role: Option<String>,
117    /// Default database applied at session creation.
118    pub default_database: Option<String>,
119    /// Default schema applied at session creation.
120    pub default_schema: Option<String>,
121    /// How sessions authenticate (SSO by default; PAT or key-pair JWT for
122    /// non-interactive/headless use). The credential is the same for every
123    /// session in every pool this engine creates.
124    pub auth: AuthMethod,
125    /// Max concurrent sessions (and browser auths) per `(account, user)`.
126    pub pool_size: usize,
127    /// Per-request HTTP timeout for REST calls.
128    pub http_timeout: Duration,
129    /// Overall deadline for one sign-in (SSO + login) so a hung auth can't hold
130    /// the shared auth gate indefinitely.
131    pub auth_timeout: Duration,
132    /// Overall deadline for one query (submit + async-result polling).
133    pub query_timeout: Duration,
134    /// How often the background task heartbeats idle sessions to keep their
135    /// master token alive. Zero disables the heartbeat.
136    pub heartbeat_interval: Duration,
137}
138
139impl Default for SnowflakeEngineConfig {
140    fn default() -> Self {
141        Self {
142            default_account: None,
143            default_user: None,
144            default_host: None,
145            default_warehouse: None,
146            default_role: None,
147            default_database: None,
148            default_schema: None,
149            auth: AuthMethod::ExternalBrowser(BrowserConfig::default()),
150            pool_size: DEFAULT_POOL_SIZE,
151            http_timeout: client::config::DEFAULT_HTTP_TIMEOUT,
152            auth_timeout: DEFAULT_AUTH_TIMEOUT,
153            query_timeout: client::config::DEFAULT_QUERY_TIMEOUT,
154            heartbeat_interval: DEFAULT_HEARTBEAT_INTERVAL,
155        }
156    }
157}
158
159impl SnowflakeEngineConfig {
160    /// Resolves defaults from env vars (then settings.json). Cheap and
161    /// side-effect-free; never authenticates.
162    ///
163    /// # Errors
164    ///
165    /// If `SNOWFLAKE_AUTHENTICATOR` names an unknown method or a selected
166    /// non-interactive method is missing its credential (see
167    /// [`resolve_auth_method`]).
168    pub fn from_env_and_settings() -> Result<Self> {
169        let settings = Settings::load().unwrap_or_default();
170        let pool_size = settings
171            .get_env_var(ENV_POOL_SIZE)
172            .and_then(|s| s.trim().parse::<usize>().ok())
173            .filter(|&n| n >= 1)
174            .unwrap_or(DEFAULT_POOL_SIZE);
175        let secs = |key: &str| {
176            settings
177                .get_env_var(key)
178                .and_then(|s| s.trim().parse::<u64>().ok())
179                .filter(|&n| n >= 1)
180                .map(Duration::from_secs)
181        };
182        let private_key_pem = match settings.get_env_var(ENV_PRIVATE_KEY_PATH) {
183            Some(path) => Some(
184                std::fs::read_to_string(&path)
185                    .with_context(|| format!("reading {ENV_PRIVATE_KEY_PATH} '{path}'"))?,
186            ),
187            None => settings.get_env_var(ENV_PRIVATE_KEY),
188        };
189        let auth = resolve_auth_method(
190            settings.get_env_var(ENV_AUTHENTICATOR).as_deref(),
191            settings.get_env_var(ENV_TOKEN),
192            private_key_pem,
193            settings.get_env_var(ENV_PRIVATE_KEY_PASSPHRASE),
194        )?;
195        Ok(Self {
196            default_account: settings.get_env_var(ENV_ACCOUNT),
197            default_user: settings.get_env_var(ENV_USER),
198            default_host: host_override_from(settings.get_env_var(ENV_HOST)),
199            default_warehouse: settings.get_env_var(ENV_WAREHOUSE),
200            default_role: settings.get_env_var(ENV_ROLE),
201            default_database: settings.get_env_var(ENV_DATABASE),
202            default_schema: settings.get_env_var(ENV_SCHEMA),
203            auth,
204            pool_size,
205            http_timeout: secs(ENV_HTTP_TIMEOUT).unwrap_or(client::config::DEFAULT_HTTP_TIMEOUT),
206            auth_timeout: secs(ENV_AUTH_TIMEOUT).unwrap_or(DEFAULT_AUTH_TIMEOUT),
207            query_timeout: secs(ENV_QUERY_TIMEOUT).unwrap_or(client::config::DEFAULT_QUERY_TIMEOUT),
208            heartbeat_interval: heartbeat_interval_from(
209                settings.get_env_var(ENV_HEARTBEAT_INTERVAL),
210            ),
211        })
212    }
213}
214
215/// Normalizes the `SNOWFLAKE_HOST` override: trims surrounding whitespace (e.g. a
216/// trailing newline from a `$(cat …)`-style value) and treats a blank value as
217/// unset, so an empty override never shadows the derived host.
218fn host_override_from(raw: Option<String>) -> Option<String> {
219    raw.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
220}
221
222/// Parses the heartbeat-interval setting: seconds, with `0` meaning disabled.
223/// Unset or unparseable values fall back to the default. (The `secs` helper in
224/// [`SnowflakeEngineConfig::from_env_and_settings`] rejects `0`, which here is
225/// a meaningful value.)
226fn heartbeat_interval_from(raw: Option<String>) -> Duration {
227    raw.and_then(|s| s.trim().parse::<u64>().ok())
228        .map_or(DEFAULT_HEARTBEAT_INTERVAL, Duration::from_secs)
229}
230
231/// Resolves the [`AuthMethod`] from the `SNOWFLAKE_AUTHENTICATOR` selector and
232/// the method-specific credential vars (`private_key_pem` is the already-read
233/// key material, from a file or inline). An unset or blank selector keeps
234/// external-browser SSO, preserving the pre-#1108 default. The PAT secret is
235/// trimmed (dropping a stray trailing newline from `$(cat …)`-style values).
236///
237/// # Errors
238///
239/// If the selector is unknown, a non-interactive method is selected without its
240/// credential, or an encrypted key passphrase is set (unsupported).
241fn resolve_auth_method(
242    authenticator: Option<&str>,
243    token: Option<String>,
244    private_key_pem: Option<String>,
245    passphrase: Option<String>,
246) -> Result<AuthMethod> {
247    let selector = authenticator.unwrap_or("").trim().to_ascii_lowercase();
248    match selector.as_str() {
249        "" | "externalbrowser" | "external_browser" => {
250            Ok(AuthMethod::ExternalBrowser(BrowserConfig::default()))
251        }
252        "programmatic_access_token" | "pat" => {
253            let token = token
254                .map(|t| t.trim().to_string())
255                .filter(|t| !t.is_empty())
256                .ok_or_else(|| {
257                    anyhow!("{ENV_AUTHENTICATOR}={selector} requires {ENV_TOKEN} to be set")
258                })?;
259            Ok(AuthMethod::ProgrammaticAccessToken {
260                token: Secret::from(token),
261            })
262        }
263        "snowflake_jwt" | "keypair" | "key_pair" | "jwt" => {
264            if passphrase.is_some_and(|p| !p.trim().is_empty()) {
265                bail!(
266                    "{ENV_PRIVATE_KEY_PASSPHRASE} is set, but encrypted private keys are not yet \
267                     supported; decrypt the key with `openssl pkcs8 -in key.p8 -out \
268                     key_unencrypted.p8` and unset {ENV_PRIVATE_KEY_PASSPHRASE}"
269                );
270            }
271            let private_key_pem = private_key_pem
272                .filter(|k| !k.trim().is_empty())
273                .ok_or_else(|| {
274                    anyhow!(
275                        "{ENV_AUTHENTICATOR}={selector} requires {ENV_PRIVATE_KEY_PATH} or \
276                         {ENV_PRIVATE_KEY}"
277                    )
278                })?;
279            Ok(AuthMethod::KeyPairJwt(KeyPairConfig {
280                private_key_pem: Secret::from(private_key_pem),
281            }))
282        }
283        other => Err(anyhow!(
284            "unknown {ENV_AUTHENTICATOR} '{other}' \
285             (expected externalbrowser, programmatic_access_token, or snowflake_jwt)"
286        )),
287    }
288}
289
290/// A single arbitrary-SQL query request routed to the engine.
291///
292/// `account`/`user` and the per-query context default to the engine config when
293/// omitted. Serialized by the CLI client (after [`fill_defaults_from`]
294/// resolution) and deserialized from the daemon `query` op payload.
295///
296/// [`fill_defaults_from`]: QueryRequest::fill_defaults_from
297#[derive(Clone, Debug, Default, Deserialize, Serialize)]
298#[serde(default)]
299pub struct QueryRequest {
300    /// Target account; falls back to `SNOWFLAKE_ACCOUNT`.
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub account: Option<String>,
303    /// Authenticating user; falls back to `SNOWFLAKE_USER`.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub user: Option<String>,
306    /// Per-query `USE WAREHOUSE` override.
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub warehouse: Option<String>,
309    /// Per-query `USE ROLE` override.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub role: Option<String>,
312    /// Per-query `USE DATABASE` override.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub database: Option<String>,
315    /// Per-query `USE SCHEMA` override.
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub schema: Option<String>,
318    /// The SQL to execute.
319    pub sql: String,
320}
321
322impl QueryRequest {
323    /// Fills each unset identity/context field from `env`.
324    ///
325    /// Called by the CLI client with a profile-aware source
326    /// ([`SettingsEnv`](crate::utils::settings::SettingsEnv)) so that
327    /// `--profile` / `OMNI_DEV_PROFILE` resolve in the invoking process —
328    /// the daemon's startup defaults then only back-fill requests that still
329    /// omit a field (e.g. bare socket clients). Explicit values are never
330    /// overwritten.
331    pub fn fill_defaults_from(&mut self, env: &impl EnvSource) {
332        let fill = |slot: &mut Option<String>, key: &str| {
333            if slot.is_none() {
334                *slot = env.var(key);
335            }
336        };
337        fill(&mut self.account, ENV_ACCOUNT);
338        fill(&mut self.user, ENV_USER);
339        fill(&mut self.warehouse, ENV_WAREHOUSE);
340        fill(&mut self.role, ENV_ROLE);
341        fill(&mut self.database, ENV_DATABASE);
342        fill(&mut self.schema, ENV_SCHEMA);
343    }
344    /// The per-query context overrides (the `Some` fields override the session
345    /// base context).
346    fn overrides(&self) -> QueryContext {
347        QueryContext {
348            warehouse: self.warehouse.clone(),
349            role: self.role.clone(),
350            database: self.database.clone(),
351            schema: self.schema.clone(),
352        }
353    }
354}
355
356/// The running keep-alive heartbeat loop: cancelled and awaited on shutdown.
357struct HeartbeatTask {
358    token: CancellationToken,
359    handle: JoinHandle<()>,
360}
361
362/// The account-agnostic Snowflake query engine: lazy multiplexed auth, bounded
363/// per-identity session pools, and concurrent arbitrary-SQL execution.
364///
365/// A background keep-alive heartbeat for idle sessions is started by
366/// [`start_heartbeat`](Self::start_heartbeat) and stopped by
367/// [`shutdown`](Self::shutdown).
368pub struct SnowflakeEngine {
369    config: SnowflakeEngineConfig,
370    registry: PoolRegistry,
371    heartbeat: StdMutex<Option<HeartbeatTask>>,
372}
373
374impl SnowflakeEngine {
375    /// Builds an engine. Cheap — no eager auth or I/O.
376    #[must_use]
377    pub fn new(config: SnowflakeEngineConfig) -> Self {
378        Self {
379            config,
380            registry: PoolRegistry::new(),
381            heartbeat: StdMutex::new(None),
382        }
383    }
384
385    /// Starts the background keep-alive heartbeat loop (idempotent).
386    ///
387    /// Every `heartbeat_interval` the loop heartbeats each pool's idle sessions
388    /// — renewing a session token about to lapse — so the server-side
389    /// `CLIENT_SESSION_KEEP_ALIVE` actually extends the master token and an
390    /// idle pool survives past the token TTL without a new browser SSO (#1107).
391    /// Busy sessions are skipped; the query path keeps them alive inline.
392    ///
393    /// No-op when the interval is zero (disabled) or when called outside a
394    /// tokio runtime. Stopped by [`shutdown`](Self::shutdown).
395    pub fn start_heartbeat(&self) {
396        let interval = self.config.heartbeat_interval;
397        if interval.is_zero() {
398            return;
399        }
400        if tokio::runtime::Handle::try_current().is_err() {
401            tracing::debug!("no tokio runtime; Snowflake keep-alive heartbeat not started");
402            return;
403        }
404        let mut guard = self.lock_heartbeat();
405        if guard.is_some() {
406            return;
407        }
408        let token = CancellationToken::new();
409        let loop_token = token.clone();
410        let registry = self.registry.clone();
411        let handle = tokio::spawn(async move {
412            loop {
413                tokio::select! {
414                    () = loop_token.cancelled() => break,
415                    () = tokio::time::sleep(interval) => {
416                        heartbeat_all_pools(&registry, interval).await;
417                    }
418                }
419            }
420        });
421        *guard = Some(HeartbeatTask { token, handle });
422    }
423
424    fn lock_heartbeat(&self) -> MutexGuard<'_, Option<HeartbeatTask>> {
425        self.heartbeat
426            .lock()
427            .unwrap_or_else(std::sync::PoisonError::into_inner)
428    }
429
430    /// A snapshot of every active pool.
431    #[must_use]
432    pub fn sessions(&self) -> Vec<SessionInfo> {
433        self.registry.snapshot()
434    }
435
436    /// The number of active pools (`(account, user)` identities).
437    #[must_use]
438    pub fn pool_count(&self) -> usize {
439        self.registry.len()
440    }
441
442    /// Evicts the pool for `(account, user)`. Returns whether one existed.
443    pub fn disconnect(&self, account: &str, user: &str) -> bool {
444        let key = SessionKey::new(normalize_account(account), user.trim());
445        self.registry.remove(&key).is_some()
446    }
447
448    /// Evicts the pool with the given id. Returns whether one existed.
449    pub fn disconnect_by_id(&self, id: u64) -> bool {
450        self.registry.remove_by_id(id).is_some()
451    }
452
453    /// Evicts every pool. Returns how many were removed.
454    pub fn disconnect_all(&self) -> usize {
455        self.registry.take_all().len()
456    }
457
458    /// Stops the keep-alive heartbeat, then drops every pool (and its sessions).
459    pub async fn shutdown(&self) {
460        let task = self.lock_heartbeat().take();
461        if let Some(task) = task {
462            task.token.cancel();
463            let _ = task.handle.await;
464        }
465        let pools = self.registry.take_all();
466        drop(pools);
467    }
468
469    /// Runs arbitrary SQL against the `(account, user)` pool, authenticating a
470    /// session on first use, and returns a self-describing `{ columns, rows }`
471    /// payload. Concurrent calls run on separate pooled sessions (up to the pool
472    /// size).
473    ///
474    /// # Errors
475    ///
476    /// Returns an error if no account/user can be resolved, a context flag is not
477    /// a valid identifier, authentication fails, or the query fails. On a
478    /// session-expiry error that session is discarded and the next query
479    /// re-authenticates.
480    pub async fn query(&self, req: QueryRequest) -> Result<Value> {
481        let account = normalize_account(
482            req.account
483                .as_deref()
484                .or(self.config.default_account.as_deref())
485                .ok_or_else(|| {
486                    anyhow!("no Snowflake account: pass --account or set SNOWFLAKE_ACCOUNT")
487                })?,
488        );
489        let user = req
490            .user
491            .as_deref()
492            .or(self.config.default_user.as_deref())
493            .ok_or_else(|| anyhow!("no Snowflake user: pass --user or set SNOWFLAKE_USER"))?
494            .trim()
495            .to_string();
496        validate_context(&req)?;
497
498        let key = SessionKey::new(account, user);
499        let overrides = req.overrides();
500        let pool = self.registry.get_or_create(&key, self.config.pool_size);
501
502        // Check out a session. The pool reuses an idle one when available
503        // (re-checking after the auth gate so a session freed mid-auth is reused),
504        // and only authenticates a new one — serialized to one browser at a time
505        // by the pool's shared auth gate — when none is idle and it is under
506        // capacity. The permit inside the checkout caps concurrency at pool_size.
507        let cfg = self.config.clone();
508        let create_key = key.clone();
509        let auth_timeout = self.config.auth_timeout;
510        let checkout = pool
511            .checkout(move || async move {
512                // Overall sign-in deadline so a hung auth releases the gate.
513                match tokio::time::timeout(
514                    auth_timeout,
515                    create_session_with_base(&create_key, &cfg),
516                )
517                .await
518                {
519                    Ok(result) => result,
520                    Err(_) => Err(ClientError::Auth(format!(
521                        "Snowflake sign-in timed out after {auth_timeout:?}"
522                    ))),
523                }
524            })
525            .await
526            .map_err(|e| {
527                anyhow::Error::new(e).context(format!(
528                    "failed to authenticate Snowflake session for {} / {}",
529                    key.account, key.user
530                ))
531            })?;
532
533        // Proactively renew a session whose token is about to expire, before use.
534        if checkout
535            .session()
536            .session_expiring_within(TimeDelta::seconds(120))
537            && checkout.session().renew().await.is_err()
538        {
539            pool.discard(checkout);
540            return Err(anyhow!(
541                "Snowflake session expired and was discarded — re-run the query to re-authenticate"
542            ));
543        }
544
545        // Record what this member is now running, so menus/status show it.
546        pool.start_query(checkout.id(), sql_preview(&req.sql, SQL_PREVIEW_MAX));
547
548        // Apply the requested context and run the query, transparently renewing
549        // the token and retrying once if it expires mid-flight.
550        let target = checkout.base().overlay(&overrides);
551        match run_with_renew(checkout.session(), checkout.current(), &target, &req.sql).await {
552            Ok(rows) => {
553                pool.touch();
554                pool.checkin(checkout, target);
555                Ok(client::rows_to_payload(&rows))
556            }
557            Err(e) if e.is_session_expired() => {
558                pool.discard(checkout);
559                Err(anyhow!(
560                    "Snowflake session expired and was discarded — re-run the query to re-authenticate"
561                ))
562            }
563            Err(e) => {
564                // Log the underlying cause server-side and surface it to the
565                // client (the daemon reply uses the full anyhow chain).
566                tracing::warn!("Snowflake query failed: {e}");
567                // The session's context is uncertain after a failure; check in
568                // with an empty context so the next reuse re-applies every dimension.
569                pool.checkin(checkout, QueryContext::default());
570                Err(anyhow::Error::new(e).context("Snowflake query failed"))
571            }
572        }
573    }
574}
575
576impl Drop for SnowflakeEngine {
577    fn drop(&mut self) {
578        // Best-effort: an engine dropped without `shutdown()` must not leave the
579        // heartbeat loop running forever. Cancellation is sync; the task itself
580        // is detached and exits on its next select.
581        let task = self.lock_heartbeat().take();
582        if let Some(task) = task {
583            task.token.cancel();
584        }
585    }
586}
587
588/// Sends one keep-alive round to every pool's currently-idle sessions,
589/// discarding any session that is dead beyond renewal.
590async fn heartbeat_all_pools(registry: &PoolRegistry, interval: Duration) {
591    for pool in registry.pools() {
592        let checkouts = pool.checkout_all_idle();
593        if checkouts.is_empty() {
594            continue;
595        }
596        let total = checkouts.len();
597        let mut kept = 0usize;
598        for checkout in checkouts {
599            if keep_session_alive(checkout.session(), interval).await {
600                pool.restore(checkout);
601                kept += 1;
602            } else {
603                pool.discard(checkout);
604            }
605        }
606        tracing::debug!(
607            pool = pool.id(),
608            kept,
609            total,
610            "Snowflake keep-alive heartbeat round"
611        );
612    }
613}
614
615/// Keeps one idle session alive: proactively renews a session token that would
616/// lapse before the next tick (the heartbeat itself is authorized by the
617/// session token), then heartbeats so the server extends the master token.
618///
619/// Returns whether the session is still usable: `false` only when the master
620/// token has expired (a full re-auth is unavoidable), so the caller discards
621/// it. Transient errors keep the session for the next tick.
622async fn keep_session_alive(session: &SnowflakeSession, interval: Duration) -> bool {
623    let margin_secs = i64::try_from(interval.as_secs())
624        .unwrap_or(i64::MAX)
625        .saturating_add(HEARTBEAT_RENEW_MARGIN_SECS);
626    let margin = TimeDelta::try_seconds(margin_secs).unwrap_or(TimeDelta::MAX);
627    if session.session_expiring_within(margin) {
628        match session.renew().await {
629            Ok(()) => {}
630            Err(e) if e.is_session_expired() => {
631                tracing::warn!("Snowflake keep-alive: master token expired; discarding session");
632                return false;
633            }
634            Err(e) => {
635                // Transient: keep the session and try again next tick.
636                tracing::warn!("Snowflake keep-alive renew failed: {e}");
637                return true;
638            }
639        }
640    }
641    match session.heartbeat().await {
642        Ok(()) => true,
643        Err(e) if e.is_session_expired() => match session.renew().await {
644            Ok(()) => true,
645            Err(renew_err) if renew_err.is_session_expired() => {
646                tracing::warn!("Snowflake keep-alive: master token expired; discarding session");
647                false
648            }
649            Err(renew_err) => {
650                tracing::warn!("Snowflake keep-alive renew failed: {renew_err}");
651                true
652            }
653        },
654        Err(e) => {
655            tracing::warn!("Snowflake keep-alive heartbeat failed: {e}");
656            true
657        }
658    }
659}
660
661/// Authenticates a session (via the engine's configured auth method), enables
662/// keep-alive, and captures its base (account/user default) context.
663async fn create_session_with_base(
664    key: &SessionKey,
665    config: &SnowflakeEngineConfig,
666) -> client::Result<(SnowflakeSession, QueryContext)> {
667    let mut cfg = SnowflakeClientConfig::external_browser(&key.account, &key.user);
668    cfg.auth = config.auth.clone();
669    cfg.host = config.default_host.clone();
670    cfg.warehouse = config.default_warehouse.clone();
671    cfg.role = config.default_role.clone();
672    cfg.database = config.default_database.clone();
673    cfg.schema = config.default_schema.clone();
674    cfg.http_timeout = config.http_timeout;
675    cfg.query_timeout = config.query_timeout;
676
677    let client = SnowflakeClient::new(cfg)?;
678    let session = client.create_session().await?;
679    session
680        .query("ALTER SESSION SET CLIENT_SESSION_KEEP_ALIVE = true")
681        .await?;
682    let base = capture_base_context(&session).await?;
683    Ok((session, base))
684}
685
686/// Reads the session's effective default context so per-query overrides can
687/// later be reset back to it.
688async fn capture_base_context(session: &SnowflakeSession) -> client::Result<QueryContext> {
689    let rows = session
690        .query("SELECT CURRENT_WAREHOUSE(), CURRENT_ROLE(), CURRENT_DATABASE(), CURRENT_SCHEMA()")
691        .await?;
692    let Some(row) = rows.first() else {
693        return Ok(QueryContext::default());
694    };
695    Ok(QueryContext {
696        warehouse: row.raw_at(0).map(str::to_string),
697        role: row.raw_at(1).map(str::to_string),
698        database: row.raw_at(2).map(str::to_string),
699        schema: row.raw_at(3).map(str::to_string),
700    })
701}
702
703/// Applies the context and runs the SQL, transparently renewing the session
704/// token (via the master token) and retrying once if it expired mid-flight.
705async fn run_with_renew(
706    session: &SnowflakeSession,
707    current: &QueryContext,
708    target: &QueryContext,
709    sql: &str,
710) -> client::Result<Vec<Row>> {
711    match apply_and_query(session, current, target, sql).await {
712        Err(e) if e.is_session_expired() => {
713            session.renew().await?;
714            // Re-apply the full context on the renewed session, then retry.
715            apply_and_query(session, &QueryContext::default(), target, sql).await
716        }
717        other => other,
718    }
719}
720
721/// Issues any needed `USE` statements, then runs the SQL.
722async fn apply_and_query(
723    session: &SnowflakeSession,
724    current: &QueryContext,
725    target: &QueryContext,
726    sql: &str,
727) -> client::Result<Vec<Row>> {
728    apply_context(session, current, target).await?;
729    session.query(sql).await
730}
731
732/// Issues `USE` for each context dimension whose target differs from the
733/// session's current value. Target names are either validated user overrides or
734/// Snowflake-reported base names.
735async fn apply_context(
736    session: &SnowflakeSession,
737    current: &QueryContext,
738    target: &QueryContext,
739) -> client::Result<()> {
740    for (keyword, cur, tgt) in [
741        (
742            "WAREHOUSE",
743            current.warehouse.as_deref(),
744            target.warehouse.as_deref(),
745        ),
746        ("ROLE", current.role.as_deref(), target.role.as_deref()),
747        (
748            "DATABASE",
749            current.database.as_deref(),
750            target.database.as_deref(),
751        ),
752        (
753            "SCHEMA",
754            current.schema.as_deref(),
755            target.schema.as_deref(),
756        ),
757    ] {
758        if let Some(name) = tgt {
759            if cur != Some(name) {
760                session
761                    .query(format!("USE {keyword} {name}").as_str())
762                    .await?;
763            }
764        }
765    }
766    Ok(())
767}
768
769/// Normalizes an account identifier for keying (Snowflake is case-insensitive).
770fn normalize_account(account: &str) -> String {
771    account.trim().to_ascii_uppercase()
772}
773
774/// A single-line, length-bounded preview of SQL for the "running" display
775/// (collapses whitespace/newlines; appends `…` when truncated).
776fn sql_preview(sql: &str, max: usize) -> String {
777    let collapsed = sql.split_whitespace().collect::<Vec<_>>().join(" ");
778    if collapsed.chars().count() > max {
779        let head: String = collapsed.chars().take(max).collect();
780        format!("{}…", head.trim_end())
781    } else {
782        collapsed
783    }
784}
785
786/// Validates every present context flag as a safe Snowflake identifier before it
787/// is interpolated into a `USE …` statement.
788fn validate_context(req: &QueryRequest) -> Result<()> {
789    for (name, value) in [
790        ("warehouse", req.warehouse.as_deref()),
791        ("role", req.role.as_deref()),
792        ("database", req.database.as_deref()),
793        ("schema", req.schema.as_deref()),
794    ] {
795        if let Some(value) = value {
796            validate_identifier(name, value)?;
797        }
798    }
799    Ok(())
800}
801
802/// Rejects context values that are not bare Snowflake identifiers (letters,
803/// digits, `_`, `$`, `.`), so a `--warehouse` flag cannot smuggle extra SQL into
804/// the `USE …` statement.
805fn validate_identifier(field: &str, value: &str) -> Result<()> {
806    if value.is_empty() {
807        bail!("--{field} must not be empty");
808    }
809    if !value
810        .chars()
811        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '$' | '.'))
812    {
813        bail!(
814            "--{field} '{value}' is not a valid Snowflake identifier \
815             (allowed: letters, digits, '_', '$', '.')"
816        );
817    }
818    Ok(())
819}
820
821#[cfg(test)]
822#[allow(clippy::unwrap_used, clippy::expect_used)]
823mod tests {
824    use super::*;
825    use crate::test_support::env::MapEnv;
826
827    #[test]
828    fn default_config_has_a_nonzero_pool_size() {
829        assert!(SnowflakeEngineConfig::default().pool_size >= 1);
830    }
831
832    #[test]
833    fn heartbeat_interval_from_parses_seconds_zero_and_garbage() {
834        assert_eq!(heartbeat_interval_from(None), DEFAULT_HEARTBEAT_INTERVAL);
835        assert_eq!(
836            heartbeat_interval_from(Some("300".to_string())),
837            Duration::from_secs(300)
838        );
839        // `0` is meaningful: it disables the heartbeat.
840        assert_eq!(
841            heartbeat_interval_from(Some(" 0 ".to_string())),
842            Duration::ZERO
843        );
844        assert_eq!(
845            heartbeat_interval_from(Some("garbage".to_string())),
846            DEFAULT_HEARTBEAT_INTERVAL
847        );
848    }
849
850    #[test]
851    fn host_override_from_trims_and_treats_blank_as_unset() {
852        assert_eq!(host_override_from(None), None);
853        assert_eq!(host_override_from(Some("   ".to_string())), None);
854        assert_eq!(
855            host_override_from(Some(
856                "  acct.privatelink.snowflakecomputing.com\n".to_string()
857            )),
858            Some("acct.privatelink.snowflakecomputing.com".to_string())
859        );
860    }
861
862    #[test]
863    fn resolve_auth_method_defaults_to_external_browser() {
864        // Unset, blank, and the explicit name all keep interactive SSO.
865        for selector in [
866            None,
867            Some(""),
868            Some("  "),
869            Some("externalbrowser"),
870            Some("EXTERNALBROWSER"),
871        ] {
872            assert!(matches!(
873                resolve_auth_method(selector, None, None, None).unwrap(),
874                AuthMethod::ExternalBrowser(_)
875            ));
876        }
877    }
878
879    #[test]
880    fn resolve_auth_method_reads_pat_and_trims_it() {
881        let auth = resolve_auth_method(
882            Some("programmatic_access_token"),
883            Some("  tok-123\n".to_string()),
884            None,
885            None,
886        )
887        .unwrap();
888        let AuthMethod::ProgrammaticAccessToken { token } = auth else {
889            panic!("expected a PAT auth method");
890        };
891        assert_eq!(token.expose_secret(), "tok-123");
892    }
893
894    #[test]
895    fn resolve_auth_method_accepts_the_pat_alias() {
896        assert!(matches!(
897            resolve_auth_method(Some("pat"), Some("t".to_string()), None, None).unwrap(),
898            AuthMethod::ProgrammaticAccessToken { .. }
899        ));
900    }
901
902    #[test]
903    fn resolve_auth_method_errors_when_pat_is_missing_or_blank() {
904        assert!(resolve_auth_method(Some("programmatic_access_token"), None, None, None).is_err());
905        assert!(resolve_auth_method(Some("pat"), Some("   ".to_string()), None, None).is_err());
906    }
907
908    #[test]
909    fn resolve_auth_method_reads_key_pair_pem() {
910        let auth = resolve_auth_method(
911            Some("snowflake_jwt"),
912            None,
913            Some("-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----".to_string()),
914            None,
915        )
916        .unwrap();
917        let AuthMethod::KeyPairJwt(cfg) = auth else {
918            panic!("expected a key-pair auth method");
919        };
920        assert!(cfg
921            .private_key_pem
922            .expose_secret()
923            .contains("BEGIN PRIVATE KEY"));
924    }
925
926    #[test]
927    fn resolve_auth_method_accepts_key_pair_aliases() {
928        for selector in ["snowflake_jwt", "keypair", "key_pair", "jwt"] {
929            assert!(matches!(
930                resolve_auth_method(Some(selector), None, Some("pem".to_string()), None).unwrap(),
931                AuthMethod::KeyPairJwt(_)
932            ));
933        }
934    }
935
936    #[test]
937    fn resolve_auth_method_errors_when_key_is_missing() {
938        assert!(resolve_auth_method(Some("snowflake_jwt"), None, None, None).is_err());
939        assert!(
940            resolve_auth_method(Some("snowflake_jwt"), None, Some("  ".to_string()), None).is_err()
941        );
942    }
943
944    #[test]
945    fn resolve_auth_method_rejects_an_encrypted_key_passphrase() {
946        let err = resolve_auth_method(
947            Some("snowflake_jwt"),
948            None,
949            Some("pem".to_string()),
950            Some("hunter2".to_string()),
951        )
952        .unwrap_err();
953        assert!(err.to_string().contains(ENV_PRIVATE_KEY_PASSPHRASE));
954    }
955
956    #[test]
957    fn resolve_auth_method_rejects_an_unknown_selector() {
958        let err = resolve_auth_method(Some("carrier-pigeon"), None, None, None).unwrap_err();
959        assert!(err.to_string().contains("carrier-pigeon"));
960    }
961
962    #[tokio::test]
963    async fn start_heartbeat_is_a_noop_when_disabled() {
964        let engine = SnowflakeEngine::new(SnowflakeEngineConfig {
965            heartbeat_interval: Duration::ZERO,
966            ..SnowflakeEngineConfig::default()
967        });
968        engine.start_heartbeat();
969        assert!(engine.lock_heartbeat().is_none());
970    }
971
972    #[test]
973    fn start_heartbeat_is_a_noop_outside_a_runtime() {
974        let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
975        engine.start_heartbeat();
976        assert!(engine.lock_heartbeat().is_none());
977    }
978
979    #[tokio::test]
980    async fn start_heartbeat_is_idempotent_and_shutdown_stops_it() {
981        let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
982        engine.start_heartbeat();
983        // Cancelling the running task's token lets a replacement be detected: a
984        // second start must keep this task, not spawn (and orphan) a fresh one.
985        engine.lock_heartbeat().as_ref().unwrap().token.cancel();
986        engine.start_heartbeat();
987        assert!(
988            engine
989                .lock_heartbeat()
990                .as_ref()
991                .unwrap()
992                .token
993                .is_cancelled(),
994            "second start must not replace the running task"
995        );
996        engine.shutdown().await;
997        assert!(engine.lock_heartbeat().is_none());
998    }
999
1000    #[test]
1001    fn normalize_account_uppercases_and_trims() {
1002        assert_eq!(normalize_account("  my-org.acct  "), "MY-ORG.ACCT");
1003    }
1004
1005    #[test]
1006    fn validate_identifier_accepts_bare_identifiers() {
1007        for value in ["WH", "my_wh", "DB.SCHEMA", "wh$1"] {
1008            assert!(validate_identifier("warehouse", value).is_ok(), "{value}");
1009        }
1010    }
1011
1012    #[test]
1013    fn validate_identifier_rejects_injection_and_empty() {
1014        for value in ["", "wh; DROP TABLE t", "wh OR 1=1", "wh'", "a b"] {
1015            assert!(validate_identifier("warehouse", value).is_err(), "{value}");
1016        }
1017    }
1018
1019    #[test]
1020    fn fill_defaults_from_fills_unset_fields() {
1021        let env = MapEnv::new()
1022            .with(ENV_ACCOUNT, "ACCT")
1023            .with(ENV_USER, "me")
1024            .with(ENV_WAREHOUSE, "WH")
1025            .with(ENV_ROLE, "R")
1026            .with(ENV_DATABASE, "DB")
1027            .with(ENV_SCHEMA, "S");
1028        let mut req = QueryRequest {
1029            sql: "SELECT 1".to_string(),
1030            ..QueryRequest::default()
1031        };
1032        req.fill_defaults_from(&env);
1033        assert_eq!(req.account.as_deref(), Some("ACCT"));
1034        assert_eq!(req.user.as_deref(), Some("me"));
1035        assert_eq!(req.warehouse.as_deref(), Some("WH"));
1036        assert_eq!(req.role.as_deref(), Some("R"));
1037        assert_eq!(req.database.as_deref(), Some("DB"));
1038        assert_eq!(req.schema.as_deref(), Some("S"));
1039    }
1040
1041    #[test]
1042    fn fill_defaults_from_keeps_explicit_values() {
1043        let env = MapEnv::new()
1044            .with(ENV_ACCOUNT, "ENV_ACCT")
1045            .with(ENV_USER, "env_user");
1046        let mut req = QueryRequest {
1047            account: Some("FLAG_ACCT".to_string()),
1048            sql: "SELECT 1".to_string(),
1049            ..QueryRequest::default()
1050        };
1051        req.fill_defaults_from(&env);
1052        assert_eq!(req.account.as_deref(), Some("FLAG_ACCT"));
1053        assert_eq!(req.user.as_deref(), Some("env_user"));
1054    }
1055
1056    #[test]
1057    fn fill_defaults_from_leaves_unresolved_fields_none() {
1058        let mut req = QueryRequest {
1059            sql: "SELECT 1".to_string(),
1060            ..QueryRequest::default()
1061        };
1062        req.fill_defaults_from(&MapEnv::new());
1063        assert!(req.account.is_none());
1064        assert!(req.user.is_none());
1065        assert!(req.warehouse.is_none());
1066    }
1067
1068    #[test]
1069    fn query_request_serializes_without_none_fields() {
1070        let req = QueryRequest {
1071            account: Some("ACCT".to_string()),
1072            sql: "SELECT 1".to_string(),
1073            ..QueryRequest::default()
1074        };
1075        let value = serde_json::to_value(&req).unwrap();
1076        assert_eq!(
1077            value,
1078            serde_json::json!({ "account": "ACCT", "sql": "SELECT 1" })
1079        );
1080    }
1081
1082    #[test]
1083    fn validate_context_checks_each_present_flag() {
1084        let mut req = QueryRequest {
1085            sql: "SELECT 1".to_string(),
1086            ..QueryRequest::default()
1087        };
1088        assert!(validate_context(&req).is_ok());
1089        req.role = Some("good_role".to_string());
1090        assert!(validate_context(&req).is_ok());
1091        req.database = Some("bad; drop".to_string());
1092        assert!(validate_context(&req).is_err());
1093    }
1094
1095    #[tokio::test]
1096    async fn query_without_account_errors_without_network() {
1097        let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
1098        let err = engine
1099            .query(QueryRequest {
1100                sql: "SELECT 1".to_string(),
1101                ..QueryRequest::default()
1102            })
1103            .await
1104            .unwrap_err();
1105        assert!(err.to_string().contains("account"));
1106    }
1107
1108    #[tokio::test]
1109    async fn query_without_user_errors_without_network() {
1110        let engine = SnowflakeEngine::new(SnowflakeEngineConfig {
1111            default_account: Some("ACCT".to_string()),
1112            ..SnowflakeEngineConfig::default()
1113        });
1114        let err = engine
1115            .query(QueryRequest {
1116                sql: "SELECT 1".to_string(),
1117                ..QueryRequest::default()
1118            })
1119            .await
1120            .unwrap_err();
1121        assert!(err.to_string().contains("user"));
1122    }
1123
1124    #[test]
1125    fn disconnect_and_sessions_on_empty_engine() {
1126        let engine = SnowflakeEngine::new(SnowflakeEngineConfig::default());
1127        assert_eq!(engine.pool_count(), 0);
1128        assert!(engine.sessions().is_empty());
1129        assert!(!engine.disconnect("ACCT", "user"));
1130        assert!(!engine.disconnect_by_id(1));
1131        assert_eq!(engine.disconnect_all(), 0);
1132    }
1133
1134    #[test]
1135    fn sql_preview_collapses_whitespace_and_truncates() {
1136        // Whitespace/newlines collapse to single spaces; short SQL is unchanged.
1137        assert_eq!(sql_preview("SELECT   1\n  FROM t", 60), "SELECT 1 FROM t");
1138        // Over-length SQL is truncated with an ellipsis.
1139        let long = format!("SELECT {}", "a".repeat(100));
1140        let preview = sql_preview(&long, 20);
1141        assert!(preview.ends_with('…'));
1142        assert!(preview.chars().count() <= 21, "{preview}");
1143    }
1144
1145    #[test]
1146    fn overrides_extracts_only_the_set_dimensions() {
1147        let req = QueryRequest {
1148            warehouse: Some("WH".to_string()),
1149            schema: Some("S".to_string()),
1150            sql: "SELECT 1".to_string(),
1151            ..QueryRequest::default()
1152        };
1153        let overrides = req.overrides();
1154        assert_eq!(overrides.warehouse.as_deref(), Some("WH"));
1155        assert_eq!(overrides.schema.as_deref(), Some("S"));
1156        assert!(overrides.role.is_none());
1157        assert!(overrides.database.is_none());
1158    }
1159
1160    mod orchestration {
1161        use super::*;
1162        use serde_json::json;
1163        use wiremock::matchers::{method, path};
1164        use wiremock::{Mock, MockServer, ResponseTemplate};
1165
1166        /// Mounts a `query-request` handler that returns `data` for every POST.
1167        async fn mount_query(server: &MockServer, data: serde_json::Value) {
1168            Mock::given(method("POST"))
1169                .and(path("/queries/v1/query-request"))
1170                .respond_with(
1171                    ResponseTemplate::new(200)
1172                        .set_body_json(json!({ "success": true, "data": data })),
1173                )
1174                .mount(server)
1175                .await;
1176        }
1177
1178        #[tokio::test]
1179        async fn capture_base_context_reads_current_context() {
1180            let server = MockServer::start().await;
1181            mount_query(
1182                &server,
1183                json!({
1184                    "rowtype": [
1185                        { "name": "CURRENT_WAREHOUSE()", "type": "text" },
1186                        { "name": "CURRENT_ROLE()", "type": "text" },
1187                        { "name": "CURRENT_DATABASE()", "type": "text" },
1188                        { "name": "CURRENT_SCHEMA()", "type": "text" },
1189                    ],
1190                    "rowset": [["WH", "R", "DB", "S"]],
1191                }),
1192            )
1193            .await;
1194            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1195            let base = capture_base_context(&session).await.unwrap();
1196            assert_eq!(base.warehouse.as_deref(), Some("WH"));
1197            assert_eq!(base.role.as_deref(), Some("R"));
1198            assert_eq!(base.database.as_deref(), Some("DB"));
1199            assert_eq!(base.schema.as_deref(), Some("S"));
1200        }
1201
1202        #[tokio::test]
1203        async fn capture_base_context_defaults_when_no_rows() {
1204            let server = MockServer::start().await;
1205            mount_query(
1206                &server,
1207                json!({ "rowtype": [{ "name": "X", "type": "text" }], "rowset": [] }),
1208            )
1209            .await;
1210            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1211            assert_eq!(
1212                capture_base_context(&session).await.unwrap(),
1213                QueryContext::default()
1214            );
1215        }
1216
1217        #[tokio::test]
1218        async fn apply_context_issues_use_only_for_differing_dimensions() {
1219            let server = MockServer::start().await;
1220            mount_query(&server, json!({ "rowtype": [], "rowset": [] })).await;
1221            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1222
1223            let current = QueryContext {
1224                warehouse: Some("WH".to_string()),
1225                ..QueryContext::default()
1226            };
1227            let target = QueryContext {
1228                warehouse: Some("WH".to_string()), // same → no USE
1229                role: Some("R2".to_string()),      // differs → one USE
1230                ..QueryContext::default()
1231            };
1232            apply_context(&session, &current, &target).await.unwrap();
1233
1234            let reqs = server.received_requests().await.unwrap();
1235            assert_eq!(reqs.len(), 1, "only the differing dimension issues a USE");
1236            assert!(String::from_utf8_lossy(&reqs[0].body).contains("USE ROLE R2"));
1237        }
1238
1239        #[tokio::test]
1240        async fn run_with_renew_runs_without_renew_when_not_expired() {
1241            let server = MockServer::start().await;
1242            mount_query(
1243                &server,
1244                json!({ "rowtype": [{ "name": "N", "type": "text" }], "rowset": [["x"]] }),
1245            )
1246            .await;
1247            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1248            let ctx = QueryContext::default();
1249            let rows = run_with_renew(&session, &ctx, &ctx, "SELECT 1")
1250                .await
1251                .unwrap();
1252            assert_eq!(rows.len(), 1);
1253        }
1254
1255        #[tokio::test]
1256        async fn run_with_renew_renews_and_retries_once_on_expiry() {
1257            let server = MockServer::start().await;
1258            // First query attempt: session expired (then this rule is exhausted).
1259            Mock::given(method("POST"))
1260                .and(path("/queries/v1/query-request"))
1261                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1262                    "success": false, "code": "390112", "message": "expired", "data": {}
1263                })))
1264                .up_to_n_times(1)
1265                .with_priority(1)
1266                .mount(&server)
1267                .await;
1268            // Renew succeeds.
1269            Mock::given(method("POST"))
1270                .and(path("/session/token-request"))
1271                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1272                    "success": true,
1273                    "data": { "sessionToken": "fresh", "validityInSecondsST": 3600 }
1274                })))
1275                .mount(&server)
1276                .await;
1277            // The retried query succeeds.
1278            Mock::given(method("POST"))
1279                .and(path("/queries/v1/query-request"))
1280                .respond_with(ResponseTemplate::new(200).set_body_json(json!({
1281                    "success": true,
1282                    "data": { "rowtype": [{ "name": "N", "type": "text" }], "rowset": [["x"]] }
1283                })))
1284                .with_priority(2)
1285                .mount(&server)
1286                .await;
1287
1288            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1289            let ctx = QueryContext::default();
1290            let rows = run_with_renew(&session, &ctx, &ctx, "SELECT 1")
1291                .await
1292                .unwrap();
1293            assert_eq!(rows.len(), 1, "renewed and retried transparently");
1294        }
1295    }
1296
1297    mod keep_alive {
1298        use super::*;
1299        use serde_json::json;
1300        use wiremock::matchers::{method, path};
1301        use wiremock::{Mock, MockServer, ResponseTemplate};
1302
1303        /// A short interval so `session_expiring_within(interval + margin)` is
1304        /// false for the test session's fresh 3600s token.
1305        const INTERVAL: Duration = Duration::from_secs(60);
1306
1307        /// Mounts a `session/heartbeat` handler answering with `body`.
1308        async fn mount_heartbeat(server: &MockServer, body: serde_json::Value) {
1309            Mock::given(method("POST"))
1310                .and(path("/session/heartbeat"))
1311                .respond_with(ResponseTemplate::new(200).set_body_json(body))
1312                .mount(server)
1313                .await;
1314        }
1315
1316        /// Mounts a `session/token-request` (renew) handler answering with `body`.
1317        async fn mount_renew(server: &MockServer, body: serde_json::Value) {
1318            Mock::given(method("POST"))
1319                .and(path("/session/token-request"))
1320                .respond_with(ResponseTemplate::new(200).set_body_json(body))
1321                .mount(server)
1322                .await;
1323        }
1324
1325        fn ok_body() -> serde_json::Value {
1326            json!({ "success": true, "data": {} })
1327        }
1328
1329        fn renew_ok_body() -> serde_json::Value {
1330            json!({
1331                "success": true,
1332                "data": { "sessionToken": "fresh", "validityInSecondsST": 3600 }
1333            })
1334        }
1335
1336        fn expired_body() -> serde_json::Value {
1337            json!({ "success": false, "code": "390112", "message": "expired", "data": {} })
1338        }
1339
1340        #[tokio::test]
1341        async fn keep_session_alive_heartbeats_a_healthy_session() {
1342            let server = MockServer::start().await;
1343            mount_heartbeat(&server, ok_body()).await;
1344            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1345            assert!(keep_session_alive(&session, INTERVAL).await);
1346            let reqs = server.received_requests().await.unwrap();
1347            assert_eq!(reqs.len(), 1, "one heartbeat, no renew");
1348        }
1349
1350        #[tokio::test]
1351        async fn keep_session_alive_renews_when_the_heartbeat_reports_expiry() {
1352            let server = MockServer::start().await;
1353            mount_heartbeat(&server, expired_body()).await;
1354            mount_renew(&server, renew_ok_body()).await;
1355            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1356            assert!(keep_session_alive(&session, INTERVAL).await);
1357            let reqs = server.received_requests().await.unwrap();
1358            assert!(
1359                reqs.iter()
1360                    .any(|r| r.url.path() == "/session/token-request"),
1361                "renewed after the expired heartbeat"
1362            );
1363        }
1364
1365        #[tokio::test]
1366        async fn keep_session_alive_discards_when_the_master_token_is_dead() {
1367            let server = MockServer::start().await;
1368            mount_heartbeat(&server, expired_body()).await;
1369            mount_renew(&server, expired_body()).await;
1370            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1371            assert!(!keep_session_alive(&session, INTERVAL).await);
1372        }
1373
1374        #[tokio::test]
1375        async fn keep_session_alive_keeps_the_session_on_transient_errors() {
1376            let server = MockServer::start().await;
1377            mount_heartbeat(
1378                &server,
1379                json!({ "success": false, "code": "390001", "message": "hiccup", "data": {} }),
1380            )
1381            .await;
1382            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1383            assert!(keep_session_alive(&session, INTERVAL).await);
1384        }
1385
1386        #[tokio::test]
1387        async fn keep_session_alive_proactively_renews_a_token_expiring_before_the_next_tick() {
1388            let server = MockServer::start().await;
1389            mount_heartbeat(&server, ok_body()).await;
1390            mount_renew(&server, renew_ok_body()).await;
1391            let session = client::test_session(&server.uri(), Duration::from_secs(5));
1392            // interval + margin exceeds the fresh 3600s validity → renew first.
1393            assert!(keep_session_alive(&session, Duration::from_secs(7200)).await);
1394            let reqs = server.received_requests().await.unwrap();
1395            assert_eq!(
1396                reqs[0].url.path(),
1397                "/session/token-request",
1398                "renew ran first"
1399            );
1400            assert_eq!(reqs[1].url.path(), "/session/heartbeat");
1401        }
1402
1403        #[tokio::test]
1404        async fn engine_heartbeat_loop_beats_idle_sessions_and_stops_on_shutdown() {
1405            let server = MockServer::start().await;
1406            mount_heartbeat(&server, ok_body()).await;
1407
1408            let engine = SnowflakeEngine::new(SnowflakeEngineConfig {
1409                heartbeat_interval: Duration::from_millis(50),
1410                ..SnowflakeEngineConfig::default()
1411            });
1412            // Park one idle session in a pool, bypassing the (live-only) SSO.
1413            let pool = engine
1414                .registry
1415                .get_or_create(&SessionKey::new("ACCT", "user"), 2);
1416            let uri = server.uri();
1417            let checkout = pool
1418                .checkout(|| async {
1419                    Ok::<_, std::convert::Infallible>((
1420                        client::test_session(&uri, Duration::from_secs(5)),
1421                        QueryContext::default(),
1422                    ))
1423                })
1424                .await
1425                .unwrap();
1426            pool.checkin(checkout, QueryContext::default());
1427
1428            engine.start_heartbeat();
1429            let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
1430            while server.received_requests().await.unwrap().is_empty() {
1431                assert!(
1432                    tokio::time::Instant::now() < deadline,
1433                    "no heartbeat within the deadline"
1434                );
1435                tokio::time::sleep(Duration::from_millis(10)).await;
1436            }
1437            // The borrowed session was restored, not consumed.
1438            assert_eq!(pool.live(), 1);
1439
1440            engine.shutdown().await;
1441            let after = server.received_requests().await.unwrap().len();
1442            tokio::time::sleep(Duration::from_millis(150)).await;
1443            assert_eq!(
1444                server.received_requests().await.unwrap().len(),
1445                after,
1446                "no heartbeats after shutdown"
1447            );
1448            assert_eq!(engine.pool_count(), 0, "pools drained on shutdown");
1449        }
1450    }
1451}