1use 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::{filter_incremental, max_value};
19use faucet_core::{FaucetError, SharedAuthProvider, Source, Stream, StreamPage};
20use reqwest::Client;
21use serde::Deserialize;
22use serde_json::{Value, json};
23
24use crate::config::{
25 DatabricksReplication, DatabricksSourceConfig, ResultDisposition, shared_auth,
26};
27use crate::convert::{ColumnInfo, row_to_json};
28
29pub struct DatabricksSource {
31 config: DatabricksSourceConfig,
32 client: Client,
33 endpoint_base: Option<String>,
36 auth_provider: Option<SharedAuthProvider>,
39 start_bookmark: Mutex<Option<Value>>,
41}
42
43#[derive(Debug, Deserialize)]
44struct ResultChunk {
45 #[serde(default)]
46 data_array: Option<Vec<Vec<Value>>>,
47 #[serde(default)]
48 next_chunk_internal_link: Option<String>,
49 #[serde(default)]
53 external_links: Option<Vec<ExternalLink>>,
54}
55
56#[derive(Debug, Deserialize)]
58struct ExternalLink {
59 #[serde(default)]
60 external_link: Option<String>,
61 #[serde(default)]
62 next_chunk_internal_link: Option<String>,
63}
64
65fn next_chunk_link(chunk: &ResultChunk) -> Option<String> {
68 chunk.next_chunk_internal_link.clone().or_else(|| {
69 chunk
70 .external_links
71 .as_ref()
72 .and_then(|l| l.first())
73 .and_then(|e| e.next_chunk_internal_link.clone())
74 })
75}
76
77struct Executed {
79 columns: Vec<ColumnInfo>,
80 result: Option<ResultChunk>,
81}
82
83struct IncrementalCtx {
85 column: String,
86 start: Value,
87}
88
89impl DatabricksSource {
90 pub fn new(config: DatabricksSourceConfig) -> Result<Self, FaucetError> {
92 config.validate()?;
93 Ok(Self {
94 config,
95 client: faucet_common_databricks::http_client()?,
96 endpoint_base: None,
97 auth_provider: None,
98 start_bookmark: Mutex::new(None),
99 })
100 }
101
102 pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
105 self.auth_provider = Some(provider);
106 self
107 }
108
109 pub fn with_endpoint_base(mut self, base: impl Into<String>) -> Self {
112 self.endpoint_base = Some(base.into());
113 self
114 }
115
116 fn base_url(&self) -> String {
117 match &self.endpoint_base {
118 Some(b) => b.trim_end_matches('/').to_owned(),
119 None => self.config.workspace_url.trim_end_matches('/').to_owned(),
120 }
121 }
122
123 fn statements_url(&self) -> String {
124 format!("{}/api/2.0/sql/statements", self.base_url())
125 }
126
127 async fn auth_header(&self) -> Result<String, FaucetError> {
130 resolve_authorization(&shared_auth(&self.config.auth), self.auth_provider.as_ref()).await
131 }
132
133 fn statement_client(&self) -> StatementClient {
135 StatementClient::new(
136 self.client.clone(),
137 self.base_url(),
138 self.config.warehouse_id.clone(),
139 shared_auth(&self.config.auth),
140 self.auth_provider.clone(),
141 StatementOptions {
142 wait_timeout_secs: self.config.wait_timeout_secs,
143 poll_interval: Duration::from_secs(self.config.poll_interval_secs.max(1)),
144 statement_timeout: (self.config.statement_timeout_secs > 0)
145 .then(|| Duration::from_secs(self.config.statement_timeout_secs)),
146 ..StatementOptions::default()
147 },
148 ErrorSide::Source,
149 )
150 }
151
152 fn incremental_ctx(&self) -> Option<IncrementalCtx> {
155 match &self.config.replication {
156 DatabricksReplication::Full => None,
157 DatabricksReplication::Incremental {
158 column,
159 initial_value,
160 } => {
161 let start = self
162 .start_bookmark
163 .lock()
164 .expect("start_bookmark mutex poisoned")
165 .clone()
166 .unwrap_or_else(|| initial_value.clone());
167 Some(IncrementalCtx {
168 column: column.clone(),
169 start,
170 })
171 }
172 }
173 }
174
175 fn build_body(&self, context: &HashMap<String, Value>, incr: Option<&IncrementalCtx>) -> Value {
178 let mut sql = self.config.sql.clone();
179 let mut params: Vec<Value> = self
181 .config
182 .parameters
183 .iter()
184 .map(|p| {
185 json!({
186 "name": p.name,
187 "value": value_to_param_string(&p.value),
188 "type": p.param_type.clone().unwrap_or_else(|| "STRING".into()),
189 })
190 })
191 .collect();
192
193 if !context.is_empty() {
195 let (rewritten, ctx_values) =
196 faucet_core::util::substitute_context_bind_params(&sql, context, 0, |i| {
197 format!(":_faucet_ct{i}")
198 });
199 sql = rewritten;
200 for (i, v) in ctx_values.into_iter().enumerate() {
201 params.push(json!({
202 "name": format!("_faucet_ct{i}"),
203 "value": value_to_param_string(&v),
204 }));
205 }
206 }
207
208 if let Some(ctx) = incr
210 && sql.contains("${bookmark}")
211 {
212 sql = sql.replace("${bookmark}", ":_faucet_bookmark");
213 params.push(json!({
214 "name": "_faucet_bookmark",
215 "value": value_to_param_string(&ctx.start),
216 }));
217 }
218
219 let (disposition, format) = if self.config.arrow_native {
225 ("EXTERNAL_LINKS", "ARROW_STREAM")
226 } else {
227 match self.config.result_disposition {
228 ResultDisposition::ExternalLinks => ("EXTERNAL_LINKS", "JSON_ARRAY"),
229 ResultDisposition::Inline => ("INLINE", "JSON_ARRAY"),
230 }
231 };
232 let mut body = json!({
233 "statement": sql,
234 "warehouse_id": self.config.warehouse_id,
235 "wait_timeout": format!("{}s", self.config.wait_timeout_secs),
236 "on_wait_timeout": "CONTINUE",
237 "disposition": disposition,
238 "format": format,
239 });
240 if let Some(c) = &self.config.catalog {
241 body["catalog"] = json!(c);
242 }
243 if let Some(s) = &self.config.schema {
244 body["schema"] = json!(s);
245 }
246 if !params.is_empty() {
247 body["parameters"] = Value::Array(params);
248 }
249 body
250 }
251
252 async fn run_statement(
254 &self,
255 context: &HashMap<String, Value>,
256 incr: Option<&IncrementalCtx>,
257 ) -> Result<Executed, FaucetError> {
258 let body = self.build_body(context, incr);
259 let resp = self.statement_client().execute_body(&body).await?;
260 let columns = resp.columns().to_vec();
261 let result = resp.result.map(decode_chunk).transpose()?;
262 Ok(Executed { columns, result })
263 }
264
265 async fn fetch_chunk(&self, link: &str) -> Result<ResultChunk, FaucetError> {
267 decode_chunk(self.statement_client().fetch_chunk(link).await?)
268 }
269
270 #[cfg(feature = "arrow")]
274 async fn fetch_arrow_link(
275 &self,
276 url: &str,
277 ) -> Result<Vec<arrow::array::RecordBatch>, FaucetError> {
278 let resp = self.get_external(url).await?;
279 let data = resp.bytes().await.map_err(|e| {
280 FaucetError::Source(format!(
281 "databricks: reading external-link body failed: {e}"
282 ))
283 })?;
284 tokio::task::spawn_blocking(move || decode_arrow_ipc(data))
285 .await
286 .map_err(|e| {
287 FaucetError::Source(format!("databricks: arrow decode task panicked: {e}"))
288 })?
289 }
290}
291
292impl DatabricksSource {
293 async fn get_external(&self, url: &str) -> Result<reqwest::Response, FaucetError> {
297 let resp = self.client.get(url).send().await.map_err(|e| {
298 FaucetError::Source(format!("databricks: external-link request failed: {e}"))
299 })?;
300 let status = resp.status();
301 if !status.is_success() {
302 let body = resp.text().await.unwrap_or_default();
303 return Err(FaucetError::Source(format!(
304 "databricks: external link HTTP {status}: {body}"
305 )));
306 }
307 Ok(resp)
308 }
309
310 async fn fetch_json_link(&self, url: &str) -> Result<Vec<Vec<Value>>, FaucetError> {
312 self.get_external(url).await?.json().await.map_err(|e| {
313 FaucetError::Source(format!(
314 "databricks: could not parse an external-link chunk: {e}"
315 ))
316 })
317 }
318
319 async fn chunk_rows(&self, chunk: &mut ResultChunk) -> Result<Vec<Vec<Value>>, FaucetError> {
322 let mut rows = chunk.data_array.take().unwrap_or_default();
323 for link in chunk.external_links.iter().flatten() {
324 if let Some(url) = link.external_link.as_deref() {
325 rows.extend(self.fetch_json_link(url).await?);
326 }
327 }
328 Ok(rows)
329 }
330}
331
332#[cfg(feature = "arrow")]
335fn decode_arrow_ipc(data: bytes::Bytes) -> Result<Vec<arrow::array::RecordBatch>, FaucetError> {
336 use arrow::ipc::reader::StreamReader;
337
338 let reader = StreamReader::try_new(std::io::Cursor::new(data), None).map_err(|e| {
339 FaucetError::Source(format!("databricks: arrow IPC reader init failed: {e}"))
340 })?;
341 let mut batches = Vec::new();
342 for batch in reader {
343 batches.push(batch.map_err(|e| {
344 FaucetError::Source(format!("databricks: arrow IPC decode failed: {e}"))
345 })?);
346 }
347 Ok(batches)
348}
349
350fn default_state_key(config: &DatabricksSourceConfig) -> String {
352 let material = format!(
354 "{}\u{1f}{}\u{1f}{}",
355 config.workspace_url, config.warehouse_id, config.sql
356 );
357 format!(
358 "databricks:{:016x}",
359 faucet_core::shard::shard_hash(&material)
360 )
361}
362
363fn decode_chunk(v: Value) -> Result<ResultChunk, FaucetError> {
365 serde_json::from_value(v)
366 .map_err(|e| FaucetError::Source(format!("databricks: could not parse response: {e}")))
367}
368
369#[async_trait]
370impl Source for DatabricksSource {
371 fn config_schema(&self) -> Value {
372 serde_json::to_value(faucet_core::schema_for!(DatabricksSourceConfig))
373 .expect("schema serialization")
374 }
375
376 fn connector_name(&self) -> &'static str {
377 "databricks"
378 }
379
380 fn dataset_uri(&self) -> String {
381 format!(
382 "databricks://{}/warehouses/{}",
383 self.config
384 .workspace_url
385 .trim_start_matches("https://")
386 .trim_end_matches('/'),
387 self.config.warehouse_id
388 )
389 }
390
391 fn state_key(&self) -> Option<String> {
392 match &self.config.replication {
393 DatabricksReplication::Full => None,
394 DatabricksReplication::Incremental { .. } => Some(
395 self.config
396 .state_key
397 .clone()
398 .unwrap_or_else(|| default_state_key(&self.config)),
399 ),
400 }
401 }
402
403 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
404 *self
405 .start_bookmark
406 .lock()
407 .expect("start_bookmark mutex poisoned") = Some(bookmark);
408 Ok(())
409 }
410
411 #[cfg(feature = "arrow")]
415 fn supports_columnar(&self) -> bool {
416 self.config.arrow_native
417 }
418
419 #[cfg(feature = "arrow")]
425 fn stream_batches<'a>(
426 &'a self,
427 context: &'a HashMap<String, Value>,
428 _batch_size: usize,
429 ) -> Pin<
430 Box<
431 dyn Stream<Item = Result<faucet_core::columnar::ColumnarPage, FaucetError>> + Send + 'a,
432 >,
433 > {
434 Box::pin(async_stream::try_stream! {
435 if !self.config.arrow_native {
436 Err(FaucetError::Source(
437 "databricks: stream_batches requires `arrow_native: true`".into(),
438 ))?;
439 }
440 let resp = self.run_statement(context, None).await?;
441 let mut chunk = resp.result;
442 let mut total_records = 0usize;
443 let mut total_pages = 0usize;
444 while let Some(c) = chunk {
445 if let Some(links) = c.external_links.as_ref() {
446 for link in links {
447 if let Some(url) = link.external_link.as_deref() {
448 let batches = self.fetch_arrow_link(url).await?;
449 for batch in batches {
450 if batch.num_rows() == 0 {
451 continue;
452 }
453 total_records += batch.num_rows();
454 total_pages += 1;
455 yield faucet_core::columnar::ColumnarPage { batch, bookmark: None };
456 }
457 }
458 }
459 }
460 chunk = match next_chunk_link(&c) {
461 Some(link) => Some(self.fetch_chunk(&link).await?),
462 None => None,
463 };
464 }
465 tracing::info!(
466 pages = total_pages,
467 total_records,
468 "databricks columnar stream complete",
469 );
470 })
471 }
472
473 fn stream_pages<'a>(
474 &'a self,
475 context: &'a HashMap<String, Value>,
476 _batch_size: usize,
477 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
478 Box::pin(async_stream::try_stream! {
479 #[cfg(feature = "arrow")]
484 if self.config.arrow_native {
485 let resp = self.run_statement(context, None).await?;
486 let cap = if self.config.batch_size == 0 { 1024 } else { self.config.batch_size };
487 let mut buffer: Vec<Value> = Vec::with_capacity(cap);
488 let mut chunk = resp.result;
489 while let Some(c) = chunk {
490 if let Some(links) = c.external_links.as_ref() {
491 for link in links {
492 if let Some(url) = link.external_link.as_deref() {
493 let batches = self.fetch_arrow_link(url).await?;
494 for batch in &batches {
495 for row in faucet_core::columnar::record_batch_to_values(batch)? {
496 buffer.push(row);
497 if self.config.batch_size != 0
498 && buffer.len() >= self.config.batch_size
499 {
500 let page = std::mem::replace(
501 &mut buffer,
502 Vec::with_capacity(cap),
503 );
504 yield StreamPage { records: page, bookmark: None };
505 }
506 }
507 }
508 }
509 }
510 }
511 chunk = match next_chunk_link(&c) {
512 Some(link) => Some(self.fetch_chunk(&link).await?),
513 None => None,
514 };
515 }
516 if !buffer.is_empty() {
517 yield StreamPage { records: buffer, bookmark: None };
518 }
519 return;
520 }
521
522 let incr = self.incremental_ctx();
523 let resp = self.run_statement(context, incr.as_ref()).await?;
524
525 let columns: Vec<ColumnInfo> = resp.columns;
526
527 let batch = self.config.batch_size;
528 let cap = if batch == 0 { 1024 } else { batch };
529 let mut buffer: Vec<Value> = Vec::with_capacity(cap);
530 let mut running_max: Option<Value> = None;
531
532 let mut chunk = resp.result;
534 while let Some(mut c) = chunk {
535 let data = self.chunk_rows(&mut c).await?;
536 {
537 for row in &data {
538 let obj = row_to_json(row, &columns);
539 if let Some(ic) = &incr
542 && let Some(v) = obj.get(&ic.column)
543 {
544 running_max = Some(match running_max.take() {
545 Some(m) => max_value(m, v.clone()),
546 None => v.clone(),
547 });
548 }
549 buffer.push(obj);
550 if batch != 0 && buffer.len() >= batch {
551 let page = std::mem::replace(&mut buffer, Vec::with_capacity(cap));
552 let kept = apply_incr_filter(page, incr.as_ref());
553 if !kept.is_empty() {
554 yield StreamPage { records: kept, bookmark: None };
555 }
556 }
557 }
558 }
559 chunk = match next_chunk_link(&c) {
560 Some(link) => Some(self.fetch_chunk(&link).await?),
561 None => None,
562 };
563 }
564
565 let kept = apply_incr_filter(buffer, incr.as_ref());
567 let bookmark = if incr.is_some() { running_max } else { None };
568 if !kept.is_empty() || bookmark.is_some() {
569 yield StreamPage { records: kept, bookmark };
570 }
571 })
572 }
573
574 async fn fetch_with_context(
575 &self,
576 context: &HashMap<String, Value>,
577 ) -> Result<Vec<Value>, FaucetError> {
578 use futures::StreamExt;
579 let mut out = Vec::new();
580 let mut s = self.stream_pages(context, self.config.batch_size);
581 while let Some(page) = s.next().await {
582 out.extend(page?.records);
583 }
584 Ok(out)
585 }
586
587 async fn check(
588 &self,
589 ctx: &faucet_core::check::CheckContext,
590 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
591 use faucet_core::check::{CheckReport, Probe};
592 let started = std::time::Instant::now();
593 let auth = match self.auth_header().await {
595 Ok(a) => a,
596 Err(e) => {
597 return Ok(CheckReport::single(Probe::fail(
598 "auth",
599 started.elapsed(),
600 e.to_string(),
601 )));
602 }
603 };
604 let body = json!({
605 "statement": "SELECT 1",
606 "warehouse_id": self.config.warehouse_id,
607 "wait_timeout": "50s",
608 "disposition": "INLINE",
609 "format": "JSON_ARRAY",
610 });
611 let fut = self
612 .client
613 .post(self.statements_url())
614 .header("Authorization", &auth)
615 .header("Content-Type", "application/json")
616 .json(&body)
617 .send();
618 let probe = match tokio::time::timeout(ctx.timeout, fut).await {
619 Ok(Ok(r)) if r.status().is_success() => Probe::pass("warehouse", started.elapsed()),
620 Ok(Ok(r)) => Probe::fail_hint(
621 "warehouse",
622 started.elapsed(),
623 format!("databricks probe returned HTTP {}", r.status()),
624 "Verify workspace_url, warehouse_id, and token permissions (CAN USE).",
625 ),
626 Ok(Err(e)) => Probe::fail_hint(
627 "warehouse",
628 started.elapsed(),
629 format!("databricks probe request failed: {e}"),
630 "Verify workspace_url and network reachability.",
631 ),
632 Err(_) => Probe::fail_hint(
633 "warehouse",
634 started.elapsed(),
635 format!("databricks probe timed out after {:?}", ctx.timeout),
636 "Check warehouse availability and network reachability.",
637 ),
638 };
639 Ok(CheckReport::single(probe))
640 }
641}
642
643fn apply_incr_filter(page: Vec<Value>, incr: Option<&IncrementalCtx>) -> Vec<Value> {
645 match incr {
646 Some(ic) => filter_incremental(page, &ic.column, &ic.start),
647 None => page,
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654 use crate::config::{DatabricksAuth, DatabricksParam};
655 use faucet_core::AuthSpec;
656
657 fn cfg() -> DatabricksSourceConfig {
658 DatabricksSourceConfig {
659 workspace_url: "https://x.cloud.databricks.com".into(),
660 warehouse_id: "wh1".into(),
661 sql: "SELECT * FROM t WHERE ts > ${bookmark}".into(),
662 auth: AuthSpec::Inline(DatabricksAuth::Pat {
663 token: "tok".into(),
664 }),
665 catalog: Some("main".into()),
666 schema: Some("s".into()),
667 parameters: vec![DatabricksParam {
668 name: "min".into(),
669 value: json!(10),
670 param_type: Some("INT".into()),
671 }],
672 wait_timeout_secs: 50,
673 poll_interval_secs: 1,
674 statement_timeout_secs: 3600,
675 batch_size: 1000,
676 arrow_native: false,
677 result_disposition: ResultDisposition::default(),
678 replication: DatabricksReplication::Incremental {
679 column: "ts".into(),
680 initial_value: json!("2026-01-01"),
681 },
682 state_key: None,
683 }
684 }
685
686 fn source(c: DatabricksSourceConfig) -> DatabricksSource {
687 DatabricksSource::new(c).unwrap()
688 }
689
690 #[test]
691 fn body_has_required_fields_and_params() {
692 let s = source(cfg());
693 let incr = s.incremental_ctx();
694 let body = s.build_body(&HashMap::new(), incr.as_ref());
695 assert_eq!(body["warehouse_id"], json!("wh1"));
696 assert_eq!(body["catalog"], json!("main"));
697 assert_eq!(body["disposition"], json!("EXTERNAL_LINKS"));
699 assert_eq!(body["format"], json!("JSON_ARRAY"));
700 assert_eq!(body["wait_timeout"], json!("50s"));
701 let mut inline = cfg();
702 inline.result_disposition = ResultDisposition::Inline;
703 let inline_body = source(inline).build_body(&HashMap::new(), incr.as_ref());
704 assert_eq!(inline_body["disposition"], json!("INLINE"));
705 assert_eq!(inline_body["format"], json!("JSON_ARRAY"));
706 assert!(
708 body["statement"]
709 .as_str()
710 .unwrap()
711 .contains(":_faucet_bookmark")
712 );
713 assert!(!body["statement"].as_str().unwrap().contains("${bookmark}"));
714 let params = body["parameters"].as_array().unwrap();
715 assert!(
717 params
718 .iter()
719 .any(|p| p["name"] == json!("min") && p["type"] == json!("INT"))
720 );
721 let bm = params
722 .iter()
723 .find(|p| p["name"] == json!("_faucet_bookmark"))
724 .unwrap();
725 assert_eq!(bm["value"], json!("2026-01-01"));
726 }
727
728 #[test]
729 fn full_mode_has_no_state_key_or_bookmark_param() {
730 let mut c = cfg();
731 c.replication = DatabricksReplication::Full;
732 c.sql = "SELECT 1".into();
733 let s = source(c);
734 assert!(s.state_key().is_none());
735 let body = s.build_body(&HashMap::new(), None);
736 assert!(
737 body.get("parameters").is_none()
738 || body["parameters"]
739 .as_array()
740 .unwrap()
741 .iter()
742 .all(|p| p["name"] != json!("_faucet_bookmark"))
743 );
744 }
745
746 #[test]
747 fn incremental_state_key_derived_and_stable() {
748 let s = source(cfg());
749 let k1 = s.state_key().unwrap();
750 let k2 = source(cfg()).state_key().unwrap();
751 assert_eq!(k1, k2);
752 assert!(k1.starts_with("databricks:"));
753 let c = cfg();
754 let material = format!("{}\u{1f}{}\u{1f}{}", c.workspace_url, c.warehouse_id, c.sql);
755 assert_eq!(
756 k1,
757 format!(
758 "databricks:{:016x}",
759 faucet_core::shard::shard_hash(&material)
760 )
761 );
762 }
763
764 #[tokio::test]
765 async fn explicit_state_key_wins() {
766 let mut c = cfg();
767 c.state_key = Some("my-key".into());
768 let s = source(c);
769 assert_eq!(s.state_key().as_deref(), Some("my-key"));
770 s.apply_start_bookmark(json!("2026-06-01")).await.unwrap();
772 assert_eq!(s.incremental_ctx().unwrap().start, json!("2026-06-01"));
773 }
774
775 #[test]
776 fn value_to_param_string_stringifies() {
777 assert_eq!(value_to_param_string(&json!(5)), json!("5"));
778 assert_eq!(value_to_param_string(&json!(true)), json!("true"));
779 assert_eq!(value_to_param_string(&json!("x")), json!("x"));
780 assert_eq!(value_to_param_string(&Value::Null), Value::Null);
781 }
782
783 #[test]
784 fn decode_chunk_rejects_non_objects() {
785 assert!(decode_chunk(json!({"data_array": [["1"]]})).is_ok());
786 let err = decode_chunk(json!(5)).unwrap_err().to_string();
787 assert!(err.contains("could not parse"), "{err}");
788 }
789
790 #[test]
791 fn dataset_uri_redacts_scheme() {
792 let s = source(cfg());
793 assert_eq!(
794 s.dataset_uri(),
795 "databricks://x.cloud.databricks.com/warehouses/wh1"
796 );
797 }
798}