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) on the last external link of the response.
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.last())
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 data = bytes::Bytes::from(self.get_external(url).await?);
295        tokio::task::spawn_blocking(move || decode_arrow_ipc(data))
296            .await
297            .map_err(|e| {
298                FaucetError::Source(format!("databricks: arrow decode task panicked: {e}"))
299            })?
300    }
301}
302
303impl DatabricksSource {
304    /// GET a presigned external link. It is signed for the storage service, so
305    /// it is fetched **without** an `Authorization` header (adding one breaks
306    /// the signature).
307    async fn get_external(&self, url: &str) -> Result<Vec<u8>, FaucetError> {
308        self.calls
309            .call("chunk", || async {
310                let resp = self.client.get(url).send().await.map_err(|e| {
311                    FaucetError::Source(format!("databricks: external-link request failed: {e}"))
312                })?;
313                let status = resp.status();
314                if !status.is_success() {
315                    let body = faucet_core::http::read_body_limited(
316                        resp,
317                        Some(faucet_core::util::DEFAULT_ERROR_BODY_MAX_LEN as u64),
318                    )
319                    .await
320                    .map(|b| String::from_utf8_lossy(&b).into_owned())
321                    .unwrap_or_default();
322                    return Err(FaucetError::Source(format!(
323                        "databricks: external link HTTP {status}: {body}"
324                    )));
325                }
326                faucet_core::http::read_body_limited(
327                    resp,
328                    Some(faucet_core::http::DEFAULT_MAX_RESPONSE_BYTES),
329                )
330                .await
331                .map_err(|e| {
332                    FaucetError::Source(format!(
333                        "databricks: reading external-link body failed: {e}"
334                    ))
335                })
336            })
337            .await
338    }
339
340    /// Fetch a presigned `JSON_ARRAY` chunk: a JSON array of row arrays.
341    async fn fetch_json_link(&self, url: &str) -> Result<Vec<Vec<Value>>, FaucetError> {
342        let body = self.get_external(url).await?;
343        serde_json::from_slice(&body).map_err(|e| {
344            FaucetError::Source(format!(
345                "databricks: could not parse an external-link chunk: {e}"
346            ))
347        })
348    }
349
350    /// Every row of one result chunk: its inline `data_array` plus the rows of
351    /// each presigned external link.
352    async fn chunk_rows(&self, chunk: &mut ResultChunk) -> Result<Vec<Vec<Value>>, FaucetError> {
353        let mut rows = chunk.data_array.take().unwrap_or_default();
354        for link in chunk.external_links.iter().flatten() {
355            if let Some(url) = link.external_link.as_deref() {
356                rows.extend(self.fetch_json_link(url).await?);
357            }
358        }
359        Ok(rows)
360    }
361}
362
363/// Decode an Arrow IPC **stream** (the ARROW_STREAM chunk body) into its
364/// `RecordBatch`es. Synchronous (runs inside `spawn_blocking`).
365#[cfg(feature = "arrow")]
366fn decode_arrow_ipc(data: bytes::Bytes) -> Result<Vec<arrow::array::RecordBatch>, FaucetError> {
367    use arrow::ipc::reader::StreamReader;
368
369    let reader = StreamReader::try_new(std::io::Cursor::new(data), None).map_err(|e| {
370        FaucetError::Source(format!("databricks: arrow IPC reader init failed: {e}"))
371    })?;
372    let mut batches = Vec::new();
373    for batch in reader {
374        batches.push(batch.map_err(|e| {
375            FaucetError::Source(format!("databricks: arrow IPC decode failed: {e}"))
376        })?);
377    }
378    Ok(batches)
379}
380
381/// Derive a stable state key from the workspace, warehouse, and query.
382fn default_state_key(config: &DatabricksSourceConfig) -> String {
383    // Fixed FNV-1a: the key is durable and `DefaultHasher` is not stable across Rust releases.
384    let material = format!(
385        "{}\u{1f}{}\u{1f}{}",
386        config.workspace_url, config.warehouse_id, config.sql
387    );
388    format!(
389        "databricks:{:016x}",
390        faucet_core::shard::shard_hash(&material)
391    )
392}
393
394/// Decode a raw result chunk.
395fn decode_chunk(v: Value) -> Result<ResultChunk, FaucetError> {
396    serde_json::from_value(v)
397        .map_err(|e| FaucetError::Source(format!("databricks: could not parse response: {e}")))
398}
399
400#[async_trait]
401impl Source for DatabricksSource {
402    fn config_schema(&self) -> Value {
403        serde_json::to_value(faucet_core::schema_for!(DatabricksSourceConfig))
404            .expect("schema serialization")
405    }
406
407    fn connector_name(&self) -> &'static str {
408        "databricks"
409    }
410
411    fn set_call_context(&self, ctx: faucet_core::io::CallContext) {
412        self.calls.install(ctx);
413    }
414
415    fn set_roundtrip_recorder(
416        &self,
417        recorder: std::sync::Arc<faucet_core::observability::RoundtripRecorder>,
418    ) {
419        self.calls.install_recorder(recorder);
420    }
421
422    fn dataset_uri(&self) -> String {
423        format!(
424            "databricks://{}/warehouses/{}",
425            self.config
426                .workspace_url
427                .trim_start_matches("https://")
428                .trim_end_matches('/'),
429            self.config.warehouse_id
430        )
431    }
432
433    fn state_key(&self) -> Option<String> {
434        match &self.config.replication {
435            DatabricksReplication::Full => None,
436            DatabricksReplication::Incremental { .. } => Some(
437                self.config
438                    .state_key
439                    .clone()
440                    .unwrap_or_else(|| default_state_key(&self.config)),
441            ),
442        }
443    }
444
445    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
446        let start = CursorBookmark::from_state(bookmark)?;
447        *self
448            .start_bookmark
449            .lock()
450            .expect("start_bookmark mutex poisoned") = Some(start);
451        Ok(())
452    }
453
454    /// The Databricks source advertises the columnar fast path only when
455    /// [`arrow_native`](DatabricksSourceConfig::arrow_native) is set — i.e. the
456    /// statement is fetched as `ARROW_STREAM` (RFC 0002 / #375).
457    #[cfg(feature = "arrow")]
458    fn supports_columnar(&self) -> bool {
459        self.config.arrow_native
460    }
461
462    /// Stream the statement's `ARROW_STREAM` result chunks as Arrow
463    /// `RecordBatch`es — one [`ColumnarPage`](faucet_core::columnar::ColumnarPage)
464    /// per batch — so a `databricks → parquet`/`delta`/`sql` chain never
465    /// materializes `serde_json::Value`. `arrow_native` is Full-replication
466    /// only, so every page carries `bookmark: None`. Empty batches are skipped.
467    #[cfg(feature = "arrow")]
468    fn stream_batches<'a>(
469        &'a self,
470        context: &'a HashMap<String, Value>,
471        _batch_size: usize,
472    ) -> Pin<
473        Box<
474            dyn Stream<Item = Result<faucet_core::columnar::ColumnarPage, FaucetError>> + Send + 'a,
475        >,
476    > {
477        Box::pin(async_stream::try_stream! {
478            if !self.config.arrow_native {
479                Err(FaucetError::Source(
480                    "databricks: stream_batches requires `arrow_native: true`".into(),
481                ))?;
482            }
483            let resp = self.run_statement(context, None).await?;
484            let mut chunk = resp.result;
485            let mut total_records = 0usize;
486            let mut total_pages = 0usize;
487            while let Some(c) = chunk {
488                if let Some(links) = c.external_links.as_ref() {
489                    for link in links {
490                        if let Some(url) = link.external_link.as_deref() {
491                            let batches = self.fetch_arrow_link(url).await?;
492                            for batch in batches {
493                                if batch.num_rows() == 0 {
494                                    continue;
495                                }
496                                total_records += batch.num_rows();
497                                total_pages += 1;
498                                yield faucet_core::columnar::ColumnarPage { batch, bookmark: None };
499                            }
500                        }
501                    }
502                }
503                chunk = match next_chunk_link(&c) {
504                    Some(link) => Some(self.fetch_chunk(&link).await?),
505                    None => None,
506                };
507            }
508            tracing::info!(
509                pages = total_pages,
510                total_records,
511                "databricks columnar stream complete",
512            );
513        })
514    }
515
516    fn stream_pages<'a>(
517        &'a self,
518        context: &'a HashMap<String, Value>,
519        _batch_size: usize,
520    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
521        Box::pin(async_stream::try_stream! {
522            // Arrow-native row path: fetch ARROW_STREAM chunks and decode each
523            // RecordBatch to JSON rows (for a non-columnar sink). `arrow_native`
524            // is Full-replication only (enforced by config validation), so no
525            // incremental filter runs here.
526            #[cfg(feature = "arrow")]
527            if self.config.arrow_native {
528                let resp = self.run_statement(context, None).await?;
529                let cap = if self.config.batch_size == 0 { 1024 } else { self.config.batch_size };
530                let mut buffer: Vec<Value> = Vec::with_capacity(cap);
531                let mut chunk = resp.result;
532                while let Some(c) = chunk {
533                    if let Some(links) = c.external_links.as_ref() {
534                        for link in links {
535                            if let Some(url) = link.external_link.as_deref() {
536                                let batches = self.fetch_arrow_link(url).await?;
537                                for batch in &batches {
538                                    for row in faucet_core::columnar::record_batch_to_values(batch)? {
539                                        buffer.push(row);
540                                        if self.config.batch_size != 0
541                                            && buffer.len() >= self.config.batch_size
542                                        {
543                                            let page = std::mem::replace(
544                                                &mut buffer,
545                                                Vec::with_capacity(cap),
546                                            );
547                                            yield StreamPage { records: page, bookmark: None };
548                                        }
549                                    }
550                                }
551                            }
552                        }
553                    }
554                    chunk = match next_chunk_link(&c) {
555                        Some(link) => Some(self.fetch_chunk(&link).await?),
556                        None => None,
557                    };
558                }
559                if !buffer.is_empty() {
560                    yield StreamPage { records: buffer, bookmark: None };
561                }
562                return;
563            }
564
565            let incr = self.incremental_ctx();
566            let resp = self.run_statement(context, incr.as_ref()).await?;
567
568            let columns: Vec<ColumnInfo> = resp.columns;
569
570            let batch = self.config.batch_size;
571            let cap = if batch == 0 { 1024 } else { batch };
572            let mut buffer: Vec<Value> = Vec::with_capacity(cap);
573            let mut tracker = incr
574                .as_ref()
575                .map(|ic| CursorTracker::new(ic.column.clone(), ic.start.clone()));
576
577            // Walk chunks: the initial `result`, then follow next_chunk_internal_link.
578            let mut chunk = resp.result;
579            while let Some(mut c) = chunk {
580                let data = self.chunk_rows(&mut c).await?;
581                {
582                    for row in &data {
583                        let obj = row_to_json(row, &columns);
584                        if tracker.as_mut().is_none_or(|t| t.admit(&obj)) {
585                            buffer.push(obj);
586                        }
587                        if batch != 0 && buffer.len() >= batch {
588                            let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
589                            let bookmark = tracker.as_ref().map(|t| t.bookmark().to_value());
590                            yield StreamPage { records: page, bookmark };
591                        }
592                    }
593                }
594                chunk = match next_chunk_link(&c) {
595                    Some(link) => Some(self.fetch_chunk(&link).await?),
596                    None => None,
597                };
598            }
599
600            // In incremental mode every page carries the position after its
601            // last row (the final page carries one even when empty).
602            if !buffer.is_empty() || tracker.is_some() {
603                let bookmark = tracker.as_ref().map(|t| t.bookmark().to_value());
604                yield StreamPage { records: buffer, bookmark };
605            }
606            if let Some(missing) = tracker.as_ref().map(CursorTracker::missing).filter(|n| *n > 0) {
607                tracing::warn!(
608                    missing,
609                    "databricks incremental read: rows without a cursor value were emitted \
610                     without moving the bookmark"
611                );
612            }
613        })
614    }
615
616    async fn fetch_with_context(
617        &self,
618        context: &HashMap<String, Value>,
619    ) -> Result<Vec<Value>, FaucetError> {
620        Ok(self.fetch_with_context_incremental(context).await?.0)
621    }
622
623    async fn fetch_with_context_incremental(
624        &self,
625        context: &HashMap<String, Value>,
626    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
627        use futures::StreamExt;
628        let mut records = Vec::new();
629        let mut bookmark = None;
630        let mut s = self.stream_pages(context, self.config.batch_size);
631        while let Some(page) = s.next().await {
632            let page = page?;
633            records.extend(page.records);
634            bookmark = page.bookmark.or(bookmark);
635        }
636        Ok((records, bookmark))
637    }
638
639    async fn check(
640        &self,
641        ctx: &faucet_core::check::CheckContext,
642    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
643        use faucet_core::check::{CheckReport, Probe};
644        let started = std::time::Instant::now();
645        // Non-scanning probe: run `SELECT 1` on the warehouse.
646        let auth = match self.auth_header().await {
647            Ok(a) => a,
648            Err(e) => {
649                return Ok(CheckReport::single(Probe::fail(
650                    "auth",
651                    started.elapsed(),
652                    e.to_string(),
653                )));
654            }
655        };
656        let body = json!({
657            "statement": "SELECT 1",
658            "warehouse_id": self.config.warehouse_id,
659            "wait_timeout": "50s",
660            "disposition": "INLINE",
661            "format": "JSON_ARRAY",
662        });
663        let calls = self.calls.get().with_timeout(ctx.timeout);
664        let fut = calls.call("submit", || async {
665            self.client
666                .post(self.statements_url())
667                .header("Authorization", &auth)
668                .header("Content-Type", "application/json")
669                .json(&body)
670                .send()
671                .await
672                .map_err(FaucetError::from)
673        });
674        let probe = match tokio::time::timeout(ctx.timeout, fut).await {
675            Ok(Ok(r)) if r.status().is_success() => Probe::pass("warehouse", started.elapsed()),
676            Ok(Ok(r)) => Probe::fail_hint(
677                "warehouse",
678                started.elapsed(),
679                format!("databricks probe returned HTTP {}", r.status()),
680                "Verify workspace_url, warehouse_id, and token permissions (CAN USE).",
681            ),
682            Ok(Err(e)) => Probe::fail_hint(
683                "warehouse",
684                started.elapsed(),
685                format!("databricks probe request failed: {e}"),
686                "Verify workspace_url and network reachability.",
687            ),
688            Err(_) => Probe::fail_hint(
689                "warehouse",
690                started.elapsed(),
691                format!("databricks probe timed out after {:?}", ctx.timeout),
692                "Check warehouse availability and network reachability.",
693            ),
694        };
695        Ok(CheckReport::single(probe))
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702    use crate::config::{DatabricksAuth, DatabricksParam};
703    use faucet_core::AuthSpec;
704
705    fn cfg() -> DatabricksSourceConfig {
706        DatabricksSourceConfig {
707            workspace_url: "https://x.cloud.databricks.com".into(),
708            warehouse_id: "wh1".into(),
709            sql: "SELECT * FROM t WHERE ts >= ${bookmark}".into(),
710            auth: AuthSpec::Inline(DatabricksAuth::Pat {
711                token: "tok".into(),
712            }),
713            catalog: Some("main".into()),
714            schema: Some("s".into()),
715            parameters: vec![DatabricksParam {
716                name: "min".into(),
717                value: json!(10),
718                param_type: Some("INT".into()),
719            }],
720            wait_timeout_secs: 50,
721            poll_interval_secs: 1,
722            statement_timeout_secs: 3600,
723            batch_size: 1000,
724            arrow_native: false,
725            result_disposition: ResultDisposition::default(),
726            replication: DatabricksReplication::Incremental {
727                column: "ts".into(),
728                initial_value: json!("2026-01-01"),
729            },
730            state_key: None,
731        }
732    }
733
734    fn source(c: DatabricksSourceConfig) -> DatabricksSource {
735        DatabricksSource::new(c).unwrap()
736    }
737
738    #[test]
739    fn body_has_required_fields_and_params() {
740        let s = source(cfg());
741        let incr = s.incremental_ctx();
742        let body = s.build_body(&HashMap::new(), incr.as_ref());
743        assert_eq!(body["warehouse_id"], json!("wh1"));
744        assert_eq!(body["catalog"], json!("main"));
745        // SQL-45: the row path no longer requests INLINE (capped at 25 MiB).
746        assert_eq!(body["disposition"], json!("EXTERNAL_LINKS"));
747        assert_eq!(body["format"], json!("JSON_ARRAY"));
748        assert_eq!(body["wait_timeout"], json!("50s"));
749        let mut inline = cfg();
750        inline.result_disposition = ResultDisposition::Inline;
751        let inline_body = source(inline).build_body(&HashMap::new(), incr.as_ref());
752        assert_eq!(inline_body["disposition"], json!("INLINE"));
753        assert_eq!(inline_body["format"], json!("JSON_ARRAY"));
754        // ${bookmark} rewritten to the named param marker.
755        assert!(
756            body["statement"]
757                .as_str()
758                .unwrap()
759                .contains(":_faucet_bookmark")
760        );
761        assert!(!body["statement"].as_str().unwrap().contains("${bookmark}"));
762        assert_eq!(
763            body["statement"],
764            json!(
765                "SELECT * FROM (SELECT * FROM t WHERE ts >= :_faucet_bookmark\n) AS q \
766                 WHERE q.`ts` >= :_faucet_bookmark ORDER BY q.`ts`"
767            )
768        );
769        assert_eq!(quote_ident("a`b"), "`a``b`");
770        let params = body["parameters"].as_array().unwrap();
771        // static `min` (typed) + the bookmark param.
772        assert!(
773            params
774                .iter()
775                .any(|p| p["name"] == json!("min") && p["type"] == json!("INT"))
776        );
777        let bm = params
778            .iter()
779            .find(|p| p["name"] == json!("_faucet_bookmark"))
780            .unwrap();
781        assert_eq!(bm["value"], json!("2026-01-01"));
782    }
783
784    #[test]
785    fn full_mode_has_no_state_key_or_bookmark_param() {
786        let mut c = cfg();
787        c.replication = DatabricksReplication::Full;
788        c.sql = "SELECT 1".into();
789        let s = source(c);
790        assert!(s.state_key().is_none());
791        let body = s.build_body(&HashMap::new(), None);
792        assert!(
793            body.get("parameters").is_none()
794                || body["parameters"]
795                    .as_array()
796                    .unwrap()
797                    .iter()
798                    .all(|p| p["name"] != json!("_faucet_bookmark"))
799        );
800    }
801
802    #[test]
803    fn incremental_state_key_derived_and_stable() {
804        let s = source(cfg());
805        let k1 = s.state_key().unwrap();
806        let k2 = source(cfg()).state_key().unwrap();
807        assert_eq!(k1, k2);
808        assert!(k1.starts_with("databricks:"));
809        let c = cfg();
810        let material = format!("{}\u{1f}{}\u{1f}{}", c.workspace_url, c.warehouse_id, c.sql);
811        assert_eq!(
812            k1,
813            format!(
814                "databricks:{:016x}",
815                faucet_core::shard::shard_hash(&material)
816            )
817        );
818    }
819
820    #[tokio::test]
821    async fn explicit_state_key_wins() {
822        let mut c = cfg();
823        c.state_key = Some("my-key".into());
824        let s = source(c);
825        assert_eq!(s.state_key().as_deref(), Some("my-key"));
826        // apply_start_bookmark overrides initial_value in the incr ctx.
827        s.apply_start_bookmark(json!("2026-06-01")).await.unwrap();
828        assert_eq!(
829            s.incremental_ctx().unwrap().start.value,
830            json!("2026-06-01")
831        );
832        assert!(
833            s.apply_start_bookmark(json!({"value": null}))
834                .await
835                .is_err()
836        );
837    }
838
839    #[test]
840    fn value_to_param_string_stringifies() {
841        assert_eq!(value_to_param_string(&json!(5)), json!("5"));
842        assert_eq!(value_to_param_string(&json!(true)), json!("true"));
843        assert_eq!(value_to_param_string(&json!("x")), json!("x"));
844        assert_eq!(value_to_param_string(&Value::Null), Value::Null);
845    }
846
847    #[test]
848    fn decode_chunk_rejects_non_objects() {
849        assert!(decode_chunk(json!({"data_array": [["1"]]})).is_ok());
850        let err = decode_chunk(json!(5)).unwrap_err().to_string();
851        assert!(err.contains("could not parse"), "{err}");
852    }
853
854    #[test]
855    fn dataset_uri_redacts_scheme() {
856        let s = source(cfg());
857        assert_eq!(
858            s.dataset_uri(),
859            "databricks://x.cloud.databricks.com/warehouses/wh1"
860        );
861    }
862}