Skip to content

Commit c738aca

Browse files
authored
Enhance Snowflake source with transform pipeline and partition streaming (#124)
1 parent 29f8e2c commit c738aca

11 files changed

Lines changed: 660 additions & 89 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/snowflake-source/Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ sync-core = { path = "../sync-core" }
1111
surreal-sink = { path = "../surreal-sink" }
1212
snowflake-types = { path = "../snowflake-types" }
1313

14+
# Shared apply / transform pipeline
15+
sync-transform = { path = "../sync-transform" }
16+
async-trait = "0.1"
17+
1418
# HTTP client (rustls to match the process-wide aws-lc-rs provider; no OpenSSL)
1519
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
1620

@@ -27,3 +31,8 @@ serde_json = "1.0"
2731
# Error handling / logging
2832
anyhow = "1.0"
2933
tracing = "0.1"
34+
35+
[dev-dependencies]
36+
tokio = { version = "1.49", features = ["rt-multi-thread", "macros", "time"] }
37+
sync-transform = { path = "../sync-transform" }
38+
async-trait = "0.1"

crates/snowflake-source/src/client.rs

Lines changed: 136 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
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
812
use 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)]
2027
pub 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.
28121
pub 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

Comments
 (0)