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