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