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