1pub 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
43const ENV_ACCOUNT: &str = "SNOWFLAKE_ACCOUNT";
45const ENV_USER: &str = "SNOWFLAKE_USER";
47const ENV_HOST: &str = "SNOWFLAKE_HOST";
51const ENV_WAREHOUSE: &str = "SNOWFLAKE_WAREHOUSE";
53const ENV_ROLE: &str = "SNOWFLAKE_ROLE";
55const ENV_DATABASE: &str = "SNOWFLAKE_DATABASE";
57const ENV_SCHEMA: &str = "SNOWFLAKE_SCHEMA";
59const ENV_POOL_SIZE: &str = "SNOWFLAKE_POOL_SIZE";
61const ENV_HTTP_TIMEOUT: &str = "SNOWFLAKE_HTTP_TIMEOUT";
63const ENV_AUTH_TIMEOUT: &str = "SNOWFLAKE_AUTH_TIMEOUT";
65const ENV_QUERY_TIMEOUT: &str = "SNOWFLAKE_QUERY_TIMEOUT";
67const ENV_HEARTBEAT_INTERVAL: &str = "SNOWFLAKE_HEARTBEAT_INTERVAL";
70const ENV_AUTHENTICATOR: &str = "SNOWFLAKE_AUTHENTICATOR";
73const ENV_TOKEN: &str = "SNOWFLAKE_TOKEN";
75const ENV_PRIVATE_KEY_PATH: &str = "SNOWFLAKE_PRIVATE_KEY_PATH";
81const ENV_PRIVATE_KEY: &str = "SNOWFLAKE_PRIVATE_KEY";
84const ENV_PRIVATE_KEY_PASSPHRASE: &str = "SNOWFLAKE_PRIVATE_KEY_PASSPHRASE";
87const ENV_BROWSER_COMMAND: &str = "SNOWFLAKE_BROWSER_COMMAND";
96
97const DEFAULT_POOL_SIZE: usize = 4;
100const DEFAULT_AUTH_TIMEOUT: Duration = Duration::from_secs(150);
103const SQL_PREVIEW_MAX: usize = 60;
105const DEFAULT_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(900);
110const HEARTBEAT_RENEW_MARGIN_SECS: i64 = 60;
113
114#[derive(Clone, Debug)]
120pub struct SnowflakeEngineConfig {
121 pub default_account: Option<String>,
123 pub default_user: Option<String>,
125 pub default_host: Option<String>,
129 pub default_warehouse: Option<String>,
131 pub default_role: Option<String>,
133 pub default_database: Option<String>,
135 pub default_schema: Option<String>,
137 pub auth: AuthMethod,
141 pub pool_size: usize,
143 pub http_timeout: Duration,
145 pub auth_timeout: Duration,
148 pub query_timeout: Duration,
150 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 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
233fn host_override_from(raw: Option<String>) -> Option<String> {
237 raw.map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
238}
239
240fn 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
249fn 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#[derive(Clone, Debug, Default, Deserialize, Serialize)]
339#[serde(default)]
340pub struct QueryRequest {
341 #[serde(skip_serializing_if = "Option::is_none")]
343 pub account: Option<String>,
344 #[serde(skip_serializing_if = "Option::is_none")]
346 pub user: Option<String>,
347 #[serde(skip_serializing_if = "Option::is_none")]
349 pub warehouse: Option<String>,
350 #[serde(skip_serializing_if = "Option::is_none")]
352 pub role: Option<String>,
353 #[serde(skip_serializing_if = "Option::is_none")]
355 pub database: Option<String>,
356 #[serde(skip_serializing_if = "Option::is_none")]
358 pub schema: Option<String>,
359 pub sql: String,
361}
362
363impl QueryRequest {
364 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 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
397struct HeartbeatTask {
399 token: CancellationToken,
400 handle: JoinHandle<()>,
401}
402
403pub struct SnowflakeEngine {
410 config: SnowflakeEngineConfig,
411 registry: PoolRegistry,
412 heartbeat: StdMutex<Option<HeartbeatTask>>,
413}
414
415impl SnowflakeEngine {
416 #[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 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(®istry, 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 #[must_use]
473 pub fn sessions(&self) -> Vec<SessionInfo> {
474 self.registry.snapshot()
475 }
476
477 #[must_use]
479 pub fn pool_count(&self) -> usize {
480 self.registry.len()
481 }
482
483 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 pub fn disconnect_by_id(&self, id: u64) -> bool {
491 self.registry.remove_by_id(id).is_some()
492 }
493
494 pub fn disconnect_all(&self) -> usize {
496 self.registry.take_all().len()
497 }
498
499 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 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 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 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 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 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 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 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 pool.start_query(
626 checkout.id(),
627 sql_preview(&req.sql, SQL_PREVIEW_MAX),
628 Some(checkout.session().abort_handle()),
629 );
630
631 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 tracing::warn!("Snowflake query failed: {e}");
650 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 let task = self.lock_heartbeat().take();
665 if let Some(task) = task {
666 task.token.cancel();
667 }
668 }
669}
670
671async 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
688async 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
715async 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 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
761async 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
786async 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
803async 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 apply_and_query(session, &QueryContext::default(), target, sql).await
816 }
817 other => other,
818 }
819}
820
821async 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
833async 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
870fn normalize_account(account: &str) -> String {
872 account.trim().to_ascii_uppercase()
873}
874
875fn 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
887fn 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
903fn 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 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 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 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 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 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 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 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 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 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 tokio::select! {
1308 _ = checkout.session().query("SELECT LONG") => panic!("query should stay parked"),
1309 counts = async {
1310 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 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 assert_eq!(sql_preview("SELECT 1\n FROM t", 60), "SELECT 1 FROM t");
1470 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 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()), role: Some("R2".to_string()), ..QueryContext::default()
1563 };
1564 apply_context(&session, ¤t, &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 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 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 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 const INTERVAL: Duration = Duration::from_secs(60);
1638
1639 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 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 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 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 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}