44//! and result-partition pagination. Result decoding into `UniversalValue`s is
55//! the job of the `snowflake-types` crate; this module only produces the raw
66//! `(rowType, data)` pair.
7+ //!
8+ //! Large results are exposed via [`QueryStream`], which keeps **one partition**
9+ //! in memory at a time and yields bounded [`QueryStream::next_batch`] slices for
10+ //! the apply path.
711
812use std:: time:: { Duration , Instant } ;
913
@@ -16,6 +20,9 @@ use crate::SourceOpts;
1620
1721/// A decoded (but not yet type-converted) result set: column metadata plus every
1822/// data row across all partitions.
23+ ///
24+ /// Prefer [`SnowflakeClient::execute_query_stream`] for large tables — this type
25+ /// materializes the full result and is mainly for small queries (DDL, discovery).
1926#[ derive( Debug , Clone ) ]
2027pub struct QueryResult {
2128 /// Per-column metadata (`resultSetMetaData.rowType`).
@@ -24,6 +31,92 @@ pub struct QueryResult {
2431 pub rows : Vec < Vec < JsonValue > > ,
2532}
2633
34+ /// Streaming view of a statement result: one Snowflake partition buffered at a
35+ /// time, sliced into caller-sized batches.
36+ pub struct QueryStream < ' a > {
37+ client : & ' a SnowflakeClient ,
38+ columns : Vec < ColumnType > ,
39+ /// Statement handle used to fetch partitions `1..partition_count`.
40+ handle : Option < String > ,
41+ /// Total partitions reported by Snowflake (or `1` when data arrived without
42+ /// `partitionInfo`).
43+ partition_count : usize ,
44+ /// Next partition index to fetch after the current buffer is exhausted.
45+ next_partition : usize ,
46+ /// Rows for the partition currently being drained.
47+ current : Vec < Vec < JsonValue > > ,
48+ /// Offset within [`Self::current`].
49+ current_offset : usize ,
50+ }
51+
52+ impl < ' a > QueryStream < ' a > {
53+ /// Column metadata for the statement (stable for the life of the stream).
54+ pub fn columns ( & self ) -> & [ ColumnType ] {
55+ & self . columns
56+ }
57+
58+ /// Yield up to `max_rows` raw cells from the current partition, fetching the
59+ /// next partition only when the buffer is empty.
60+ ///
61+ /// Returns `Ok(None)` when the result is fully consumed. A returned batch may
62+ /// be smaller than `max_rows` when a partition ends mid-batch (callers should
63+ /// treat that as a normal, final partial batch for that partition).
64+ pub async fn next_batch ( & mut self , max_rows : usize ) -> Result < Option < Vec < Vec < JsonValue > > > > {
65+ let max_rows = max_rows. max ( 1 ) ;
66+ loop {
67+ if self . current_offset >= self . current . len ( ) {
68+ self . current . clear ( ) ;
69+ self . current_offset = 0 ;
70+ if !self . fetch_next_partition_if_needed ( ) . await ? {
71+ return Ok ( None ) ;
72+ }
73+ // Empty partitions are skipped by continuing the loop.
74+ continue ;
75+ }
76+
77+ let end = ( self . current_offset + max_rows) . min ( self . current . len ( ) ) ;
78+ let batch = self . current [ self . current_offset ..end] . to_vec ( ) ;
79+ self . current_offset = end;
80+
81+ // Drop the partition buffer once fully drained so peak memory stays
82+ // near one partition (+ the in-flight apply window).
83+ if self . current_offset >= self . current . len ( ) {
84+ self . current . clear ( ) ;
85+ self . current . shrink_to_fit ( ) ;
86+ self . current_offset = 0 ;
87+ }
88+
89+ if batch. is_empty ( ) {
90+ continue ;
91+ }
92+ return Ok ( Some ( batch) ) ;
93+ }
94+ }
95+
96+ async fn fetch_next_partition_if_needed ( & mut self ) -> Result < bool > {
97+ if self . next_partition >= self . partition_count {
98+ return Ok ( false ) ;
99+ }
100+
101+ // Partition 0 is always loaded into `current` at stream open. Subsequent
102+ // partitions are fetched by index (`next_partition` starts at 1).
103+ let handle = self
104+ . handle
105+ . as_deref ( )
106+ . ok_or_else ( || anyhow ! ( "multi-partition result missing statementHandle" ) ) ?;
107+ let partition = self . next_partition ;
108+ tracing:: debug!(
109+ partition,
110+ partition_count = self . partition_count,
111+ "Fetching Snowflake result partition"
112+ ) ;
113+ self . current = self . client . fetch_partition ( handle, partition) . await ?;
114+ self . next_partition += 1 ;
115+ self . current_offset = 0 ;
116+ Ok ( true )
117+ }
118+ }
119+
27120/// Client for a single Snowflake account, bound to one warehouse/database/schema.
28121pub struct SnowflakeClient {
29122 http : reqwest:: Client ,
@@ -119,9 +212,12 @@ impl SnowflakeClient {
119212 . map_err ( |e| anyhow ! ( "failed to generate Snowflake JWT: {e}" ) )
120213 }
121214
122- /// Execute a SQL statement and return the fully-paginated result set.
123- pub async fn execute_query ( & self , sql : & str ) -> Result < QueryResult > {
124- tracing:: debug!( "Snowflake execute: {sql}" ) ;
215+ /// Execute a SQL statement and stream partitions one at a time.
216+ ///
217+ /// Peak source-side memory is roughly one Snowflake result partition (plus
218+ /// whatever the caller retains from [`QueryStream::next_batch`]).
219+ pub async fn execute_query_stream ( & self , sql : & str ) -> Result < QueryStream < ' _ > > {
220+ tracing:: debug!( "Snowflake execute (stream): {sql}" ) ;
125221
126222 let mut body = serde_json:: Map :: new ( ) ;
127223 body. insert ( "statement" . into ( ) , JsonValue :: String ( sql. to_string ( ) ) ) ;
@@ -142,28 +238,51 @@ impl SnowflakeClient {
142238
143239 let url = format ! ( "{}/api/v2/statements" , self . base_url) ;
144240 let ( status, resp) = self . send_post ( & url, & body) . await ?;
145-
146- // Drive an async (202) statement to completion.
147241 let resp = self . await_completion ( status, resp) . await ?;
148242
149243 let meta = resp
150244 . result_set_meta_data
151245 . ok_or_else ( || anyhow ! ( "Snowflake response missing resultSetMetaData" ) ) ?;
152246 let columns = meta. row_type ;
153-
154- // Partition 0 arrives inline; fetch the rest by index.
155- let mut rows = resp. data . unwrap_or_default ( ) ;
156- let partition_count = meta. partition_info . len ( ) ;
157- if partition_count > 1 {
158- let handle = resp
159- . statement_handle
160- . ok_or_else ( || anyhow ! ( "multi-partition result missing statementHandle" ) ) ?;
161- for partition in 1 ..partition_count {
162- let mut more = self . fetch_partition ( & handle, partition) . await ?;
163- rows. append ( & mut more) ;
247+ let current = resp. data . unwrap_or_default ( ) ;
248+
249+ // Snowflake normally reports partitionInfo; when it is absent but rows
250+ // arrived inline, treat that as a single already-buffered partition.
251+ let partition_count = if meta. partition_info . is_empty ( ) {
252+ if current. is_empty ( ) {
253+ 0
254+ } else {
255+ 1
164256 }
165- }
257+ } else {
258+ meta. partition_info . len ( )
259+ } ;
260+
261+ Ok ( QueryStream {
262+ client : self ,
263+ columns,
264+ handle : resp. statement_handle ,
265+ partition_count,
266+ // Partition 0 is already in `current`; the next fetch (if any) is 1.
267+ next_partition : 1 ,
268+ current,
269+ current_offset : 0 ,
270+ } )
271+ }
166272
273+ /// Execute a SQL statement and return the fully-paginated result set.
274+ ///
275+ /// Convenience for small results (DDL, `INFORMATION_SCHEMA`, tests). Prefer
276+ /// [`Self::execute_query_stream`] for table ingestion.
277+ pub async fn execute_query ( & self , sql : & str ) -> Result < QueryResult > {
278+ let mut stream = self . execute_query_stream ( sql) . await ?;
279+ let columns = stream. columns ( ) . to_vec ( ) ;
280+ let mut rows = Vec :: new ( ) ;
281+ // Drain with a large batch size; partitions still arrive one at a time,
282+ // then are appended here (intentional full materialization).
283+ while let Some ( mut batch) = stream. next_batch ( 10_000 ) . await ? {
284+ rows. append ( & mut batch) ;
285+ }
167286 Ok ( QueryResult { columns, rows } )
168287 }
169288
0 commit comments