1pub 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
36const ENV_ACCOUNT: &str = "SNOWFLAKE_ACCOUNT";
38const ENV_USER: &str = "SNOWFLAKE_USER";
40const ENV_WAREHOUSE: &str = "SNOWFLAKE_WAREHOUSE";
42const ENV_ROLE: &str = "SNOWFLAKE_ROLE";
44const ENV_DATABASE: &str = "SNOWFLAKE_DATABASE";
46const ENV_SCHEMA: &str = "SNOWFLAKE_SCHEMA";
48const ENV_POOL_SIZE: &str = "SNOWFLAKE_POOL_SIZE";
50const ENV_HTTP_TIMEOUT: &str = "SNOWFLAKE_HTTP_TIMEOUT";
52const ENV_AUTH_TIMEOUT: &str = "SNOWFLAKE_AUTH_TIMEOUT";
54const ENV_QUERY_TIMEOUT: &str = "SNOWFLAKE_QUERY_TIMEOUT";
56const ENV_HEARTBEAT_INTERVAL: &str = "SNOWFLAKE_HEARTBEAT_INTERVAL";
59
60const DEFAULT_POOL_SIZE: usize = 4;
63const DEFAULT_AUTH_TIMEOUT: Duration = Duration::from_secs(150);
66const SQL_PREVIEW_MAX: usize = 60;
68const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(900);
73const HEARTBEAT_RENEW_MARGIN_SECS: i64 = 60;
76
77#[derive(Clone, Debug)]
83pub struct SnowflakeEngineConfig {
84 pub default_account: Option<String>,
86 pub default_user: Option<String>,
88 pub default_warehouse: Option<String>,
90 pub default_role: Option<String>,
92 pub default_database: Option<String>,
94 pub default_schema: Option<String>,
96 pub pool_size: usize,
98 pub http_timeout: Duration,
100 pub auth_timeout: Duration,
103 pub query_timeout: Duration,
105 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 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
168fn 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#[derive(Clone, Debug, Default, Deserialize, Serialize)]
185#[serde(default)]
186pub struct QueryRequest {
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub account: Option<String>,
190 #[serde(skip_serializing_if = "Option::is_none")]
192 pub user: Option<String>,
193 #[serde(skip_serializing_if = "Option::is_none")]
195 pub warehouse: Option<String>,
196 #[serde(skip_serializing_if = "Option::is_none")]
198 pub role: Option<String>,
199 #[serde(skip_serializing_if = "Option::is_none")]
201 pub database: Option<String>,
202 #[serde(skip_serializing_if = "Option::is_none")]
204 pub schema: Option<String>,
205 pub sql: String,
207}
208
209impl QueryRequest {
210 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 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
243struct HeartbeatTask {
245 token: CancellationToken,
246 handle: JoinHandle<()>,
247}
248
249pub struct SnowflakeEngine {
256 config: SnowflakeEngineConfig,
257 registry: PoolRegistry,
258 heartbeat: StdMutex<Option<HeartbeatTask>>,
259}
260
261impl SnowflakeEngine {
262 #[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 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(®istry, 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 #[must_use]
319 pub fn sessions(&self) -> Vec<SessionInfo> {
320 self.registry.snapshot()
321 }
322
323 #[must_use]
325 pub fn pool_count(&self) -> usize {
326 self.registry.len()
327 }
328
329 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 pub fn disconnect_by_id(&self, id: u64) -> bool {
337 self.registry.remove_by_id(id).is_some()
338 }
339
340 pub fn disconnect_all(&self) -> usize {
342 self.registry.take_all().len()
343 }
344
345 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 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 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 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 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 pool.start_query(checkout.id(), sql_preview(&req.sql, SQL_PREVIEW_MAX));
434
435 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 tracing::warn!("Snowflake query failed: {e}");
454 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 let task = self.lock_heartbeat().take();
469 if let Some(task) = task {
470 task.token.cancel();
471 }
472 }
473}
474
475async 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
502async 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 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
548async 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
571async 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
588async 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 apply_and_query(session, &QueryContext::default(), target, sql).await
601 }
602 other => other,
603 }
604}
605
606async 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
617async 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
654fn normalize_account(account: &str) -> String {
656 account.trim().to_ascii_uppercase()
657}
658
659fn 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
671fn 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
687fn 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 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 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 assert_eq!(sql_preview("SELECT 1\n FROM t", 60), "SELECT 1 FROM t");
911 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 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()), role: Some("R2".to_string()), ..QueryContext::default()
1004 };
1005 apply_context(&session, ¤t, &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 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 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 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 const INTERVAL: Duration = Duration::from_secs(60);
1079
1080 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 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 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 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 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}