Skip to main content

faucet_transform_sql/
runtime.rs

1//! The compiled SQL transform: owns the DuckDB connection and runs each page.
2
3use crate::compile::{Reloadable, build_connection, sql_escape, validate_query};
4use crate::config::SqlTransformConfig;
5use crate::shovel::{infer_schema, json_to_record_batch, record_batches_to_json, schema_eq};
6use arrow::array::RecordBatch;
7use arrow::datatypes::SchemaRef;
8use duckdb::Connection;
9use duckdb::vtab::arrow::arrow_recordbatch_to_query_params;
10use faucet_core::FaucetError;
11use faucet_core::stage::TransformStage;
12use serde_json::Value;
13use std::sync::{Arc, Mutex};
14
15struct State {
16    conn: Connection,
17    query: String,
18    reloadables: Vec<Reloadable>,
19    cached_schema: Option<SchemaRef>,
20    pages_seen: u64,
21    aggregates: Option<bool>,
22    warned: bool,
23}
24
25/// A compiled SQL transform. One DuckDB connection, reused across the row's pages.
26pub struct SqlTransform {
27    state: Arc<Mutex<State>>,
28}
29
30impl std::fmt::Debug for SqlTransform {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        let mut d = f.debug_struct("SqlTransform");
33        match self.state.lock() {
34            Ok(st) => d.field("query", &st.query),
35            Err(e) => d.field("query", &e.into_inner().query),
36        };
37        d.finish_non_exhaustive()
38    }
39}
40
41impl SqlTransform {
42    /// Build the connection, load reference relations, and validate the query.
43    pub fn compile(cfg: &SqlTransformConfig) -> Result<Self, FaucetError> {
44        let (conn, reloadables) = build_connection(cfg)?;
45        validate_query(&conn, &cfg.query)?;
46        Ok(Self {
47            state: Arc::new(Mutex::new(State {
48                conn,
49                query: cfg.query.clone(),
50                reloadables,
51                cached_schema: None,
52                pages_seen: 0,
53                aggregates: None,
54                warned: false,
55            })),
56        })
57    }
58
59    /// Consume into a page-level `Value` transform stage.
60    pub fn into_page_stage(self) -> TransformStage {
61        let state = self.state;
62        TransformStage::PageFn(Arc::new(move |records: Vec<Value>| {
63            let mut st = state.lock().unwrap_or_else(|e| e.into_inner());
64            execute_page(&mut st, records)
65        }))
66    }
67
68    /// Consume into a `Value` page stage **plus** an Arrow `RecordBatch → Record
69    /// Batch` form that shares the same DuckDB connection (#375). The CLI passes
70    /// the batch fn to `TransformingSource::new_with_batches`, so a `parquet →
71    /// sql → parquet` chain runs Arrow end-to-end (no `Value` materialization)
72    /// while any other chain keeps using the `Value` page stage.
73    pub fn into_columnar_stage(self) -> (TransformStage, faucet_core::stage::PageFnBatchBox) {
74        let rows_state = self.state.clone();
75        let batch_state = self.state;
76        let stage = TransformStage::PageFn(Arc::new(move |records: Vec<Value>| {
77            let mut st = rows_state.lock().unwrap_or_else(|e| e.into_inner());
78            execute_page(&mut st, records)
79        }));
80        let batch: faucet_core::stage::PageFnBatchBox = Arc::new(move |batch: RecordBatch| {
81            let mut st = batch_state.lock().unwrap_or_else(|e| e.into_inner());
82            execute_batch(&mut st, batch)
83        });
84        (stage, batch)
85    }
86}
87
88/// Register `batch` into DuckDB and run the query, returning the raw Arrow
89/// result batches. Shared by the `Value` path ([`execute_page`]) and the
90/// columnar path ([`execute_batch`]) so both use the same #372 chunked
91/// registration and the same per-page aggregation-warning state.
92fn run_query_batches(st: &mut State, batch: RecordBatch) -> Result<Vec<RecordBatch>, FaucetError> {
93    reload_relations(st)?;
94    // DuckDB's arrow vtab copies each arrow array into a `DataChunk` whose
95    // capacity is `STANDARD_VECTOR_SIZE` (2048); handing it a single batch with
96    // more rows than that aborts the process (#372). `register_batch_chunked`
97    // slices to <=2048 rows into one `batch` relation, so query semantics are
98    // unchanged.
99    register_batch_chunked(st, batch)?;
100
101    // First-page aggregation detection (now that `batch` exists).
102    if st.aggregates.is_none() {
103        st.aggregates = Some(plan_has_aggregate(&st.conn, &st.query));
104    }
105    st.pages_seen += 1;
106    if st.pages_seen >= 2 && st.aggregates == Some(true) && !st.warned {
107        st.warned = true;
108        tracing::warn!(
109            target: "faucet::transform::sql",
110            "sql transform with aggregation received multiple pages; aggregation is \
111             per-page — set batch_size: 0 for global aggregation"
112        );
113    }
114
115    let mut stmt = st
116        .conn
117        .prepare(&st.query)
118        .map_err(|e| FaucetError::Transform(format!("sql transform: prepare: {e}")))?;
119    let batches: Vec<RecordBatch> = stmt
120        .query_arrow([])
121        .map_err(|e| FaucetError::Transform(format!("sql transform: execute: {e}")))?
122        .collect();
123    Ok(batches)
124}
125
126fn execute_page(st: &mut State, records: Vec<Value>) -> Result<Vec<Value>, FaucetError> {
127    if records.is_empty() {
128        return Ok(Vec::new());
129    }
130    // Schema cache: infer once per page, reuse the cached schema on a match,
131    // otherwise adopt the freshly inferred one (first page or drift).
132    let fresh = infer_schema(&records)?;
133    let schema = match &st.cached_schema {
134        Some(s) if schema_eq(s, &fresh) => s.clone(),
135        _ => {
136            st.cached_schema = Some(fresh.clone());
137            fresh
138        }
139    };
140    let batch = json_to_record_batch(&records, schema)?;
141    let batches = run_query_batches(st, batch)?;
142    record_batches_to_json(&batches)
143}
144
145/// The columnar analogue of [`execute_page`]: feed an Arrow `RecordBatch`
146/// straight to DuckDB and return the result as one `RecordBatch`, with no
147/// `Value` materialization on either side.
148fn execute_batch(st: &mut State, batch: RecordBatch) -> Result<RecordBatch, FaucetError> {
149    if batch.num_rows() == 0 {
150        // Empty page: pass through unchanged (the sink skips 0-row batches).
151        return Ok(batch);
152    }
153    let batches = run_query_batches(st, batch)?;
154    if batches.is_empty() {
155        return Ok(RecordBatch::new_empty(std::sync::Arc::new(
156            arrow::datatypes::Schema::empty(),
157        )));
158    }
159    let schema = batches[0].schema();
160    arrow::compute::concat_batches(&schema, &batches)
161        .map_err(|e| FaucetError::Transform(format!("sql transform: concat result batches: {e}")))
162}
163
164/// DuckDB's fixed vector size — the maximum rows a single arrow array may carry
165/// into the arrow vtab without tripping the capacity assertion (#372).
166const DUCKDB_VECTOR_SIZE: usize = 2048;
167
168/// Register `batch` as the temp table `batch`, splitting it into <=2048-row
169/// slices so an oversized page never aborts the process (#372). The first slice
170/// CREATEs the table, the rest INSERT into it — all landing in one relation, so
171/// the downstream query is unaffected. `RecordBatch::slice` is zero-copy.
172fn register_batch_chunked(st: &mut State, batch: RecordBatch) -> Result<(), FaucetError> {
173    let total = batch.num_rows();
174    let mut offset = 0;
175    let mut first = true;
176    // `execute_page` already returned early on empty input, but guard anyway so
177    // a zero-row batch still CREATEs an empty `batch` table (matching the prior
178    // single-CREATE behaviour) rather than leaving a stale one.
179    loop {
180        let len = (total - offset).min(DUCKDB_VECTOR_SIZE);
181        let slice = batch.slice(offset, len);
182        let params = arrow_recordbatch_to_query_params(slice);
183        let sql = if first {
184            "CREATE OR REPLACE TEMP TABLE batch AS SELECT * FROM arrow(?, ?)"
185        } else {
186            "INSERT INTO batch SELECT * FROM arrow(?, ?)"
187        };
188        st.conn
189            .execute(sql, params)
190            .map_err(|e| FaucetError::Transform(format!("sql transform: register batch: {e}")))?;
191        first = false;
192        offset += len;
193        if offset >= total {
194            break;
195        }
196    }
197    Ok(())
198}
199
200fn reload_relations(st: &mut State) -> Result<(), FaucetError> {
201    for r in st.reloadables.iter_mut() {
202        let cur = std::fs::metadata(&r.path).and_then(|m| m.modified()).ok();
203        if cur != r.last_mtime {
204            let stmt = if r.is_csv {
205                format!(
206                    "CREATE OR REPLACE TABLE \"{}\" AS SELECT * FROM read_csv_auto('{}', header={});",
207                    r.name,
208                    sql_escape(&r.path),
209                    r.has_header
210                )
211            } else {
212                format!(
213                    "CREATE OR REPLACE TABLE \"{}\" AS SELECT * FROM read_json_auto('{}', format='newline_delimited');",
214                    r.name,
215                    sql_escape(&r.path)
216                )
217            };
218            st.conn.execute_batch(&stmt).map_err(|e| {
219                FaucetError::Transform(format!("sql transform: reload '{}': {e}", r.name))
220            })?;
221            r.last_mtime = cur;
222        }
223    }
224    Ok(())
225}
226
227fn plan_has_aggregate(conn: &Connection, query: &str) -> bool {
228    let explain = format!("EXPLAIN {query}");
229    let mut found = false;
230    if let Ok(mut stmt) = conn.prepare(&explain)
231        && let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(1))
232    {
233        for r in rows.flatten() {
234            let u = r.to_uppercase();
235            if u.contains("AGGREGATE") || u.contains("WINDOW") {
236                found = true;
237                break;
238            }
239        }
240    }
241    found
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::config::SqlTransformConfig;
248    use faucet_core::stage::{apply_stages_to_page, compile_stage};
249    use serde_json::json;
250
251    fn run(query: &str, rows: Vec<Value>) -> Vec<Value> {
252        let cfg = SqlTransformConfig {
253            query: query.into(),
254            relations: vec![],
255            memory_limit: None,
256            threads: Some(1),
257        };
258        let stage = compile_stage(&SqlTransform::compile(&cfg).unwrap().into_page_stage()).unwrap();
259        apply_stages_to_page(rows, std::slice::from_ref(&stage)).unwrap()
260    }
261
262    // #372: a page larger than DuckDB's 2048-row vector size used to abort the
263    // whole process inside the arrow vtab. After chunked registration it must
264    // pass through cleanly.
265    #[test]
266    fn large_page_passthrough_does_not_abort() {
267        let rows: Vec<Value> = (0..10_000).map(|i| json!({"id": i, "v": i * 2})).collect();
268        let out = run("SELECT * FROM batch", rows);
269        assert_eq!(out.len(), 10_000, "every row of a >2048-row page survives");
270        // Spot-check a row past the first vector boundary.
271        assert_eq!(out[5_000]["id"], json!(5_000));
272    }
273
274    // Chunked registration lands every slice in one `batch` relation, so a
275    // GROUP BY still aggregates over the whole page, not per-2048-chunk.
276    #[test]
277    fn large_page_aggregate_is_global_over_the_whole_page() {
278        let rows: Vec<Value> = (0..5_000).map(|i| json!({"k": i % 4, "v": 1})).collect();
279        let out = run(
280            "SELECT k, COUNT(*) AS n FROM batch GROUP BY k ORDER BY k",
281            rows,
282        );
283        assert_eq!(out.len(), 4, "one group per key");
284        let total: i64 = out.iter().map(|r| r["n"].as_i64().unwrap()).sum();
285        assert_eq!(total, 5_000, "every row counted exactly once across chunks");
286        for r in &out {
287            assert_eq!(r["n"], json!(1_250), "5000 rows / 4 keys = 1250 each");
288        }
289    }
290
291    // The <=2048 fast path (single CREATE) is unchanged.
292    #[test]
293    fn small_page_still_works() {
294        let out = run(
295            "SELECT id FROM batch WHERE id >= 1 ORDER BY id",
296            vec![json!({"id": 0}), json!({"id": 1}), json!({"id": 2})],
297        );
298        assert_eq!(out.len(), 2);
299        assert_eq!(out[0]["id"], json!(1));
300    }
301
302    /// The columnar (#375) form: `into_columnar_stage` yields a `RecordBatch →
303    /// RecordBatch` fn; feed a batch straight in and get one back, no `Value`.
304    fn batch_fn(query: &str) -> faucet_core::stage::PageFnBatchBox {
305        let cfg = SqlTransformConfig {
306            query: query.into(),
307            relations: vec![],
308            memory_limit: None,
309            threads: Some(1),
310        };
311        let (stage, batch) = SqlTransform::compile(&cfg).unwrap().into_columnar_stage();
312        // The row form is a plain page stage; the batch form is what we test.
313        assert!(matches!(stage, TransformStage::PageFn(_)));
314        batch
315    }
316
317    #[test]
318    fn columnar_batch_fn_transforms_record_batch() {
319        let bf = batch_fn("SELECT id, v * 2 AS doubled FROM batch WHERE id >= 1 ORDER BY id");
320        let input = faucet_core::columnar::values_to_record_batch_inferred(&[
321            json!({"id": 0, "v": 5}),
322            json!({"id": 1, "v": 10}),
323            json!({"id": 2, "v": 20}),
324        ])
325        .unwrap();
326        let out = bf(input).unwrap();
327        let rows = faucet_core::columnar::record_batch_to_values(&out).unwrap();
328        assert_eq!(rows.len(), 2, "WHERE id >= 1 keeps two rows");
329        assert_eq!(rows[0]["id"], json!(1));
330        assert_eq!(rows[0]["doubled"], json!(20));
331        assert_eq!(rows[1]["doubled"], json!(40));
332    }
333
334    // #372 on the columnar path: a >2048-row inbound batch must not abort.
335    #[test]
336    fn columnar_batch_fn_handles_large_batch() {
337        let bf = batch_fn("SELECT * FROM batch");
338        let recs: Vec<Value> = (0..5_000).map(|i| json!({"id": i})).collect();
339        let input = faucet_core::columnar::values_to_record_batch_inferred(&recs).unwrap();
340        let out = bf(input).unwrap();
341        assert_eq!(out.num_rows(), 5_000);
342    }
343}