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