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}
88
89/// Client-side incremental filter context.
90struct IncrementalCtx {
91    column: String,
92    start: Value,
93}
94
95impl DatabricksSource {
96    /// Create a new source. Validates config; does no I/O.
97    pub fn new(config: DatabricksSourceConfig) -> Result<Self, FaucetError> {
98        config.validate()?;
99        Ok(Self {
100            config,
101            client: Client::new(),
102            endpoint_base: None,
103            auth_provider: None,
104            start_bookmark: Mutex::new(None),
105        })
106    }
107
108    /// Attach a shared auth provider (yields the bearer token). Takes
109    /// precedence over inline auth — lets several connectors share one token.
110    pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
111        self.auth_provider = Some(provider);
112        self
113    }
114
115    /// Override the base URL (e.g. a wiremock server). Requests go to
116    /// `{base}/api/2.0/sql/statements`.
117    pub fn with_endpoint_base(mut self, base: impl Into<String>) -> Self {
118        self.endpoint_base = Some(base.into());
119        self
120    }
121
122    fn base_url(&self) -> String {
123        match &self.endpoint_base {
124            Some(b) => b.trim_end_matches('/').to_owned(),
125            None => self.config.workspace_url.trim_end_matches('/').to_owned(),
126        }
127    }
128
129    fn statements_url(&self) -> String {
130        format!("{}/api/2.0/sql/statements", self.base_url())
131    }
132
133    /// Resolve the `Authorization` header value: shared provider first, else
134    /// inline auth.
135    async fn auth_header(&self) -> Result<String, FaucetError> {
136        if let Some(p) = &self.auth_provider {
137            let cred = p.credential().await?;
138            return cred.authorization_value().ok_or_else(|| {
139                FaucetError::Auth("databricks: shared provider yielded no bearer credential".into())
140            });
141        }
142        match &self.config.auth {
143            AuthSpec::Inline(a) => Ok(a.authorization_value()),
144            AuthSpec::Reference(r) => Err(FaucetError::Auth(format!(
145                "databricks: auth references provider '{}' but none was supplied",
146                r.name
147            ))),
148        }
149    }
150
151    /// The effective incremental start bookmark (persisted bookmark, else the
152    /// configured `initial_value`).
153    fn incremental_ctx(&self) -> Option<IncrementalCtx> {
154        match &self.config.replication {
155            DatabricksReplication::Full => None,
156            DatabricksReplication::Incremental {
157                column,
158                initial_value,
159            } => {
160                let start = self
161                    .start_bookmark
162                    .lock()
163                    .expect("start_bookmark mutex poisoned")
164                    .clone()
165                    .unwrap_or_else(|| initial_value.clone());
166                Some(IncrementalCtx {
167                    column: column.clone(),
168                    start,
169                })
170            }
171        }
172    }
173
174    /// Build the request body: the SQL (with `${bookmark}` and `{ctx}` tokens
175    /// rewritten to named params) plus the `parameters` array.
176    fn build_body(&self, context: &HashMap<String, Value>, incr: Option<&IncrementalCtx>) -> Value {
177        let mut sql = self.config.sql.clone();
178        // Named parameters: start with the user's static ones.
179        let mut params: Vec<Value> = self
180            .config
181            .parameters
182            .iter()
183            .map(|p| {
184                json!({
185                    "name": p.name,
186                    "value": value_to_param_string(&p.value),
187                    "type": p.param_type.clone().unwrap_or_else(|| "STRING".into()),
188                })
189            })
190            .collect();
191
192        // Parent-context `{key}` tokens → `:_faucet_ctN` named params.
193        if !context.is_empty() {
194            let (rewritten, ctx_values) =
195                faucet_core::util::substitute_context_bind_params(&sql, context, 0, |i| {
196                    format!(":_faucet_ct{i}")
197                });
198            sql = rewritten;
199            for (i, v) in ctx_values.into_iter().enumerate() {
200                params.push(json!({
201                    "name": format!("_faucet_ct{i}"),
202                    "value": value_to_param_string(&v),
203                }));
204            }
205        }
206
207        // Incremental `${bookmark}` token → `:_faucet_bookmark` named param.
208        if let Some(ctx) = incr
209            && sql.contains("${bookmark}")
210        {
211            sql = sql.replace("${bookmark}", ":_faucet_bookmark");
212            params.push(json!({
213                "name": "_faucet_bookmark",
214                "value": value_to_param_string(&ctx.start),
215            }));
216        }
217
218        let mut body = json!({
219            "statement": sql,
220            "warehouse_id": self.config.warehouse_id,
221            "wait_timeout": format!("{}s", self.config.wait_timeout_secs),
222            "on_wait_timeout": "CONTINUE",
223            "disposition": "INLINE",
224            "format": "JSON_ARRAY",
225        });
226        if let Some(c) = &self.config.catalog {
227            body["catalog"] = json!(c);
228        }
229        if let Some(s) = &self.config.schema {
230            body["schema"] = json!(s);
231        }
232        if !params.is_empty() {
233            body["parameters"] = Value::Array(params);
234        }
235        body
236    }
237
238    /// Submit the statement and poll until it reaches a terminal state.
239    async fn run_statement(
240        &self,
241        context: &HashMap<String, Value>,
242        incr: Option<&IncrementalCtx>,
243    ) -> Result<StatementResponse, FaucetError> {
244        let auth = self.auth_header().await?;
245        let body = self.build_body(context, incr);
246        let resp = self
247            .client
248            .post(self.statements_url())
249            .header("Authorization", &auth)
250            .header("Content-Type", "application/json")
251            .json(&body)
252            .send()
253            .await
254            .map_err(|e| FaucetError::Source(format!("databricks: submit request failed: {e}")))?;
255        let parsed = parse_http(resp).await?;
256        self.poll_until_terminal(parsed, &auth).await
257    }
258
259    /// Poll `GET /statements/{id}` until the state is terminal (or the initial
260    /// response already is), returning the terminal response.
261    async fn poll_until_terminal(
262        &self,
263        first: StatementResponse,
264        auth: &str,
265    ) -> Result<StatementResponse, FaucetError> {
266        let mut current = first;
267        loop {
268            let state = current
269                .status
270                .as_ref()
271                .map(|s| s.state.as_str())
272                .unwrap_or("");
273            match state {
274                "SUCCEEDED" => return Ok(current),
275                "FAILED" | "CANCELED" | "CLOSED" => {
276                    return Err(statement_error(state, current.status.as_ref()));
277                }
278                "PENDING" | "RUNNING" => {
279                    let id = current.statement_id.clone().ok_or_else(|| {
280                        FaucetError::Source(
281                            "databricks: pending statement without a statement_id to poll".into(),
282                        )
283                    })?;
284                    tokio::time::sleep(Duration::from_secs(self.config.poll_interval_secs.max(1)))
285                        .await;
286                    let url = format!("{}/{}", self.statements_url(), id);
287                    let resp = self
288                        .client
289                        .get(&url)
290                        .header("Authorization", auth)
291                        .send()
292                        .await
293                        .map_err(|e| {
294                            FaucetError::Source(format!("databricks: poll request failed: {e}"))
295                        })?;
296                    current = parse_http(resp).await?;
297                }
298                other => {
299                    return Err(FaucetError::Source(format!(
300                        "databricks: unexpected statement state '{other}'"
301                    )));
302                }
303            }
304        }
305    }
306
307    /// Fetch a follow-up chunk by its `next_chunk_internal_link` (a full API path).
308    async fn fetch_chunk(&self, link: &str, auth: &str) -> Result<ResultChunk, FaucetError> {
309        let url = format!("{}{}", self.base_url(), link);
310        let resp = self
311            .client
312            .get(&url)
313            .header("Authorization", auth)
314            .send()
315            .await
316            .map_err(|e| FaucetError::Source(format!("databricks: chunk request failed: {e}")))?;
317        let parsed = parse_http::<ResultChunk>(resp).await?;
318        Ok(parsed)
319    }
320}
321
322/// Derive a stable state key from the workspace, warehouse, and query.
323fn default_state_key(config: &DatabricksSourceConfig) -> String {
324    let mut h = DefaultHasher::new();
325    config.workspace_url.hash(&mut h);
326    config.warehouse_id.hash(&mut h);
327    config.sql.hash(&mut h);
328    format!("databricks:{:016x}", h.finish())
329}
330
331/// Stringify a JSON value for a Databricks named parameter (`value` is a
332/// string or null in the API).
333fn value_to_param_string(v: &Value) -> Value {
334    match v {
335        Value::Null => Value::Null,
336        Value::String(s) => Value::String(s.clone()),
337        Value::Bool(b) => Value::String(b.to_string()),
338        Value::Number(n) => Value::String(n.to_string()),
339        other => Value::String(other.to_string()),
340    }
341}
342
343/// Build a typed error from a terminal non-success statement state.
344fn statement_error(state: &str, status: Option<&StatusInfo>) -> FaucetError {
345    let detail = status.and_then(|s| s.error.as_ref()).map(|e| {
346        format!(
347            " [{}] {}",
348            e.error_code.as_deref().unwrap_or("UNKNOWN"),
349            e.message.as_deref().unwrap_or("")
350        )
351    });
352    FaucetError::Source(format!(
353        "databricks: statement {state}{}",
354        detail.unwrap_or_default()
355    ))
356}
357
358/// Parse an HTTP response into `T`, surfacing non-2xx as a typed error with the
359/// body (429/5xx/4xx transport errors; SQL errors come back as 200 + FAILED).
360async fn parse_http<T: for<'de> Deserialize<'de>>(
361    resp: reqwest::Response,
362) -> Result<T, FaucetError> {
363    let status = resp.status();
364    if !status.is_success() {
365        let body = resp.text().await.unwrap_or_default();
366        return Err(FaucetError::Source(format!(
367            "databricks: HTTP {status}: {body}"
368        )));
369    }
370    resp.json::<T>()
371        .await
372        .map_err(|e| FaucetError::Source(format!("databricks: could not parse response: {e}")))
373}
374
375#[async_trait]
376impl Source for DatabricksSource {
377    fn config_schema(&self) -> Value {
378        serde_json::to_value(faucet_core::schema_for!(DatabricksSourceConfig))
379            .expect("schema serialization")
380    }
381
382    fn connector_name(&self) -> &'static str {
383        "databricks"
384    }
385
386    fn dataset_uri(&self) -> String {
387        format!(
388            "databricks://{}/warehouses/{}",
389            self.config
390                .workspace_url
391                .trim_start_matches("https://")
392                .trim_end_matches('/'),
393            self.config.warehouse_id
394        )
395    }
396
397    fn state_key(&self) -> Option<String> {
398        match &self.config.replication {
399            DatabricksReplication::Full => None,
400            DatabricksReplication::Incremental { .. } => Some(
401                self.config
402                    .state_key
403                    .clone()
404                    .unwrap_or_else(|| default_state_key(&self.config)),
405            ),
406        }
407    }
408
409    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
410        *self
411            .start_bookmark
412            .lock()
413            .expect("start_bookmark mutex poisoned") = Some(bookmark);
414        Ok(())
415    }
416
417    fn stream_pages<'a>(
418        &'a self,
419        context: &'a HashMap<String, Value>,
420        _batch_size: usize,
421    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
422        Box::pin(async_stream::try_stream! {
423            let auth = self.auth_header().await?;
424            let incr = self.incremental_ctx();
425            let resp = self.run_statement(context, incr.as_ref()).await?;
426
427            let columns: Vec<ColumnInfo> = resp
428                .manifest
429                .and_then(|m| m.schema)
430                .map(|s| s.columns)
431                .unwrap_or_default();
432
433            let batch = self.config.batch_size;
434            let cap = if batch == 0 { 1024 } else { batch };
435            let mut buffer: Vec<Value> = Vec::with_capacity(cap);
436            let mut running_max: Option<Value> = None;
437
438            // Walk chunks: the initial `result`, then follow next_chunk_internal_link.
439            let mut chunk = resp.result;
440            while let Some(c) = chunk {
441                if let Some(data) = c.data_array {
442                    for row in &data {
443                        let obj = row_to_json(row, &columns);
444                        // Track the running max BEFORE the client-side filter so
445                        // the persisted bookmark reflects the full scan.
446                        if let Some(ic) = &incr
447                            && let Some(v) = obj.get(&ic.column)
448                        {
449                            running_max = Some(match running_max.take() {
450                                Some(m) => max_value(m, v.clone()),
451                                None => v.clone(),
452                            });
453                        }
454                        buffer.push(obj);
455                        if batch != 0 && buffer.len() >= batch {
456                            let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
457                            let kept = apply_incr_filter(page, incr.as_ref());
458                            if !kept.is_empty() {
459                                yield StreamPage { records: kept, bookmark: None };
460                            }
461                        }
462                    }
463                }
464                chunk = match c.next_chunk_internal_link {
465                    Some(link) => Some(self.fetch_chunk(&link, &auth).await?),
466                    None => None,
467                };
468            }
469
470            // Final page carries the new bookmark (incremental only).
471            let kept = apply_incr_filter(buffer, incr.as_ref());
472            let bookmark = if incr.is_some() { running_max } else { None };
473            if !kept.is_empty() || bookmark.is_some() {
474                yield StreamPage { records: kept, bookmark };
475            }
476        })
477    }
478
479    async fn fetch_with_context(
480        &self,
481        context: &HashMap<String, Value>,
482    ) -> Result<Vec<Value>, FaucetError> {
483        use futures::StreamExt;
484        let mut out = Vec::new();
485        let mut s = self.stream_pages(context, self.config.batch_size);
486        while let Some(page) = s.next().await {
487            out.extend(page?.records);
488        }
489        Ok(out)
490    }
491
492    async fn check(
493        &self,
494        ctx: &faucet_core::check::CheckContext,
495    ) -> Result<faucet_core::check::CheckReport, FaucetError> {
496        use faucet_core::check::{CheckReport, Probe};
497        let started = std::time::Instant::now();
498        // Non-scanning probe: run `SELECT 1` on the warehouse.
499        let auth = match self.auth_header().await {
500            Ok(a) => a,
501            Err(e) => {
502                return Ok(CheckReport::single(Probe::fail(
503                    "auth",
504                    started.elapsed(),
505                    e.to_string(),
506                )));
507            }
508        };
509        let body = json!({
510            "statement": "SELECT 1",
511            "warehouse_id": self.config.warehouse_id,
512            "wait_timeout": "50s",
513            "disposition": "INLINE",
514            "format": "JSON_ARRAY",
515        });
516        let fut = self
517            .client
518            .post(self.statements_url())
519            .header("Authorization", &auth)
520            .header("Content-Type", "application/json")
521            .json(&body)
522            .send();
523        let probe = match tokio::time::timeout(ctx.timeout, fut).await {
524            Ok(Ok(r)) if r.status().is_success() => Probe::pass("warehouse", started.elapsed()),
525            Ok(Ok(r)) => Probe::fail_hint(
526                "warehouse",
527                started.elapsed(),
528                format!("databricks probe returned HTTP {}", r.status()),
529                "Verify workspace_url, warehouse_id, and token permissions (CAN USE).",
530            ),
531            Ok(Err(e)) => Probe::fail_hint(
532                "warehouse",
533                started.elapsed(),
534                format!("databricks probe request failed: {e}"),
535                "Verify workspace_url and network reachability.",
536            ),
537            Err(_) => Probe::fail_hint(
538                "warehouse",
539                started.elapsed(),
540                format!("databricks probe timed out after {:?}", ctx.timeout),
541                "Check warehouse availability and network reachability.",
542            ),
543        };
544        Ok(CheckReport::single(probe))
545    }
546}
547
548/// Apply the client-side incremental filter to a page (no-op for full runs).
549fn apply_incr_filter(page: Vec<Value>, incr: Option<&IncrementalCtx>) -> Vec<Value> {
550    match incr {
551        Some(ic) => filter_incremental(page, &ic.column, &ic.start),
552        None => page,
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use crate::config::{DatabricksAuth, DatabricksParam};
560
561    fn cfg() -> DatabricksSourceConfig {
562        DatabricksSourceConfig {
563            workspace_url: "https://x.cloud.databricks.com".into(),
564            warehouse_id: "wh1".into(),
565            sql: "SELECT * FROM t WHERE ts > ${bookmark}".into(),
566            auth: AuthSpec::Inline(DatabricksAuth::Pat {
567                token: "tok".into(),
568            }),
569            catalog: Some("main".into()),
570            schema: Some("s".into()),
571            parameters: vec![DatabricksParam {
572                name: "min".into(),
573                value: json!(10),
574                param_type: Some("INT".into()),
575            }],
576            wait_timeout_secs: 50,
577            poll_interval_secs: 1,
578            batch_size: 1000,
579            replication: DatabricksReplication::Incremental {
580                column: "ts".into(),
581                initial_value: json!("2026-01-01"),
582            },
583            state_key: None,
584        }
585    }
586
587    fn source(c: DatabricksSourceConfig) -> DatabricksSource {
588        DatabricksSource::new(c).unwrap()
589    }
590
591    #[test]
592    fn body_has_required_fields_and_params() {
593        let s = source(cfg());
594        let incr = s.incremental_ctx();
595        let body = s.build_body(&HashMap::new(), incr.as_ref());
596        assert_eq!(body["warehouse_id"], json!("wh1"));
597        assert_eq!(body["catalog"], json!("main"));
598        assert_eq!(body["disposition"], json!("INLINE"));
599        assert_eq!(body["format"], json!("JSON_ARRAY"));
600        assert_eq!(body["wait_timeout"], json!("50s"));
601        // ${bookmark} rewritten to the named param marker.
602        assert!(
603            body["statement"]
604                .as_str()
605                .unwrap()
606                .contains(":_faucet_bookmark")
607        );
608        assert!(!body["statement"].as_str().unwrap().contains("${bookmark}"));
609        let params = body["parameters"].as_array().unwrap();
610        // static `min` (typed) + the bookmark param.
611        assert!(
612            params
613                .iter()
614                .any(|p| p["name"] == json!("min") && p["type"] == json!("INT"))
615        );
616        let bm = params
617            .iter()
618            .find(|p| p["name"] == json!("_faucet_bookmark"))
619            .unwrap();
620        assert_eq!(bm["value"], json!("2026-01-01"));
621    }
622
623    #[test]
624    fn full_mode_has_no_state_key_or_bookmark_param() {
625        let mut c = cfg();
626        c.replication = DatabricksReplication::Full;
627        c.sql = "SELECT 1".into();
628        let s = source(c);
629        assert!(s.state_key().is_none());
630        let body = s.build_body(&HashMap::new(), None);
631        assert!(
632            body.get("parameters").is_none()
633                || body["parameters"]
634                    .as_array()
635                    .unwrap()
636                    .iter()
637                    .all(|p| p["name"] != json!("_faucet_bookmark"))
638        );
639    }
640
641    #[test]
642    fn incremental_state_key_derived_and_stable() {
643        let s = source(cfg());
644        let k1 = s.state_key().unwrap();
645        let k2 = source(cfg()).state_key().unwrap();
646        assert_eq!(k1, k2);
647        assert!(k1.starts_with("databricks:"));
648    }
649
650    #[tokio::test]
651    async fn explicit_state_key_wins() {
652        let mut c = cfg();
653        c.state_key = Some("my-key".into());
654        let s = source(c);
655        assert_eq!(s.state_key().as_deref(), Some("my-key"));
656        // apply_start_bookmark overrides initial_value in the incr ctx.
657        s.apply_start_bookmark(json!("2026-06-01")).await.unwrap();
658        assert_eq!(s.incremental_ctx().unwrap().start, json!("2026-06-01"));
659    }
660
661    #[test]
662    fn value_to_param_string_stringifies() {
663        assert_eq!(value_to_param_string(&json!(5)), json!("5"));
664        assert_eq!(value_to_param_string(&json!(true)), json!("true"));
665        assert_eq!(value_to_param_string(&json!("x")), json!("x"));
666        assert_eq!(value_to_param_string(&Value::Null), Value::Null);
667    }
668
669    #[test]
670    fn dataset_uri_redacts_scheme() {
671        let s = source(cfg());
672        assert_eq!(
673            s.dataset_uri(),
674            "databricks://x.cloud.databricks.com/warehouses/wh1"
675        );
676    }
677}