Skip to main content

omni_dev/snowflake/client/
session.rs

1//! A live Snowflake session: query execution and token lifecycle.
2
3use std::collections::HashMap;
4use std::io::Read as _;
5use std::sync::{Arc, Mutex as StdMutex, MutexGuard};
6use std::time::Duration;
7
8use chrono::{DateTime, TimeDelta, Utc};
9use serde_json::{json, Value};
10
11use crate::utils::secret::Secret;
12
13use super::error::{Error, Result};
14use super::row::{Column, Row};
15use super::transport::{request_id, Transport};
16
17/// How often to poll an in-progress (async) query for its result.
18const POLL_INTERVAL: Duration = Duration::from_secs(3);
19
20/// Tokens returned by login, consumed by [`SnowflakeSession::new`].
21pub(crate) struct LoginTokens {
22    /// Session token (authorizes queries; short-lived; redacted in `Debug` output).
23    pub session_token: Secret,
24    /// Master token (authorizes renewal; longer-lived; redacted in `Debug` output).
25    pub master_token: Secret,
26    /// Session-token validity in seconds.
27    pub session_validity_secs: i64,
28    /// Master-token validity in seconds.
29    pub master_validity_secs: i64,
30}
31
32/// The mutable token state, refreshed by `renew`/`heartbeat`.
33#[derive(Clone, Debug)]
34struct Tokens {
35    session_token: Secret,
36    master_token: Secret,
37    session_expires_at: DateTime<Utc>,
38    master_expires_at: DateTime<Utc>,
39}
40
41/// A live, authenticated Snowflake session.
42///
43/// Holds the session and master tokens behind a mutex so a query (reading the
44/// session token) and a heartbeat/renew can interleave. `query` runs SQL;
45/// `renew` swaps in a fresh session token via the master token; `heartbeat`
46/// keeps the master token alive so renewal can continue indefinitely.
47pub struct SnowflakeSession {
48    transport: Arc<Transport>,
49    tokens: StdMutex<Tokens>,
50    /// Overall deadline for one query (submit + async polling).
51    query_timeout: Duration,
52}
53
54impl SnowflakeSession {
55    /// Builds a session from a transport and freshly issued tokens.
56    pub(crate) fn new(
57        transport: Arc<Transport>,
58        tokens: LoginTokens,
59        query_timeout: Duration,
60    ) -> Self {
61        let now = Utc::now();
62        Self {
63            transport,
64            tokens: StdMutex::new(Tokens {
65                session_token: tokens.session_token,
66                master_token: tokens.master_token,
67                session_expires_at: now + TimeDelta::seconds(tokens.session_validity_secs),
68                master_expires_at: now + TimeDelta::seconds(tokens.master_validity_secs),
69            }),
70            query_timeout,
71        }
72    }
73
74    /// Whether the session token expires within `within` from now.
75    #[must_use]
76    pub fn session_expiring_within(&self, within: TimeDelta) -> bool {
77        Utc::now() + within >= self.lock().session_expires_at
78    }
79
80    /// When the master token currently expires (renewal must happen before this,
81    /// kept alive by [`heartbeat`](Self::heartbeat)).
82    #[must_use]
83    pub fn master_expires_at(&self) -> DateTime<Utc> {
84        self.lock().master_expires_at
85    }
86
87    /// Runs SQL and returns the result rows.
88    ///
89    /// # Errors
90    ///
91    /// [`Error::SessionExpired`] when the session token is no longer valid (renew
92    /// or re-authenticate), or a transport/server/protocol error.
93    pub async fn query(&self, sql: &str) -> Result<Vec<Row>> {
94        let token = self.lock().session_token.clone();
95        let rid = request_id();
96        let body = json!({ "sqlText": sql });
97        // `post_statement` submits the query and polls the async result URL until
98        // it completes, so a query slower than the server's synchronous window
99        // (anything heavy) is not killed by a per-request timeout.
100        let data = self
101            .transport
102            .post_statement(
103                &[("requestId", rid.as_str())],
104                &body,
105                token.expose_secret(),
106                self.query_timeout,
107                POLL_INTERVAL,
108            )
109            .await?;
110
111        let (columns, index, mut rows) = parse_result(&data)?;
112        // Large results stream the tail as external blob chunks; download and
113        // append each (gzip-compressed JSON arrays).
114        if let Some(chunks) = data.get("chunks").and_then(Value::as_array) {
115            let headers = parse_chunk_headers(&data);
116            for chunk in chunks {
117                if let Some(url) = chunk.get("url").and_then(Value::as_str) {
118                    let bytes = self.transport.get_bytes(url, &headers).await?;
119                    rows.extend(decode_chunk_rows(&bytes, &columns, &index)?);
120                }
121            }
122        }
123        Ok(rows)
124    }
125
126    /// Renews the session token using the master token, extending the session.
127    ///
128    /// # Errors
129    ///
130    /// [`Error::SessionExpired`] when the master token has itself expired (a full
131    /// re-authentication is then required), or a transport/server error.
132    pub async fn renew(&self) -> Result<()> {
133        let (old_session, master) = {
134            let tokens = self.lock();
135            (tokens.session_token.clone(), tokens.master_token.clone())
136        };
137        let rid = request_id();
138        let body =
139            json!({ "oldSessionToken": old_session.expose_secret(), "requestType": "RENEW" });
140        let data = self
141            .transport
142            .post(
143                "session/token-request",
144                &[("requestId", rid.as_str())],
145                &body,
146                Some(master.expose_secret()),
147            )
148            .await?;
149
150        let new_session = data
151            .get("sessionToken")
152            .or_else(|| data.get("token"))
153            .and_then(Value::as_str)
154            .ok_or_else(|| Error::Protocol("token-request returned no sessionToken".into()))?
155            .to_string();
156        let st_validity = data
157            .get("validityInSecondsST")
158            .or_else(|| data.get("validityInSeconds"))
159            .and_then(Value::as_i64)
160            .unwrap_or(3600);
161
162        let mut tokens = self.lock();
163        tokens.session_token = new_session.into();
164        tokens.session_expires_at = Utc::now() + TimeDelta::seconds(st_validity);
165        if let Some(master_token) = data.get("masterToken").and_then(Value::as_str) {
166            tokens.master_token = master_token.into();
167        }
168        if let Some(mt_validity) = data.get("validityInSecondsMT").and_then(Value::as_i64) {
169            tokens.master_expires_at = Utc::now() + TimeDelta::seconds(mt_validity);
170        }
171        Ok(())
172    }
173
174    /// Sends a keep-alive heartbeat, extending the master token server-side.
175    ///
176    /// # Errors
177    ///
178    /// A transport/server error, or [`Error::SessionExpired`] if the session
179    /// token used to authorize the heartbeat has already lapsed.
180    pub async fn heartbeat(&self) -> Result<()> {
181        let token = self.lock().session_token.clone();
182        let rid = request_id();
183        self.transport
184            .post(
185                "session/heartbeat",
186                &[("requestId", rid.as_str())],
187                &json!({}),
188                Some(token.expose_secret()),
189            )
190            .await?;
191        // The server resets the master token's validity; the precise value
192        // comes back on the next renew.
193        Ok(())
194    }
195
196    /// Logs the session out (best-effort).
197    ///
198    /// # Errors
199    ///
200    /// A transport/server error.
201    pub async fn close(&self) -> Result<()> {
202        let token = self.lock().session_token.clone();
203        let rid = request_id();
204        self.transport
205            .post(
206                "session",
207                &[("delete", "true"), ("requestId", rid.as_str())],
208                &json!({}),
209                Some(token.expose_secret()),
210            )
211            .await?;
212        Ok(())
213    }
214
215    fn lock(&self) -> MutexGuard<'_, Tokens> {
216        self.tokens
217            .lock()
218            .unwrap_or_else(std::sync::PoisonError::into_inner)
219    }
220}
221
222/// Schema (columns + uppercased name index) and inline rows from a response.
223type ParsedResult = (Arc<Vec<Column>>, Arc<HashMap<String, usize>>, Vec<Row>);
224
225/// Parses a query-request `data` payload into its schema and inline rows.
226/// External result chunks, if any, are downloaded separately by the caller.
227fn parse_result(data: &Value) -> Result<ParsedResult> {
228    if let Some(format) = data.get("queryResultFormat").and_then(Value::as_str) {
229        if !format.eq_ignore_ascii_case("json") {
230            return Err(Error::Unsupported(format!(
231                "result format '{format}' (only JSON is supported)"
232            )));
233        }
234    }
235
236    let rowtype = data
237        .get("rowtype")
238        .and_then(Value::as_array)
239        .ok_or_else(|| Error::Protocol("query response missing rowtype".into()))?;
240    let columns: Arc<Vec<Column>> = Arc::new(rowtype.iter().map(parse_column).collect());
241
242    let mut index = HashMap::new();
243    for (i, col) in columns.iter().enumerate() {
244        index.entry(col.name.to_ascii_uppercase()).or_insert(i);
245    }
246    let index = Arc::new(index);
247
248    let rows = data
249        .get("rowset")
250        .and_then(Value::as_array)
251        .map(|rows| {
252            rows.iter()
253                .map(|row| row_from_cells(row, &columns, &index))
254                .collect()
255        })
256        .unwrap_or_default();
257    Ok((columns, index, rows))
258}
259
260/// Builds a [`Row`] from a JSON array of cells.
261fn row_from_cells(
262    row: &Value,
263    columns: &Arc<Vec<Column>>,
264    index: &Arc<HashMap<String, usize>>,
265) -> Row {
266    let cells = row
267        .as_array()
268        .map(|cells| cells.iter().map(cell_to_string).collect())
269        .unwrap_or_default();
270    Row::new(cells, Arc::clone(columns), Arc::clone(index))
271}
272
273/// Extracts the per-chunk HTTP headers from a query response.
274fn parse_chunk_headers(data: &Value) -> Vec<(String, String)> {
275    data.get("chunkHeaders")
276        .and_then(Value::as_object)
277        .map(|headers| {
278            headers
279                .iter()
280                .filter_map(|(k, v)| v.as_str().map(|v| (k.clone(), v.to_string())))
281                .collect()
282        })
283        .unwrap_or_default()
284}
285
286/// Decodes a downloaded result chunk (gzip-compressed JSON array of rows).
287fn decode_chunk_rows(
288    bytes: &[u8],
289    columns: &Arc<Vec<Column>>,
290    index: &Arc<HashMap<String, usize>>,
291) -> Result<Vec<Row>> {
292    let json = gunzip_if_needed(bytes)?;
293    // Snowflake serves each chunk as bare, comma-separated row arrays
294    // (`[r1],[r2],…`) designed to be concatenated, not a self-contained JSON
295    // array — so wrap with `[` … `]` before parsing.
296    let mut framed = Vec::with_capacity(json.len() + 2);
297    framed.push(b'[');
298    framed.extend_from_slice(&json);
299    framed.push(b']');
300    let rows: Vec<Value> = serde_json::from_slice(&framed)
301        .map_err(|e| Error::Protocol(format!("invalid result chunk JSON: {e}")))?;
302    Ok(rows
303        .iter()
304        .map(|row| row_from_cells(row, columns, index))
305        .collect())
306}
307
308/// Gunzips `bytes` when they carry the gzip magic, else returns them unchanged.
309fn gunzip_if_needed(bytes: &[u8]) -> Result<Vec<u8>> {
310    if bytes.starts_with(&[0x1f, 0x8b]) {
311        let mut decoder = flate2::read::GzDecoder::new(bytes);
312        let mut out = Vec::new();
313        decoder.read_to_end(&mut out).map_err(Error::Io)?;
314        Ok(out)
315    } else {
316        Ok(bytes.to_vec())
317    }
318}
319
320/// Parses one `rowtype` entry into a [`Column`].
321fn parse_column(value: &Value) -> Column {
322    Column {
323        name: value
324            .get("name")
325            .and_then(Value::as_str)
326            .unwrap_or_default()
327            .to_string(),
328        ty: value
329            .get("type")
330            .and_then(Value::as_str)
331            .unwrap_or_default()
332            .to_ascii_lowercase(),
333        nullable: value
334            .get("nullable")
335            .and_then(Value::as_bool)
336            .unwrap_or(true),
337        length: value.get("length").and_then(Value::as_i64),
338        precision: value.get("precision").and_then(Value::as_i64),
339        scale: value.get("scale").and_then(Value::as_i64),
340    }
341}
342
343/// Normalizes a rowset cell to its raw string form (or `None` for null).
344fn cell_to_string(value: &Value) -> Option<String> {
345    match value {
346        Value::Null => None,
347        Value::String(s) => Some(s.clone()),
348        other => Some(other.to_string()),
349    }
350}
351
352#[cfg(test)]
353#[allow(clippy::unwrap_used, clippy::expect_used)]
354mod tests {
355    use super::*;
356    use crate::snowflake::client::row::rows_to_payload;
357    use url::Url;
358    use wiremock::matchers::{method, path};
359    use wiremock::{Mock, MockServer, ResponseTemplate};
360
361    /// A session whose transport points at `server`, with the given session-token
362    /// validity. The master token is long-lived.
363    fn live_session(server: &MockServer, session_validity_secs: i64) -> SnowflakeSession {
364        let base = Url::parse(&server.uri()).unwrap().join("/").unwrap();
365        let transport = Arc::new(Transport::with_base_url(base, Duration::from_secs(5)).unwrap());
366        SnowflakeSession::new(
367            transport,
368            LoginTokens {
369                session_token: "sess".into(),
370                master_token: "mast".into(),
371                session_validity_secs,
372                master_validity_secs: 14_400,
373            },
374            Duration::from_secs(5),
375        )
376    }
377
378    #[test]
379    fn token_accessors_reflect_validities() {
380        // The token accessors never touch the network.
381        let base = Url::parse("https://acct.example/").unwrap();
382        let transport = Arc::new(Transport::with_base_url(base, Duration::from_secs(5)).unwrap());
383        let session = SnowflakeSession::new(
384            transport,
385            LoginTokens {
386                session_token: "s".into(),
387                master_token: "m".into(),
388                session_validity_secs: 3600,
389                master_validity_secs: 14_400,
390            },
391            Duration::from_secs(5),
392        );
393        assert!(session.master_expires_at() > Utc::now());
394        assert!(!session.session_expiring_within(TimeDelta::seconds(60)));
395        assert!(session.session_expiring_within(TimeDelta::seconds(7200)));
396    }
397
398    #[test]
399    fn tokens_debug_redacts_secrets() {
400        let tokens = Tokens {
401            session_token: "sekret-session-token".into(),
402            master_token: "sekret-master-token".into(),
403            session_expires_at: Utc::now(),
404            master_expires_at: Utc::now(),
405        };
406        // Debug must never print the token values (#1131).
407        let debug = format!("{tokens:?}");
408        assert!(
409            !debug.contains("sekret-session-token"),
410            "leaked session token: {debug}"
411        );
412        assert!(
413            !debug.contains("sekret-master-token"),
414            "leaked master token: {debug}"
415        );
416        assert!(debug.contains("session_token: <redacted>"));
417        assert!(debug.contains("master_token: <redacted>"));
418    }
419
420    #[tokio::test]
421    async fn query_returns_inline_rows() {
422        let server = MockServer::start().await;
423        Mock::given(method("POST"))
424            .and(path("/queries/v1/query-request"))
425            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
426                "success": true,
427                "data": {
428                    "queryResultFormat": "json",
429                    "rowtype": [{ "name": "N", "type": "fixed", "precision": 38, "scale": 0 }],
430                    "rowset": [["1"], ["2"]],
431                }
432            })))
433            .mount(&server)
434            .await;
435
436        let rows = live_session(&server, 3600).query("SELECT 1").await.unwrap();
437        assert_eq!(rows.len(), 2);
438    }
439
440    #[tokio::test]
441    async fn query_downloads_and_appends_external_chunks() {
442        let server = MockServer::start().await;
443        let chunk_url = format!("{}/chunk0", server.uri());
444        Mock::given(method("POST"))
445            .and(path("/queries/v1/query-request"))
446            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
447                "success": true,
448                "data": {
449                    "queryResultFormat": "json",
450                    "rowtype": [{ "name": "N", "type": "text" }],
451                    "rowset": [["a"]],
452                    "chunks": [{ "url": chunk_url }],
453                }
454            })))
455            .mount(&server)
456            .await;
457        // A real chunk is bare, comma-separated row arrays (no enclosing brackets).
458        Mock::given(method("GET"))
459            .and(path("/chunk0"))
460            .respond_with(ResponseTemplate::new(200).set_body_bytes(br#"["b"],["c"]"#.to_vec()))
461            .mount(&server)
462            .await;
463
464        let rows = live_session(&server, 3600).query("SELECT 1").await.unwrap();
465        assert_eq!(rows.len(), 3, "1 inline + 2 chunked");
466    }
467
468    #[tokio::test]
469    async fn query_surfaces_session_expired() {
470        let server = MockServer::start().await;
471        Mock::given(method("POST"))
472            .and(path("/queries/v1/query-request"))
473            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
474                "success": false, "code": "390112", "message": "expired", "data": {}
475            })))
476            .mount(&server)
477            .await;
478
479        let err = live_session(&server, 3600)
480            .query("SELECT 1")
481            .await
482            .unwrap_err();
483        assert!(matches!(err, Error::SessionExpired));
484    }
485
486    #[tokio::test]
487    async fn renew_swaps_in_a_fresh_session_token() {
488        let server = MockServer::start().await;
489        Mock::given(method("POST"))
490            .and(path("/session/token-request"))
491            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
492                "success": true,
493                "data": { "sessionToken": "new-sess", "validityInSecondsST": 3600 }
494            })))
495            .mount(&server)
496            .await;
497
498        // A nearly-expired session is no longer about to expire after a renew.
499        let session = live_session(&server, 1);
500        assert!(session.session_expiring_within(TimeDelta::seconds(120)));
501        session.renew().await.unwrap();
502        assert!(!session.session_expiring_within(TimeDelta::seconds(120)));
503    }
504
505    #[tokio::test]
506    async fn renew_errors_when_response_lacks_a_token() {
507        let server = MockServer::start().await;
508        Mock::given(method("POST"))
509            .and(path("/session/token-request"))
510            .respond_with(
511                ResponseTemplate::new(200).set_body_json(json!({ "success": true, "data": {} })),
512            )
513            .mount(&server)
514            .await;
515
516        let err = live_session(&server, 3600).renew().await.unwrap_err();
517        assert!(matches!(err, Error::Protocol(_)));
518    }
519
520    #[tokio::test]
521    async fn heartbeat_and_close_post_successfully() {
522        let server = MockServer::start().await;
523        Mock::given(method("POST"))
524            .and(path("/session/heartbeat"))
525            .respond_with(
526                ResponseTemplate::new(200).set_body_json(json!({ "success": true, "data": {} })),
527            )
528            .mount(&server)
529            .await;
530        Mock::given(method("POST"))
531            .and(path("/session"))
532            .respond_with(
533                ResponseTemplate::new(200).set_body_json(json!({ "success": true, "data": {} })),
534            )
535            .mount(&server)
536            .await;
537
538        let session = live_session(&server, 3600);
539        session.heartbeat().await.unwrap();
540        session.close().await.unwrap();
541    }
542
543    #[test]
544    fn parse_result_builds_columns_and_inline_rows() {
545        let data = json!({
546            "queryResultFormat": "json",
547            "rowtype": [
548                { "name": "ID", "type": "fixed", "nullable": false, "precision": 38, "scale": 0 },
549                { "name": "NAME", "type": "text", "nullable": true, "length": 16_777_216 },
550            ],
551            "rowset": [["1", "alice"], ["2", null]],
552        });
553        let (_columns, _index, rows) = parse_result(&data).unwrap();
554        assert_eq!(rows.len(), 2);
555        let payload = rows_to_payload(&rows);
556        assert_eq!(payload["columns"][0]["name"], "ID");
557        assert_eq!(payload["columns"][0]["type"], "fixed(38,0)");
558        assert_eq!(payload["rows"][0]["ID"], 1);
559        assert_eq!(payload["rows"][0]["NAME"], "alice");
560        assert_eq!(payload["rows"][1]["NAME"], Value::Null);
561    }
562
563    #[test]
564    fn parse_result_rejects_arrow_but_allows_chunked() {
565        let arrow = json!({ "queryResultFormat": "arrow", "rowtype": [], "rowset": [] });
566        assert!(matches!(parse_result(&arrow), Err(Error::Unsupported(_))));
567        // Chunked responses are no longer rejected at parse — the inline rows are
568        // returned and chunks are downloaded separately.
569        let chunked = json!({
570            "queryResultFormat": "json",
571            "rowtype": [{ "name": "C", "type": "text" }],
572            "rowset": [["a"]],
573            "chunks": [{ "url": "https://example/chunk0" }],
574        });
575        let (_c, _i, rows) = parse_result(&chunked).unwrap();
576        assert_eq!(rows.len(), 1);
577    }
578
579    #[test]
580    fn parse_result_requires_rowtype() {
581        assert!(matches!(
582            parse_result(&json!({ "rowset": [] })),
583            Err(Error::Protocol(_))
584        ));
585    }
586
587    #[test]
588    fn decode_chunk_rows_handles_gzip_and_plain_json() {
589        use std::io::Write as _;
590        let (columns, index, _rows) = parse_result(&json!({
591            "queryResultFormat": "json",
592            "rowtype": [
593                { "name": "ID", "type": "fixed", "precision": 38, "scale": 0 },
594                { "name": "NAME", "type": "text" },
595            ],
596            "rowset": [],
597        }))
598        .unwrap();
599
600        // A real chunk is bare, comma-separated row arrays WITHOUT enclosing
601        // brackets — the multi-row case that the framing fix must handle.
602        let chunk_json = br#"["3","carol"],["4",null]"#;
603
604        // Plain JSON.
605        let rows = decode_chunk_rows(chunk_json, &columns, &index).unwrap();
606        assert_eq!(rows.len(), 2);
607        assert_eq!(rows[0].to_json_object().get("NAME"), Some(&json!("carol")));
608        assert_eq!(rows[1].to_json_object().get("NAME"), Some(&Value::Null));
609
610        // Gzip-compressed (as chunks arrive over the wire).
611        let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
612        encoder.write_all(chunk_json).unwrap();
613        let gzipped = encoder.finish().unwrap();
614        let rows = decode_chunk_rows(&gzipped, &columns, &index).unwrap();
615        assert_eq!(rows.len(), 2);
616        assert_eq!(rows[0].to_json_object().get("ID"), Some(&json!(3)));
617    }
618
619    #[test]
620    fn parse_chunk_headers_reads_string_pairs() {
621        let data = json!({ "chunkHeaders": { "x-amz-server-side-encryption": "AES256" } });
622        let headers = parse_chunk_headers(&data);
623        assert_eq!(
624            headers,
625            vec![(
626                "x-amz-server-side-encryption".to_string(),
627                "AES256".to_string()
628            )]
629        );
630    }
631}