1use 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
26pub struct DatabricksSource {
28 config: DatabricksSourceConfig,
29 client: Client,
30 endpoint_base: Option<String>,
33 auth_provider: Option<SharedAuthProvider>,
36 start_bookmark: Mutex<Option<Value>>,
38}
39
40#[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
89struct IncrementalCtx {
91 column: String,
92 start: Value,
93}
94
95impl DatabricksSource {
96 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 pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
111 self.auth_provider = Some(provider);
112 self
113 }
114
115 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 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 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 fn build_body(&self, context: &HashMap<String, Value>, incr: Option<&IncrementalCtx>) -> Value {
177 let mut sql = self.config.sql.clone();
178 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 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 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 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 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 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
322fn 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
331fn 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
343fn 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
358async 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 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 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 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 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
548fn 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 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 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 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}