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