Skip to main content

faucet_source_databricks/
stream.rs

1//! Databricks SQL query source over the Statement Execution API.
2//!
3//! Submits `sql` to `POST /api/2.0/sql/statements`, polls
4//! `GET /api/2.0/sql/statements/{id}` until the statement is terminal, then
5//! streams the result chunks (INLINE + JSON_ARRAY) as typed JSON rows,
6//! following `result.next_chunk_internal_link` across chunks. No SDK — plain
7//! `reqwest` over the shared-auth bearer token.
8
9use std::collections::HashMap;
10use std::pin::Pin;
11use std::sync::Mutex;
12use std::time::Duration;
13
14use async_trait::async_trait;
15use faucet_common_databricks::{
16    ErrorSide, StatementClient, StatementOptions, resolve_authorization, value_to_param_string,
17};
18use faucet_core::replication::cursor::{CursorBookmark, CursorTracker};
19use faucet_core::{FaucetError, SharedAuthProvider, Source, Stream, StreamPage};
20use reqwest::Client;
21use serde::Deserialize;
22use serde_json::{Value, json};
23
24use crate::config::{
25    BOOKMARK_TOKEN, DatabricksReplication, DatabricksSourceConfig, ResultDisposition, shared_auth,
26};
27use crate::convert::{ColumnInfo, row_to_json};
28
29/// Databricks SQL query source.
30pub struct DatabricksSource {
31    config: DatabricksSourceConfig,
32    client: Client,
33    /// Base URL override (up to but not including `/api/2.0/...`). `None` uses
34    /// `config.workspace_url`. Set by tests to point at a mock server.
35    endpoint_base: Option<String>,
36    /// Shared auth provider; when set, supplies the bearer token (takes
37    /// precedence over inline auth).
38    auth_provider: Option<SharedAuthProvider>,
39    /// Bookmark applied via [`Source::apply_start_bookmark`].
40    start_bookmark: Mutex<Option<CursorBookmark>>,
41    /// Backend-call context (#880) every request runs through.
42    calls: faucet_core::CallSlot,
43}
44
45#[derive(Debug, Deserialize)]
46struct ResultChunk {
47    #[serde(default)]
48    data_array: Option<Vec<Vec<Value>>>,
49    #[serde(default)]
50    next_chunk_internal_link: Option<String>,
51    /// Present under `EXTERNAL_LINKS` disposition: each entry carries a
52    /// presigned URL to a chunk (a JSON array of rows, or an Arrow IPC stream
53    /// under `arrow_native`) plus its own next-chunk link.
54    #[serde(default)]
55    external_links: Option<Vec<ExternalLink>>,
56}
57
58/// One `result.external_links[]` entry (EXTERNAL_LINKS disposition).
59#[derive(Debug, Deserialize)]
60struct ExternalLink {
61    #[serde(default)]
62    external_link: Option<String>,
63    #[serde(default)]
64    next_chunk_internal_link: Option<String>,
65}
66
67/// The internal link to the next result chunk, checked at both the result
68/// level and (for EXTERNAL_LINKS) the first external-link level.
69fn next_chunk_link(chunk: &ResultChunk) -> Option<String> {
70    chunk.next_chunk_internal_link.clone().or_else(|| {
71        chunk
72            .external_links
73            .as_ref()
74            .and_then(|l| l.first())
75            .and_then(|e| e.next_chunk_internal_link.clone())
76    })
77}
78
79/// A terminal statement: its result columns and first chunk.
80struct Executed {
81    columns: Vec<ColumnInfo>,
82    result: Option<ResultChunk>,
83}
84
85/// Incremental-read context (column + starting position).
86struct IncrementalCtx {
87    column: String,
88    start: CursorBookmark,
89}
90
91/// A Databricks SQL identifier in backticks (a backtick inside is doubled).
92fn quote_ident(name: &str) -> String {
93    format!("`{}`", name.replace('`', "``"))
94}
95
96impl DatabricksSource {
97    /// Create a new source. Validates config; does no I/O.
98    pub fn new(config: DatabricksSourceConfig) -> Result<Self, FaucetError> {
99        config.validate()?;
100        Ok(Self {
101            config,
102            client: faucet_common_databricks::http_client()?,
103            endpoint_base: None,
104            auth_provider: None,
105            start_bookmark: Mutex::new(None),
106            calls: faucet_core::CallSlot::new(faucet_core::CallContext::new().without_timeout()),
107        })
108    }
109
110    /// Attach a shared auth provider (yields the bearer token). Takes
111    /// precedence over inline auth — lets several connectors share one token.
112    pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
113        self.auth_provider = Some(provider);
114        self
115    }
116
117    /// Override the base URL (e.g. a wiremock server). Requests go to
118    /// `{base}/api/2.0/sql/statements`.
119    pub fn with_endpoint_base(mut self, base: impl Into<String>) -> Self {
120        self.endpoint_base = Some(base.into());
121        self
122    }
123
124    fn base_url(&self) -> String {
125        match &self.endpoint_base {
126            Some(b) => b.trim_end_matches('/').to_owned(),
127            None => self.config.workspace_url.trim_end_matches('/').to_owned(),
128        }
129    }
130
131    fn statements_url(&self) -> String {
132        format!("{}/api/2.0/sql/statements", self.base_url())
133    }
134
135    /// Resolve the `Authorization` header value: shared provider first, else
136    /// inline auth.
137    async fn auth_header(&self) -> Result<String, FaucetError> {
138        resolve_authorization(&shared_auth(&self.config.auth), self.auth_provider.as_ref()).await
139    }
140
141    /// A Statement Execution API client for this source's warehouse.
142    fn statement_client(&self) -> StatementClient {
143        StatementClient::new(
144            self.client.clone(),
145            self.base_url(),
146            self.config.warehouse_id.clone(),
147            shared_auth(&self.config.auth),
148            self.auth_provider.clone(),
149            StatementOptions {
150                wait_timeout_secs: self.config.wait_timeout_secs,
151                poll_interval: Duration::from_secs(self.config.poll_interval_secs.max(1)),
152                statement_timeout: (self.config.statement_timeout_secs > 0)
153                    .then(|| Duration::from_secs(self.config.statement_timeout_secs)),
154                ..StatementOptions::default()
155            },
156            ErrorSide::Source,
157        )
158        .with_call_context(self.calls.get())
159    }
160
161    /// The effective incremental start bookmark (persisted bookmark, else the
162    /// configured `initial_value`).
163    fn incremental_ctx(&self) -> Option<IncrementalCtx> {
164        match &self.config.replication {
165            DatabricksReplication::Full => None,
166            DatabricksReplication::Incremental {
167                column,
168                initial_value,
169            } => {
170                let start = self
171                    .start_bookmark
172                    .lock()
173                    .expect("start_bookmark mutex poisoned")
174                    .clone()
175                    .unwrap_or_else(|| CursorBookmark::initial(initial_value.clone()));
176                Some(IncrementalCtx {
177                    column: column.clone(),
178                    start,
179                })
180            }
181        }
182    }
183
184    /// Build the request body: the SQL (with `${bookmark}` and `{ctx}` tokens
185    /// rewritten to named params, and for an incremental read wrapped as
186    /// `SELECT * FROM (<sql>) AS q WHERE q.<column> >= :_faucet_bookmark
187    /// ORDER BY q.<column>`) plus the `parameters` array.
188    fn build_body(&self, context: &HashMap<String, Value>, incr: Option<&IncrementalCtx>) -> Value {
189        let mut sql = self.config.sql.clone();
190        // Named parameters: start with the user's static ones.
191        let mut params: Vec<Value> = self
192            .config
193            .parameters
194            .iter()
195            .map(|p| {
196                json!({
197                    "name": p.name,
198                    "value": value_to_param_string(&p.value),
199                    "type": p.param_type.clone().unwrap_or_else(|| "STRING".into()),
200                })
201            })
202            .collect();
203
204        // Parent-context `{key}` tokens → `:_faucet_ctN` named params.
205        if !context.is_empty() {
206            let (rewritten, ctx_values) =
207                faucet_core::util::substitute_context_bind_params(&sql, context, 0, |i| {
208                    format!(":_faucet_ct{i}")
209                });
210            sql = rewritten;
211            for (i, v) in ctx_values.into_iter().enumerate() {
212                params.push(json!({
213                    "name": format!("_faucet_ct{i}"),
214                    "value": value_to_param_string(&v),
215                }));
216            }
217        }
218
219        // Incremental: `${bookmark}` → the `:_faucet_bookmark` named param,
220        // and the outer `>=` cursor filter + order on the same param.
221        if let Some(ctx) = incr {
222            sql = sql.replace(BOOKMARK_TOKEN, ":_faucet_bookmark");
223            let c = quote_ident(&ctx.column);
224            let inner = sql.trim_end_matches(|ch: char| ch.is_whitespace() || ch == ';');
225            sql = format!(
226                "SELECT * FROM ({inner}\n) AS q WHERE q.{c} >= :_faucet_bookmark ORDER BY q.{c}"
227            );
228            params.push(json!({
229                "name": "_faucet_bookmark",
230                "value": value_to_param_string(&ctx.start.value),
231            }));
232        }
233
234        // ARROW_STREAM is only valid with EXTERNAL_LINKS disposition; JSON_ARRAY
235        // stays INLINE. `arrow_native` is validated to require the `arrow`
236        // feature at config load, so requesting Arrow here is always decodable.
237        // INLINE results are capped at 25 MiB server-side, so the row path
238        // reads presigned JSON chunks by default (SQL-45).
239        let (disposition, format) = if self.config.arrow_native {
240            ("EXTERNAL_LINKS", "ARROW_STREAM")
241        } else {
242            match self.config.result_disposition {
243                ResultDisposition::ExternalLinks => ("EXTERNAL_LINKS", "JSON_ARRAY"),
244                ResultDisposition::Inline => ("INLINE", "JSON_ARRAY"),
245            }
246        };
247        let mut body = json!({
248            "statement": sql,
249            "warehouse_id": self.config.warehouse_id,
250            "wait_timeout": format!("{}s", self.config.wait_timeout_secs),
251            "on_wait_timeout": "CONTINUE",
252            "disposition": disposition,
253            "format": format,
254        });
255        if let Some(c) = &self.config.catalog {
256            body["catalog"] = json!(c);
257        }
258        if let Some(s) = &self.config.schema {
259            body["schema"] = json!(s);
260        }
261        if !params.is_empty() {
262            body["parameters"] = Value::Array(params);
263        }
264        body
265    }
266
267    /// Submit the statement and poll until it reaches a terminal state.
268    async fn run_statement(
269        &self,
270        context: &HashMap<String, Value>,
271        incr: Option<&IncrementalCtx>,
272    ) -> Result<Executed, FaucetError> {
273        let body = self.build_body(context, incr);
274        let resp = self.statement_client().execute_body(&body).await?;
275        let columns = resp.columns().to_vec();
276        crate::convert::check_unique_columns(&columns)?;
277        let result = resp.result.map(decode_chunk).transpose()?;
278        Ok(Executed { columns, result })
279    }
280
281    /// Fetch a follow-up chunk by its `next_chunk_internal_link` (a full API path).
282    async fn fetch_chunk(&self, link: &str) -> Result<ResultChunk, FaucetError> {
283        decode_chunk(self.statement_client().fetch_chunk(link).await?)
284    }
285
286    /// Fetch a presigned external link and decode its body as an Arrow IPC
287    /// stream. The link is a pre-signed cloud-storage URL, so it is fetched
288    /// **without** an `Authorization` header (adding one breaks the signature).
289    #[cfg(feature = "arrow")]
290    async fn fetch_arrow_link(
291        &self,
292        url: &str,
293    ) -> Result<Vec<arrow::array::RecordBatch>, FaucetError> {
294        let resp = self.get_external(url).await?;
295        let data = resp.bytes().await.map_err(|e| {
296            FaucetError::Source(format!(
297                "databricks: reading external-link body failed: {e}"
298            ))
299        })?;
300        tokio::task::spawn_blocking(move || decode_arrow_ipc(data))
301            .await
302            .map_err(|e| {
303                FaucetError::Source(format!("databricks: arrow decode task panicked: {e}"))
304            })?
305    }
306}
307
308impl DatabricksSource {
309    /// GET a presigned external link. It is signed for the storage service, so
310    /// it is fetched **without** an `Authorization` header (adding one breaks
311    /// the signature).
312    async fn get_external(&self, url: &str) -> Result<reqwest::Response, FaucetError> {
313        let resp = self
314            .calls
315            .call("chunk", || async {
316                self.client.get(url).send().await.map_err(|e| {
317                    FaucetError::Source(format!("databricks: external-link request failed: {e}"))
318                })
319            })
320            .await?;
321        let status = resp.status();
322        if !status.is_success() {
323            let body = resp.text().await.unwrap_or_default();
324            return Err(FaucetError::Source(format!(
325                "databricks: external link HTTP {status}: {body}"
326            )));
327        }
328        Ok(resp)
329    }
330
331    /// Fetch a presigned `JSON_ARRAY` chunk: a JSON array of row arrays.
332    async fn fetch_json_link(&self, url: &str) -> Result<Vec<Vec<Value>>, FaucetError> {
333        self.get_external(url).await?.json().await.map_err(|e| {
334            FaucetError::Source(format!(
335                "databricks: could not parse an external-link chunk: {e}"
336            ))
337        })
338    }
339
340    /// Every row of one result chunk: its inline `data_array` plus the rows of
341    /// each presigned external link.
342    async fn chunk_rows(&self, chunk: &mut ResultChunk) -> Result<Vec<Vec<Value>>, FaucetError> {
343        let mut rows = chunk.data_array.take().unwrap_or_default();
344        for link in chunk.external_links.iter().flatten() {
345            if let Some(url) = link.external_link.as_deref() {
346                rows.extend(self.fetch_json_link(url).await?);
347            }
348        }
349        Ok(rows)
350    }
351}
352
353/// Decode an Arrow IPC **stream** (the ARROW_STREAM chunk body) into its
354/// `RecordBatch`es. Synchronous (runs inside `spawn_blocking`).
355#[cfg(feature = "arrow")]
356fn decode_arrow_ipc(data: bytes::Bytes) -> Result<Vec<arrow::array::RecordBatch>, FaucetError> {
357    use arrow::ipc::reader::StreamReader;
358
359    let reader = StreamReader::try_new(std::io::Cursor::new(data), None).map_err(|e| {
360        FaucetError::Source(format!("databricks: arrow IPC reader init failed: {e}"))
361    })?;
362    let mut batches = Vec::new();
363    for batch in reader {
364        batches.push(batch.map_err(|e| {
365            FaucetError::Source(format!("databricks: arrow IPC decode failed: {e}"))
366        })?);
367    }
368    Ok(batches)
369}
370
371/// Derive a stable state key from the workspace, warehouse, and query.
372fn default_state_key(config: &DatabricksSourceConfig) -> String {
373    // Fixed FNV-1a: the key is durable and `DefaultHasher` is not stable across Rust releases.
374    let material = format!(
375        "{}\u{1f}{}\u{1f}{}",
376        config.workspace_url, config.warehouse_id, config.sql
377    );
378    format!(
379        "databricks:{:016x}",
380        faucet_core::shard::shard_hash(&material)
381    )
382}
383
384/// Decode a raw result chunk.
385fn decode_chunk(v: Value) -> Result<ResultChunk, FaucetError> {
386    serde_json::from_value(v)
387        .map_err(|e| FaucetError::Source(format!("databricks: could not parse response: {e}")))
388}
389
390#[async_trait]
391impl Source for DatabricksSource {
392    fn config_schema(&self) -> Value {
393        serde_json::to_value(faucet_core::schema_for!(DatabricksSourceConfig))
394            .expect("schema serialization")
395    }
396
397    fn connector_name(&self) -> &'static str {
398        "databricks"
399    }
400
401    fn set_call_context(&self, ctx: faucet_core::io::CallContext) {
402        self.calls.install(ctx);
403    }
404
405    fn set_roundtrip_recorder(
406        &self,
407        recorder: std::sync::Arc<faucet_core::observability::RoundtripRecorder>,
408    ) {
409        self.calls.install_recorder(recorder);
410    }
411
412    fn dataset_uri(&self) -> String {
413        format!(
414            "databricks://{}/warehouses/{}",
415            self.config
416                .workspace_url
417                .trim_start_matches("https://")
418                .trim_end_matches('/'),
419            self.config.warehouse_id
420        )
421    }
422
423    fn state_key(&self) -> Option<String> {
424        match &self.config.replication {
425            DatabricksReplication::Full => None,
426            DatabricksReplication::Incremental { .. } => Some(
427                self.config
428                    .state_key
429                    .clone()
430                    .unwrap_or_else(|| default_state_key(&self.config)),
431            ),
432        }
433    }
434
435    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
436        let start = CursorBookmark::from_state(bookmark)?;
437        *self
438            .start_bookmark
439            .lock()
440            .expect("start_bookmark mutex poisoned") = Some(start);
441        Ok(())
442    }
443
444    /// The Databricks source advertises the columnar fast path only when
445    /// [`arrow_native`](DatabricksSourceConfig::arrow_native) is set — i.e. the
446    /// statement is fetched as `ARROW_STREAM` (RFC 0002 / #375).
447    #[cfg(feature = "arrow")]
448    fn supports_columnar(&self) -> bool {
449        self.config.arrow_native
450    }
451
452    /// Stream the statement's `ARROW_STREAM` result chunks as Arrow
453    /// `RecordBatch`es — one [`ColumnarPage`](faucet_core::columnar::ColumnarPage)
454    /// per batch — so a `databricks → parquet`/`delta`/`sql` chain never
455    /// materializes `serde_json::Value`. `arrow_native` is Full-replication
456    /// only, so every page carries `bookmark: None`. Empty batches are skipped.
457    #[cfg(feature = "arrow")]
458    fn stream_batches<'a>(
459        &'a self,
460        context: &'a HashMap<String, Value>,
461        _batch_size: usize,
462    ) -> Pin<
463        Box<
464            dyn Stream<Item = Result<faucet_core::columnar::ColumnarPage, FaucetError>> + Send + 'a,
465        >,
466    > {
467        Box::pin(async_stream::try_stream! {
468            if !self.config.arrow_native {
469                Err(FaucetError::Source(
470                    "databricks: stream_batches requires `arrow_native: true`".into(),
471                ))?;
472            }
473            let resp = self.run_statement(context, None).await?;
474            let mut chunk = resp.result;
475            let mut total_records = 0usize;
476            let mut total_pages = 0usize;
477            while let Some(c) = chunk {
478                if let Some(links) = c.external_links.as_ref() {
479                    for link in links {
480                        if let Some(url) = link.external_link.as_deref() {
481                            let batches = self.fetch_arrow_link(url).await?;
482                            for batch in batches {
483                                if batch.num_rows() == 0 {
484                                    continue;
485                                }
486                                total_records += batch.num_rows();
487                                total_pages += 1;
488                                yield faucet_core::columnar::ColumnarPage { batch, bookmark: None };
489                            }
490                        }
491                    }
492                }
493                chunk = match next_chunk_link(&c) {
494                    Some(link) => Some(self.fetch_chunk(&link).await?),
495                    None => None,
496                };
497            }
498            tracing::info!(
499                pages = total_pages,
500                total_records,
501                "databricks columnar stream complete",
502            );
503        })
504    }
505
506    fn stream_pages<'a>(
507        &'a self,
508        context: &'a HashMap<String, Value>,
509        _batch_size: usize,
510    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
511        Box::pin(async_stream::try_stream! {
512            // Arrow-native row path: fetch ARROW_STREAM chunks and decode each
513            // RecordBatch to JSON rows (for a non-columnar sink). `arrow_native`
514            // is Full-replication only (enforced by config validation), so no
515            // incremental filter runs here.
516            #[cfg(feature = "arrow")]
517            if self.config.arrow_native {
518                let resp = self.run_statement(context, None).await?;
519                let cap = if self.config.batch_size == 0 { 1024 } else { self.config.batch_size };
520                let mut buffer: Vec<Value> = Vec::with_capacity(cap);
521                let mut chunk = resp.result;
522                while let Some(c) = chunk {
523                    if let Some(links) = c.external_links.as_ref() {
524                        for link in links {
525                            if let Some(url) = link.external_link.as_deref() {
526                                let batches = self.fetch_arrow_link(url).await?;
527                                for batch in &batches {
528                                    for row in faucet_core::columnar::record_batch_to_values(batch)? {
529                                        buffer.push(row);
530                                        if self.config.batch_size != 0
531                                            && buffer.len() >= self.config.batch_size
532                                        {
533                                            let page = std::mem::replace(
534                                                &mut buffer,
535                                                Vec::with_capacity(cap),
536                                            );
537                                            yield StreamPage { records: page, bookmark: None };
538                                        }
539                                    }
540                                }
541                            }
542                        }
543                    }
544                    chunk = match next_chunk_link(&c) {
545                        Some(link) => Some(self.fetch_chunk(&link).await?),
546                        None => None,
547                    };
548                }
549                if !buffer.is_empty() {
550                    yield StreamPage { records: buffer, bookmark: None };
551                }
552                return;
553            }
554
555            let incr = self.incremental_ctx();
556            let resp = self.run_statement(context, incr.as_ref()).await?;
557
558            let columns: Vec<ColumnInfo> = resp.columns;
559
560            let batch = self.config.batch_size;
561            let cap = if batch == 0 { 1024 } else { batch };
562            let mut buffer: Vec<Value> = Vec::with_capacity(cap);
563            let mut tracker = incr
564                .as_ref()
565                .map(|ic| CursorTracker::new(ic.column.clone(), ic.start.clone()));
566
567            // Walk chunks: the initial `result`, then follow next_chunk_internal_link.
568            let mut chunk = resp.result;
569            while let Some(mut c) = chunk {
570                let data = self.chunk_rows(&mut c).await?;
571                {
572                    for row in &data {
573                        let obj = row_to_json(row, &columns);
574                        if tracker.as_mut().is_none_or(|t| t.admit(&obj)) {
575                            buffer.push(obj);
576                        }
577                        if batch != 0 && buffer.len() >= batch {
578                            let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
579                            let bookmark = tracker.as_ref().map(|t| t.bookmark().to_value());
580                            yield StreamPage { records: page, bookmark };
581                        }
582                    }
583                }
584                chunk = match next_chunk_link(&c) {
585                    Some(link) => Some(self.fetch_chunk(&link).await?),
586                    None => None,
587                };
588            }
589
590            // In incremental mode every page carries the position after its
591            // last row (the final page carries one even when empty).
592            if !buffer.is_empty() || tracker.is_some() {
593                let bookmark = tracker.as_ref().map(|t| t.bookmark().to_value());
594                yield StreamPage { records: buffer, bookmark };
595            }
596            if let Some(missing) = tracker.as_ref().map(CursorTracker::missing).filter(|n| *n > 0) {
597                tracing::warn!(
598                    missing,
599                    "databricks incremental read: rows without a cursor value were emitted \
600                     without moving the bookmark"
601                );
602            }
603        })
604    }
605
606    async fn fetch_with_context(
607        &self,
608        context: &HashMap<String, Value>,
609    ) -> Result<Vec<Value>, FaucetError> {
610        Ok(self.fetch_with_context_incremental(context).await?.0)
611    }
612
613    async fn fetch_with_context_incremental(
614        &self,
615        context: &HashMap<String, Value>,
616    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
617        use futures::StreamExt;
618        let mut records = Vec::new();
619        let mut bookmark = None;
620        let mut s = self.stream_pages(context, self.config.batch_size);
621        while let Some(page) = s.next().await {
622            let page = page?;
623            records.extend(page.records);
624            bookmark = page.bookmark.or(bookmark);
625        }
626        Ok((records, bookmark))
627    }
628
629    async fn check(
630        &self,
631        ctx: &faucet_core::check::CheckContext,
632    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
633        use faucet_core::check::{CheckReport, Probe};
634        let started = std::time::Instant::now();
635        // Non-scanning probe: run `SELECT 1` on the warehouse.
636        let auth = match self.auth_header().await {
637            Ok(a) => a,
638            Err(e) => {
639                return Ok(CheckReport::single(Probe::fail(
640                    "auth",
641                    started.elapsed(),
642                    e.to_string(),
643                )));
644            }
645        };
646        let body = json!({
647            "statement": "SELECT 1",
648            "warehouse_id": self.config.warehouse_id,
649            "wait_timeout": "50s",
650            "disposition": "INLINE",
651            "format": "JSON_ARRAY",
652        });
653        let calls = self.calls.get().with_timeout(ctx.timeout);
654        let fut = calls.call("submit", || async {
655            self.client
656                .post(self.statements_url())
657                .header("Authorization", &auth)
658                .header("Content-Type", "application/json")
659                .json(&body)
660                .send()
661                .await
662                .map_err(FaucetError::from)
663        });
664        let probe = match tokio::time::timeout(ctx.timeout, fut).await {
665            Ok(Ok(r)) if r.status().is_success() => Probe::pass("warehouse", started.elapsed()),
666            Ok(Ok(r)) => Probe::fail_hint(
667                "warehouse",
668                started.elapsed(),
669                format!("databricks probe returned HTTP {}", r.status()),
670                "Verify workspace_url, warehouse_id, and token permissions (CAN USE).",
671            ),
672            Ok(Err(e)) => Probe::fail_hint(
673                "warehouse",
674                started.elapsed(),
675                format!("databricks probe request failed: {e}"),
676                "Verify workspace_url and network reachability.",
677            ),
678            Err(_) => Probe::fail_hint(
679                "warehouse",
680                started.elapsed(),
681                format!("databricks probe timed out after {:?}", ctx.timeout),
682                "Check warehouse availability and network reachability.",
683            ),
684        };
685        Ok(CheckReport::single(probe))
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692    use crate::config::{DatabricksAuth, DatabricksParam};
693    use faucet_core::AuthSpec;
694
695    fn cfg() -> DatabricksSourceConfig {
696        DatabricksSourceConfig {
697            workspace_url: "https://x.cloud.databricks.com".into(),
698            warehouse_id: "wh1".into(),
699            sql: "SELECT * FROM t WHERE ts >= ${bookmark}".into(),
700            auth: AuthSpec::Inline(DatabricksAuth::Pat {
701                token: "tok".into(),
702            }),
703            catalog: Some("main".into()),
704            schema: Some("s".into()),
705            parameters: vec![DatabricksParam {
706                name: "min".into(),
707                value: json!(10),
708                param_type: Some("INT".into()),
709            }],
710            wait_timeout_secs: 50,
711            poll_interval_secs: 1,
712            statement_timeout_secs: 3600,
713            batch_size: 1000,
714            arrow_native: false,
715            result_disposition: ResultDisposition::default(),
716            replication: DatabricksReplication::Incremental {
717                column: "ts".into(),
718                initial_value: json!("2026-01-01"),
719            },
720            state_key: None,
721        }
722    }
723
724    fn source(c: DatabricksSourceConfig) -> DatabricksSource {
725        DatabricksSource::new(c).unwrap()
726    }
727
728    #[test]
729    fn body_has_required_fields_and_params() {
730        let s = source(cfg());
731        let incr = s.incremental_ctx();
732        let body = s.build_body(&HashMap::new(), incr.as_ref());
733        assert_eq!(body["warehouse_id"], json!("wh1"));
734        assert_eq!(body["catalog"], json!("main"));
735        // SQL-45: the row path no longer requests INLINE (capped at 25 MiB).
736        assert_eq!(body["disposition"], json!("EXTERNAL_LINKS"));
737        assert_eq!(body["format"], json!("JSON_ARRAY"));
738        assert_eq!(body["wait_timeout"], json!("50s"));
739        let mut inline = cfg();
740        inline.result_disposition = ResultDisposition::Inline;
741        let inline_body = source(inline).build_body(&HashMap::new(), incr.as_ref());
742        assert_eq!(inline_body["disposition"], json!("INLINE"));
743        assert_eq!(inline_body["format"], json!("JSON_ARRAY"));
744        // ${bookmark} rewritten to the named param marker.
745        assert!(
746            body["statement"]
747                .as_str()
748                .unwrap()
749                .contains(":_faucet_bookmark")
750        );
751        assert!(!body["statement"].as_str().unwrap().contains("${bookmark}"));
752        assert_eq!(
753            body["statement"],
754            json!(
755                "SELECT * FROM (SELECT * FROM t WHERE ts >= :_faucet_bookmark\n) AS q \
756                 WHERE q.`ts` >= :_faucet_bookmark ORDER BY q.`ts`"
757            )
758        );
759        assert_eq!(quote_ident("a`b"), "`a``b`");
760        let params = body["parameters"].as_array().unwrap();
761        // static `min` (typed) + the bookmark param.
762        assert!(
763            params
764                .iter()
765                .any(|p| p["name"] == json!("min") && p["type"] == json!("INT"))
766        );
767        let bm = params
768            .iter()
769            .find(|p| p["name"] == json!("_faucet_bookmark"))
770            .unwrap();
771        assert_eq!(bm["value"], json!("2026-01-01"));
772    }
773
774    #[test]
775    fn full_mode_has_no_state_key_or_bookmark_param() {
776        let mut c = cfg();
777        c.replication = DatabricksReplication::Full;
778        c.sql = "SELECT 1".into();
779        let s = source(c);
780        assert!(s.state_key().is_none());
781        let body = s.build_body(&HashMap::new(), None);
782        assert!(
783            body.get("parameters").is_none()
784                || body["parameters"]
785                    .as_array()
786                    .unwrap()
787                    .iter()
788                    .all(|p| p["name"] != json!("_faucet_bookmark"))
789        );
790    }
791
792    #[test]
793    fn incremental_state_key_derived_and_stable() {
794        let s = source(cfg());
795        let k1 = s.state_key().unwrap();
796        let k2 = source(cfg()).state_key().unwrap();
797        assert_eq!(k1, k2);
798        assert!(k1.starts_with("databricks:"));
799        let c = cfg();
800        let material = format!("{}\u{1f}{}\u{1f}{}", c.workspace_url, c.warehouse_id, c.sql);
801        assert_eq!(
802            k1,
803            format!(
804                "databricks:{:016x}",
805                faucet_core::shard::shard_hash(&material)
806            )
807        );
808    }
809
810    #[tokio::test]
811    async fn explicit_state_key_wins() {
812        let mut c = cfg();
813        c.state_key = Some("my-key".into());
814        let s = source(c);
815        assert_eq!(s.state_key().as_deref(), Some("my-key"));
816        // apply_start_bookmark overrides initial_value in the incr ctx.
817        s.apply_start_bookmark(json!("2026-06-01")).await.unwrap();
818        assert_eq!(
819            s.incremental_ctx().unwrap().start.value,
820            json!("2026-06-01")
821        );
822        assert!(
823            s.apply_start_bookmark(json!({"value": null}))
824                .await
825                .is_err()
826        );
827    }
828
829    #[test]
830    fn value_to_param_string_stringifies() {
831        assert_eq!(value_to_param_string(&json!(5)), json!("5"));
832        assert_eq!(value_to_param_string(&json!(true)), json!("true"));
833        assert_eq!(value_to_param_string(&json!("x")), json!("x"));
834        assert_eq!(value_to_param_string(&Value::Null), Value::Null);
835    }
836
837    #[test]
838    fn decode_chunk_rejects_non_objects() {
839        assert!(decode_chunk(json!({"data_array": [["1"]]})).is_ok());
840        let err = decode_chunk(json!(5)).unwrap_err().to_string();
841        assert!(err.contains("could not parse"), "{err}");
842    }
843
844    #[test]
845    fn dataset_uri_redacts_scheme() {
846        let s = source(cfg());
847        assert_eq!(
848            s.dataset_uri(),
849            "databricks://x.cloud.databricks.com/warehouses/wh1"
850        );
851    }
852}