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