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