1use crate::error::FaucetError;
4use crate::pipeline::StreamPage;
5use async_trait::async_trait;
6use futures_core::Stream;
7use serde_json::Value;
8use std::pin::Pin;
9
10#[async_trait]
12pub trait Source: Send + Sync {
13 async fn fetch_with_context(
21 &self,
22 context: &std::collections::HashMap<String, Value>,
23 ) -> Result<Vec<Value>, FaucetError>;
24
25 async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
27 self.fetch_with_context(&std::collections::HashMap::new())
28 .await
29 }
30
31 async fn fetch_with_context_incremental(
37 &self,
38 context: &std::collections::HashMap<String, Value>,
39 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
40 let records = self.fetch_with_context(context).await?;
41 Ok((records, None))
42 }
43
44 async fn fetch_all_incremental(&self) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
46 self.fetch_with_context_incremental(&std::collections::HashMap::new())
47 .await
48 }
49
50 fn stream_pages<'a>(
74 &'a self,
75 context: &'a std::collections::HashMap<String, Value>,
76 batch_size: usize,
77 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
78 Box::pin(async_stream::try_stream! {
79 let (records, bookmark) = self
80 .fetch_with_context_incremental(context)
81 .await?;
82 let total = records.len();
83 let chunk = if batch_size == 0 { usize::MAX } else { batch_size };
86
87 if total == 0 {
88 if bookmark.is_some() {
89 yield StreamPage {
90 records: Vec::new(),
91 bookmark,
92 };
93 }
94 return;
95 }
96
97 let mut iter = records.into_iter();
98 let mut consumed = 0usize;
99 loop {
100 let batch: Vec<Value> = iter.by_ref().take(chunk).collect();
101 if batch.is_empty() {
102 break;
103 }
104 consumed += batch.len();
105 let page_bookmark = if consumed >= total {
106 bookmark.clone()
107 } else {
108 None
109 };
110 yield StreamPage {
111 records: batch,
112 bookmark: page_bookmark,
113 };
114 }
115 })
116 }
117
118 fn config_schema(&self) -> Value {
120 serde_json::json!({"type": "object", "properties": {}})
121 }
122
123 fn state_key(&self) -> Option<String> {
135 None
136 }
137
138 async fn apply_start_bookmark(&self, _bookmark: Value) -> Result<(), FaucetError> {
147 Ok(())
148 }
149
150 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
162 Ok(None)
163 }
164
165 fn supports_exactly_once(&self) -> bool {
174 false
175 }
176
177 fn is_shardable(&self) -> bool {
184 false
185 }
186
187 async fn enumerate_shards(
199 &self,
200 _target: usize,
201 ) -> Result<Vec<crate::shard::ShardSpec>, FaucetError> {
202 Ok(vec![crate::shard::ShardSpec::whole()])
203 }
204
205 async fn apply_shard(&self, _shard: &crate::shard::ShardSpec) -> Result<(), FaucetError> {
215 Ok(())
216 }
217
218 fn connector_name(&self) -> &'static str {
226 crate::observability::strip_type_name(std::any::type_name::<Self>())
227 }
228
229 fn dataset_uri(&self) -> String {
237 format!("{}://unknown", self.connector_name())
238 }
239
240 async fn check(
253 &self,
254 ctx: &crate::check::CheckContext,
255 ) -> Result<crate::check::CheckReport, FaucetError> {
256 use crate::check::{CheckReport, Probe};
257 use futures::StreamExt;
258
259 let empty = std::collections::HashMap::new();
260 let start = std::time::Instant::now();
261 let mut pages = self.stream_pages(&empty, 1);
262 let probe = match tokio::time::timeout(ctx.timeout, pages.next()).await {
263 Err(_) => Probe::fail("read", start.elapsed(), "timed out fetching first page"),
264 Ok(None) | Ok(Some(Ok(_))) => Probe::pass("read", start.elapsed()),
265 Ok(Some(Err(e))) => Probe::fail("read", start.elapsed(), e.to_string()),
266 };
267 Ok(CheckReport::single(probe))
268 }
269}
270
271pub type RowOutcome = Result<(), FaucetError>;
277
278#[async_trait]
280pub trait Sink: Send + Sync {
281 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError>;
285
286 async fn flush(&self) -> Result<(), FaucetError> {
291 Ok(())
292 }
293
294 async fn write_batch_partial(&self, records: &[Value]) -> Result<Vec<RowOutcome>, FaucetError> {
303 self.write_batch(records).await?;
304 Ok(records.iter().map(|_| Ok(())).collect())
305 }
306
307 fn supports_idempotent_writes(&self) -> bool {
314 false
315 }
316
317 fn supported_write_modes(&self) -> &'static [crate::write_mode::WriteMode] {
323 &[crate::write_mode::WriteMode::Append]
324 }
325
326 async fn current_schema(&self) -> Result<Option<Value>, FaucetError> {
333 Ok(None)
334 }
335
336 fn supports_schema_evolution(&self) -> bool {
340 false
341 }
342
343 async fn evolve_schema(
351 &self,
352 evolution: &crate::drift::SchemaEvolution,
353 ) -> Result<(), FaucetError> {
354 let _ = evolution;
355 Err(FaucetError::Sink(format!(
356 "sink '{}' does not support schema evolution",
357 self.connector_name()
358 )))
359 }
360
361 async fn write_batch_idempotent(
371 &self,
372 records: &[Value],
373 scope: &str,
374 token: &str,
375 ) -> Result<usize, FaucetError> {
376 let _ = (scope, token);
377 self.write_batch(records).await
378 }
379
380 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
383 let _ = scope;
384 Ok(None)
385 }
386
387 fn config_schema(&self) -> Value {
395 serde_json::json!({"type": "object", "properties": {}})
396 }
397
398 fn connector_name(&self) -> &'static str {
401 crate::observability::strip_type_name(std::any::type_name::<Self>())
402 }
403
404 fn dataset_uri(&self) -> String {
412 format!("{}://unknown", self.connector_name())
413 }
414
415 async fn check(
426 &self,
427 _ctx: &crate::check::CheckContext,
428 ) -> Result<crate::check::CheckReport, FaucetError> {
429 Ok(crate::check::CheckReport::not_implemented())
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use serde_json::json;
437
438 struct MockSource {
441 records: Vec<Value>,
442 }
443
444 #[async_trait]
445 impl Source for MockSource {
446 async fn fetch_with_context(
447 &self,
448 _context: &std::collections::HashMap<String, Value>,
449 ) -> Result<Vec<Value>, FaucetError> {
450 Ok(self.records.clone())
451 }
452 }
453
454 struct IncrementalSource {
455 records: Vec<Value>,
456 bookmark: Value,
457 }
458
459 #[async_trait]
460 impl Source for IncrementalSource {
461 async fn fetch_with_context(
462 &self,
463 _context: &std::collections::HashMap<String, Value>,
464 ) -> Result<Vec<Value>, FaucetError> {
465 Ok(self.records.clone())
466 }
467
468 async fn fetch_with_context_incremental(
469 &self,
470 _context: &std::collections::HashMap<String, Value>,
471 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
472 Ok((self.records.clone(), Some(self.bookmark.clone())))
473 }
474 }
475
476 struct FailingSource;
477
478 #[async_trait]
479 impl Source for FailingSource {
480 async fn fetch_with_context(
481 &self,
482 _context: &std::collections::HashMap<String, Value>,
483 ) -> Result<Vec<Value>, FaucetError> {
484 Err(FaucetError::Auth("no credentials".into()))
485 }
486 }
487
488 struct MockSink {
491 written: std::sync::Mutex<Vec<Value>>,
492 }
493
494 impl MockSink {
495 fn new() -> Self {
496 Self {
497 written: std::sync::Mutex::new(Vec::new()),
498 }
499 }
500 }
501
502 #[async_trait]
503 impl Sink for MockSink {
504 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
505 let mut w = self.written.lock().unwrap();
506 w.extend(records.iter().cloned());
507 Ok(records.len())
508 }
509 }
510
511 struct FailingSink;
512
513 #[async_trait]
514 impl Sink for FailingSink {
515 async fn write_batch(&self, _records: &[Value]) -> Result<usize, FaucetError> {
516 Err(FaucetError::Sink("write failed".into()))
517 }
518 }
519
520 #[tokio::test]
523 async fn source_fetch_all_returns_records() {
524 let source = MockSource {
525 records: vec![json!({"id": 1}), json!({"id": 2})],
526 };
527 let records = source.fetch_all().await.unwrap();
528 assert_eq!(records.len(), 2);
529 assert_eq!(records[0]["id"], 1);
530 }
531
532 #[tokio::test]
533 async fn source_fetch_all_empty() {
534 let source = MockSource { records: vec![] };
535 let records = source.fetch_all().await.unwrap();
536 assert!(records.is_empty());
537 }
538
539 #[tokio::test]
540 async fn source_default_incremental_returns_none_bookmark() {
541 let source = MockSource {
542 records: vec![json!({"id": 1})],
543 };
544 let (records, bookmark) = source.fetch_all_incremental().await.unwrap();
545 assert_eq!(records.len(), 1);
546 assert!(bookmark.is_none());
547 }
548
549 #[tokio::test]
550 async fn source_custom_incremental_returns_bookmark() {
551 let source = IncrementalSource {
552 records: vec![json!({"id": 1})],
553 bookmark: json!("2024-12-01"),
554 };
555 let (records, bookmark) = source.fetch_all_incremental().await.unwrap();
556 assert_eq!(records.len(), 1);
557 assert_eq!(bookmark, Some(json!("2024-12-01")));
558 }
559
560 #[tokio::test]
561 async fn source_error_propagates() {
562 let source = FailingSource;
563 let result = source.fetch_all().await;
564 assert!(result.is_err());
565 assert!(matches!(result, Err(FaucetError::Auth(_))));
566 }
567
568 #[tokio::test]
569 async fn source_as_trait_object() {
570 let source: Box<dyn Source> = Box::new(MockSource {
571 records: vec![json!({"id": 42})],
572 });
573 let records = source.fetch_all().await.unwrap();
574 assert_eq!(records[0]["id"], 42);
575 }
576
577 #[tokio::test]
580 async fn sink_write_batch_returns_count() {
581 let sink = MockSink::new();
582 let records = vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})];
583 let count = sink.write_batch(&records).await.unwrap();
584 assert_eq!(count, 3);
585 }
586
587 #[tokio::test]
588 async fn sink_write_batch_empty() {
589 let sink = MockSink::new();
590 let count = sink.write_batch(&[]).await.unwrap();
591 assert_eq!(count, 0);
592 }
593
594 #[tokio::test]
595 async fn sink_accumulates_records() {
596 let sink = MockSink::new();
597 sink.write_batch(&[json!({"a": 1})]).await.unwrap();
598 sink.write_batch(&[json!({"b": 2})]).await.unwrap();
599 let written = sink.written.lock().unwrap();
600 assert_eq!(written.len(), 2);
601 }
602
603 #[tokio::test]
604 async fn sink_default_flush_is_noop() {
605 let sink = MockSink::new();
606 assert!(sink.flush().await.is_ok());
607 }
608
609 #[tokio::test]
610 async fn sink_error_propagates() {
611 let sink = FailingSink;
612 let result = sink.write_batch(&[json!({"id": 1})]).await;
613 assert!(result.is_err());
614 assert!(matches!(result, Err(FaucetError::Sink(_))));
615 }
616
617 #[tokio::test]
618 async fn sink_as_trait_object() {
619 let sink: Box<dyn Sink> = Box::new(MockSink::new());
620 let count = sink.write_batch(&[json!({"id": 1})]).await.unwrap();
621 assert_eq!(count, 1);
622 }
623
624 use crate::pipeline::DEFAULT_BATCH_SIZE;
627 use futures::StreamExt;
628
629 #[tokio::test]
630 async fn default_stream_pages_chunks_records() {
631 let source = MockSource {
632 records: (0..5).map(|i| json!({"i": i})).collect(),
633 };
634 let ctx = std::collections::HashMap::new();
635 let mut pages = source.stream_pages(&ctx, 2);
636 let mut all = Vec::new();
637 while let Some(page) = pages.next().await {
638 all.push(page.unwrap());
639 }
640 assert_eq!(all.len(), 3);
642 assert_eq!(all[0].records.len(), 2);
643 assert_eq!(all[1].records.len(), 2);
644 assert_eq!(all[2].records.len(), 1);
645 }
646
647 #[tokio::test]
648 async fn default_stream_pages_attaches_bookmark_to_final_page_only() {
649 let source = IncrementalSource {
650 records: (0..5).map(|i| json!({"i": i})).collect(),
651 bookmark: json!("v1"),
652 };
653 let ctx = std::collections::HashMap::new();
654 let mut pages = source.stream_pages(&ctx, 2);
655 let mut collected = Vec::new();
656 while let Some(page) = pages.next().await {
657 collected.push(page.unwrap());
658 }
659 assert_eq!(collected.len(), 3);
660 assert!(collected[0].bookmark.is_none());
661 assert!(collected[1].bookmark.is_none());
662 assert_eq!(collected[2].bookmark, Some(json!("v1")));
663 }
664
665 #[tokio::test]
666 async fn default_stream_pages_single_page_when_batch_size_exceeds_total() {
667 let source = MockSource {
668 records: vec![json!({"id": 1}), json!({"id": 2})],
669 };
670 let ctx = std::collections::HashMap::new();
671 let mut pages = source.stream_pages(&ctx, 100);
672 let mut collected = Vec::new();
673 while let Some(page) = pages.next().await {
674 collected.push(page.unwrap());
675 }
676 assert_eq!(collected.len(), 1);
677 assert_eq!(collected[0].records.len(), 2);
678 }
679
680 #[tokio::test]
681 async fn default_stream_pages_batch_size_zero_emits_single_page() {
682 let source = MockSource {
685 records: (0..50_000).map(|i| json!({"i": i})).collect(),
686 };
687 let ctx = std::collections::HashMap::new();
688 let mut pages = source.stream_pages(&ctx, 0);
689 let mut collected = Vec::new();
690 while let Some(page) = pages.next().await {
691 collected.push(page.unwrap());
692 }
693 assert_eq!(
694 collected.len(),
695 1,
696 "batch_size=0 must emit exactly one page"
697 );
698 assert_eq!(collected[0].records.len(), 50_000);
699 }
700
701 #[tokio::test]
702 async fn default_stream_pages_batch_size_zero_attaches_bookmark_to_sole_page() {
703 let source = IncrementalSource {
704 records: (0..3).map(|i| json!({"i": i})).collect(),
705 bookmark: json!("v1"),
706 };
707 let ctx = std::collections::HashMap::new();
708 let mut pages = source.stream_pages(&ctx, 0);
709 let page = pages.next().await.unwrap().unwrap();
710 assert_eq!(page.records.len(), 3);
711 assert_eq!(page.bookmark, Some(json!("v1")));
712 assert!(pages.next().await.is_none());
713 }
714
715 #[tokio::test]
716 async fn default_stream_pages_empty_source_yields_no_pages() {
717 let source = MockSource { records: vec![] };
718 let ctx = std::collections::HashMap::new();
719 let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
720 assert!(pages.next().await.is_none());
721 }
722
723 #[tokio::test]
724 async fn default_stream_pages_empty_source_with_bookmark_yields_single_empty_page() {
725 let source = IncrementalSource {
726 records: vec![],
727 bookmark: json!("v0"),
728 };
729 let ctx = std::collections::HashMap::new();
730 let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
731 let mut collected = Vec::new();
732 while let Some(page) = pages.next().await {
733 collected.push(page.unwrap());
734 }
735 assert_eq!(collected.len(), 1);
738 assert!(collected[0].records.is_empty());
739 assert_eq!(collected[0].bookmark, Some(json!("v0")));
740 }
741
742 #[tokio::test]
743 async fn default_stream_pages_propagates_fetch_errors() {
744 let source = FailingSource;
745 let ctx = std::collections::HashMap::new();
746 let mut pages = source.stream_pages(&ctx, DEFAULT_BATCH_SIZE);
747 let first = pages.next().await.unwrap();
748 assert!(matches!(first, Err(FaucetError::Auth(_))));
749 }
750
751 #[test]
752 fn source_default_connector_name_is_stripped_type_name() {
753 let source = MockSource { records: vec![] };
756 assert_eq!(source.connector_name(), "MockSource");
757 }
758
759 #[test]
760 fn sink_default_connector_name_is_stripped_type_name() {
761 let sink = MockSink::new();
762 assert_eq!(sink.connector_name(), "MockSink");
763 }
764
765 #[test]
766 fn source_default_dataset_uri_uses_connector_name() {
767 let source = MockSource { records: vec![] };
768 assert_eq!(source.dataset_uri(), "MockSource://unknown");
769 }
770
771 #[test]
772 fn sink_default_dataset_uri_uses_connector_name() {
773 let sink = MockSink::new();
774 assert_eq!(sink.dataset_uri(), "MockSink://unknown");
775 }
776
777 #[tokio::test]
780 async fn default_write_batch_partial_success_returns_all_ok() {
781 let sink = MockSink::new();
782 let records = vec![json!({"id": 1}), json!({"id": 2}), json!({"id": 3})];
783 let outcomes = sink.write_batch_partial(&records).await.unwrap();
784 assert_eq!(outcomes.len(), 3);
785 assert!(outcomes.iter().all(|o| o.is_ok()));
786 assert_eq!(sink.written.lock().unwrap().len(), 3);
787 }
788
789 #[tokio::test]
790 async fn default_write_batch_partial_bubbles_outer_err() {
791 let sink = FailingSink;
792 let records = vec![json!({"id": 1}), json!({"id": 2})];
793 let result = sink.write_batch_partial(&records).await;
794 assert!(matches!(result, Err(FaucetError::Sink(_))));
795 }
796
797 #[tokio::test]
798 async fn default_write_batch_partial_empty_returns_empty_vec() {
799 let sink = MockSink::new();
800 let outcomes = sink.write_batch_partial(&[]).await.unwrap();
801 assert!(outcomes.is_empty());
802 }
803
804 #[tokio::test]
805 async fn default_write_batch_partial_callable_through_trait_object() {
806 let sink: Box<dyn Sink> = Box::new(MockSink::new());
807 let records = vec![json!({"id": 1}), json!({"id": 2})];
808 let outcomes = sink.write_batch_partial(&records).await.unwrap();
809 assert_eq!(outcomes.len(), 2);
810 assert!(outcomes.iter().all(|o| o.is_ok()));
811 }
812
813 #[tokio::test]
816 async fn source_default_check_pulls_first_page_and_passes() {
817 let source = MockSource {
818 records: vec![json!({"id": 1}), json!({"id": 2})],
819 };
820 let report = source
821 .check(&crate::check::CheckContext::default())
822 .await
823 .unwrap();
824 assert_eq!(report.failed_count(), 0);
825 assert!(
826 report
827 .probes
828 .iter()
829 .any(|p| p.name == "read" && matches!(p.status, crate::check::ProbeStatus::Pass))
830 );
831 }
832
833 #[tokio::test]
834 async fn source_default_check_passes_on_empty_source() {
835 let source = MockSource { records: vec![] };
836 let report = source
837 .check(&crate::check::CheckContext::default())
838 .await
839 .unwrap();
840 assert_eq!(report.failed_count(), 0);
842 }
843
844 #[tokio::test]
845 async fn source_default_check_fails_when_fetch_errors() {
846 let source = FailingSource;
847 let report = source
848 .check(&crate::check::CheckContext::default())
849 .await
850 .unwrap();
851 assert_eq!(report.failed_count(), 1);
852 assert!(report.probes.iter().any(
853 |p| p.name == "read" && matches!(p.status, crate::check::ProbeStatus::Fail { .. })
854 ));
855 }
856
857 #[tokio::test]
858 async fn sink_default_check_is_not_implemented_skip() {
859 let sink = MockSink::new();
860 let report = sink
861 .check(&crate::check::CheckContext::default())
862 .await
863 .unwrap();
864 assert_eq!(report.probes.len(), 1);
865 assert!(matches!(
866 report.probes[0].status,
867 crate::check::ProbeStatus::Skip { .. }
868 ));
869 }
870
871 #[tokio::test]
872 async fn source_check_callable_through_trait_object() {
873 let source: Box<dyn Source> = Box::new(MockSource {
874 records: vec![json!({"id": 1})],
875 });
876 let report = source
877 .check(&crate::check::CheckContext::default())
878 .await
879 .unwrap();
880 assert_eq!(report.failed_count(), 0);
881 }
882
883 #[tokio::test]
886 async fn sink_default_is_not_idempotent() {
887 let sink = MockSink::new();
888 assert!(!sink.supports_idempotent_writes());
889 let n = sink
891 .write_batch_idempotent(&[json!({"id": 1})], "scope::a", "00000000000000000001")
892 .await
893 .unwrap();
894 assert_eq!(n, 1);
895 assert_eq!(sink.last_committed_token("scope::a").await.unwrap(), None);
896 assert_eq!(sink.written.lock().unwrap().len(), 1);
897 }
898
899 #[test]
900 fn source_default_does_not_support_exactly_once() {
901 let source = MockSource { records: vec![] };
902 assert!(!source.supports_exactly_once());
903 }
904
905 #[test]
906 fn sink_default_supported_write_modes_is_append_only() {
907 use crate::write_mode::WriteMode;
908 let sink = MockSink::new();
909 assert_eq!(sink.supported_write_modes(), &[WriteMode::Append]);
910 }
911
912 #[test]
913 fn supported_write_modes_callable_through_trait_object() {
914 use crate::write_mode::WriteMode;
915 let sink: Box<dyn Sink> = Box::new(MockSink::new());
916 assert!(sink.supported_write_modes().contains(&WriteMode::Append));
917 }
918
919 #[tokio::test]
920 async fn sink_default_current_schema_is_none() {
921 let sink = MockSink::new();
922 assert_eq!(sink.current_schema().await.unwrap(), None);
923 }
924
925 #[test]
926 fn sink_default_does_not_support_schema_evolution() {
927 let sink = MockSink::new();
928 assert!(!sink.supports_schema_evolution());
929 }
930
931 #[tokio::test]
932 async fn sink_default_evolve_schema_is_unsupported_error() {
933 let sink = MockSink::new();
934 let evo = crate::drift::SchemaEvolution::default();
935 let err = sink.evolve_schema(&evo).await.unwrap_err();
936 assert!(matches!(err, FaucetError::Sink(_)));
937 assert!(err.to_string().contains("schema evolution"));
938 }
939
940 #[tokio::test]
941 async fn source_default_capture_resume_position_is_none() {
942 let source = MockSource { records: vec![] };
943 assert_eq!(source.capture_resume_position().await.unwrap(), None);
944 }
945
946 #[tokio::test]
947 async fn capture_resume_position_callable_through_trait_object() {
948 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
949 assert!(source.capture_resume_position().await.unwrap().is_none());
950 }
951
952 #[tokio::test]
953 async fn source_default_is_not_shardable() {
954 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
955 assert!(!source.is_shardable());
956 }
957
958 #[tokio::test]
959 async fn source_default_enumerates_single_whole_shard() {
960 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
963 let shards = source.enumerate_shards(8).await.unwrap();
964 assert_eq!(shards.len(), 1);
965 assert!(shards[0].is_whole());
966 }
967
968 #[tokio::test]
969 async fn source_default_apply_shard_is_noop() {
970 let source: Box<dyn Source> = Box::new(MockSource { records: vec![] });
971 source
973 .apply_shard(&crate::shard::ShardSpec::whole())
974 .await
975 .unwrap();
976 }
977}