1use std::collections::{BTreeMap, HashMap};
4use std::sync::Arc;
5
6use arrow_schema::SchemaRef;
7use async_trait::async_trait;
8use parking_lot::RwLock;
9
10use crate::config::{ConnectorConfig, ConnectorInfo};
11use crate::connector::{SinkConnector, SourceConnector};
12use crate::error::ConnectorError;
13use crate::reference::ReferenceTableSource;
14use crate::serde::{self, Format, RecordDeserializer, RecordSerializer};
15
16pub type SourceFactory = Arc<
23 dyn Fn(Option<&Arc<prometheus::Registry>>) -> Result<Box<dyn SourceConnector>, ConnectorError>
24 + Send
25 + Sync,
26>;
27
28pub type SinkFactory = Arc<
37 dyn Fn(
38 &ConnectorConfig,
39 Option<&Arc<prometheus::Registry>>,
40 ) -> Result<Box<dyn SinkConnector>, ConnectorError>
41 + Send
42 + Sync,
43>;
44
45pub type TableSourceFactory = Arc<
47 dyn Fn(&ConnectorConfig, SchemaRef) -> Result<Box<dyn ReferenceTableSource>, ConnectorError>
48 + Send
49 + Sync,
50>;
51
52#[async_trait]
54pub trait LookupSourceFactory: Send + Sync {
55 async fn resolve_schema(
61 &self,
62 config: &ConnectorConfig,
63 explicit: Option<SchemaRef>,
64 ) -> Result<crate::schema::resolution::SchemaBinding, ConnectorError> {
65 let schema = explicit.ok_or_else(|| {
66 ConnectorError::FeatureUnsupported(
67 "lookup connector needs explicit fields; no metadata resolver is implemented"
68 .into(),
69 )
70 })?;
71 crate::schema::resolution::logical_binding(
72 config,
73 crate::schema::resolution::SchemaDirection::Source,
74 crate::schema::resolution::SchemaOrigin::Explicit,
75 &schema,
76 )
77 }
78
79 async fn build(
85 &self,
86 config: ConnectorConfig,
87 declared_schema: Option<arrow_schema::SchemaRef>,
88 ) -> Result<Arc<dyn laminar_core::lookup::source::LookupSourceDyn>, ConnectorError>;
89}
90
91type LookupSourceRegistration = (ConnectorInfo, Arc<dyn LookupSourceFactory>);
92
93#[derive(Clone)]
97pub struct ConnectorRegistry {
98 sources: Arc<RwLock<HashMap<String, (ConnectorInfo, SourceFactory)>>>,
99 sinks: Arc<RwLock<HashMap<String, (ConnectorInfo, SinkFactory)>>>,
100 table_sources: Arc<RwLock<HashMap<String, (ConnectorInfo, TableSourceFactory)>>>,
101 lookup_sources: Arc<RwLock<HashMap<String, LookupSourceRegistration>>>,
102 frozen: Arc<RwLock<bool>>,
105 resolution_slots: Arc<tokio::sync::Semaphore>,
106 resolution_metrics:
107 Arc<RwLock<Option<Arc<crate::schema::resolution_metrics::ResolutionMetrics>>>>,
108}
109
110impl ConnectorRegistry {
111 #[must_use]
113 pub fn new() -> Self {
114 Self {
115 sources: Arc::new(RwLock::new(HashMap::new())),
116 sinks: Arc::new(RwLock::new(HashMap::new())),
117 table_sources: Arc::new(RwLock::new(HashMap::new())),
118 lookup_sources: Arc::new(RwLock::new(HashMap::new())),
119 frozen: Arc::new(RwLock::new(false)),
120 resolution_slots: Arc::new(tokio::sync::Semaphore::new(8)),
121 resolution_metrics: Arc::new(RwLock::new(None)),
122 }
123 }
124
125 pub fn register_schema_resolution_metrics(
130 &self,
131 registry: &prometheus::Registry,
132 ) -> Result<(), ConnectorError> {
133 let mut slot = self.resolution_metrics.write();
134 if slot.is_some() {
135 return Err(ConnectorError::Internal(
136 "schema resolution metrics are already installed".into(),
137 ));
138 }
139 *slot = Some(crate::schema::resolution_metrics::ResolutionMetrics::register(registry)?);
140 Ok(())
141 }
142
143 async fn resolve_guarded<F>(
144 &self,
145 direction: &'static str,
146 config: &ConnectorConfig,
147 requested: Option<SchemaRef>,
148 work: F,
149 ) -> Result<crate::schema::resolution::SchemaBinding, ConnectorError>
150 where
151 F: std::future::Future<
152 Output = Result<crate::schema::resolution::SchemaBinding, ConnectorError>,
153 >,
154 {
155 let observation = crate::schema::resolution_metrics::ResolutionObservation::new(
156 self.resolution_metrics.read().clone(),
157 direction,
158 );
159 let result = async {
160 let expected = if direction == "sink" {
161 crate::schema::resolution::SchemaDirection::Sink
162 } else {
163 crate::schema::resolution::SchemaDirection::Source
164 };
165 if let Some(schema) = &requested {
166 crate::schema::resolution::logical_binding(
167 config,
168 expected,
169 crate::schema::resolution::SchemaOrigin::Explicit,
170 schema,
171 )?;
172 }
173 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30);
174 let _permit = tokio::time::timeout_at(deadline, self.resolution_slots.acquire())
175 .await
176 .map_err(|_| ConnectorError::Timeout(30_000))?
177 .map_err(|_| {
178 ConnectorError::Internal("schema resolution admission is closed".into())
179 })?;
180 let binding = tokio::time::timeout_at(deadline, work)
181 .await
182 .map_err(|_| ConnectorError::Timeout(30_000))??;
183 if binding.direction != expected || binding.connector != config.connector_type() {
184 return Err(ConnectorError::SchemaMismatch(format!(
185 "{} {direction} resolver returned a contract for a different connector or direction",
186 config.connector_type()
187 )));
188 }
189 if let Some(requested) = requested {
190 let fields = binding.logical.fields().get(..requested.fields().len());
191 if fields != Some(requested.fields().as_ref())
192 || binding.logical.metadata() != requested.metadata()
193 || (expected == crate::schema::resolution::SchemaDirection::Sink
194 && binding.logical != *requested)
195 {
196 return Err(ConnectorError::SchemaMismatch(format!(
197 "{} {direction} resolver changed declared fields or the bound query schema",
198 config.connector_type()
199 )));
200 }
201 }
202 binding
203 .canonical_bytes()
204 .map_err(crate::schema::resolution::binding_error)?;
205 Ok(binding)
206 }
207 .await;
208 if result.is_err() {
209 tracing::warn!(
210 direction,
211 stage = "schema-resolution",
212 "connector contract resolution failed"
213 );
214 }
215 observation.finish(result)
216 }
217
218 pub fn freeze(&self) {
223 *self.frozen.write() = true;
224 }
225
226 #[must_use]
228 pub fn is_frozen(&self) -> bool {
229 *self.frozen.read()
230 }
231
232 pub fn register_source(
238 &self,
239 name: impl Into<String>,
240 info: ConnectorInfo,
241 factory: SourceFactory,
242 ) -> Result<(), ConnectorError> {
243 let name = name.into();
244 let frozen = self.frozen.read();
245 if *frozen {
246 return Err(ConnectorError::RegistryFrozen {
247 kind: "source",
248 name,
249 });
250 }
251 let mut sources = self.sources.write();
252 if sources.contains_key(&name) {
253 return Err(ConnectorError::FactoryAlreadyRegistered {
254 kind: "source",
255 name,
256 });
257 }
258 sources.insert(name, (info, factory));
259 drop(frozen);
260 Ok(())
261 }
262
263 pub fn register_sink(
269 &self,
270 name: impl Into<String>,
271 info: ConnectorInfo,
272 factory: SinkFactory,
273 ) -> Result<(), ConnectorError> {
274 let name = name.into();
275 let frozen = self.frozen.read();
276 if *frozen {
277 return Err(ConnectorError::RegistryFrozen { kind: "sink", name });
278 }
279 let mut sinks = self.sinks.write();
280 if sinks.contains_key(&name) {
281 return Err(ConnectorError::FactoryAlreadyRegistered { kind: "sink", name });
282 }
283 sinks.insert(name, (info, factory));
284 drop(frozen);
285 Ok(())
286 }
287
288 pub async fn default_source_schema(
303 &self,
304 connector_type: &str,
305 properties: &std::collections::HashMap<String, String>,
306 ) -> Result<Option<SchemaRef>, ConnectorError> {
307 let factory = {
308 let sources = self.sources.read();
309 let Some((_, factory)) = sources.get(connector_type) else {
310 return Ok(None);
311 };
312 factory.clone()
313 };
314
315 let mut instance = factory(None)?;
316 instance.discover_schema(properties).await?;
317 let schema = instance.schema();
318 Ok((!schema.fields().is_empty()).then_some(schema))
319 }
320
321 pub async fn resolve_source_schema(
326 &self,
327 config: &ConnectorConfig,
328 explicit: Option<SchemaRef>,
329 ) -> Result<crate::schema::resolution::SchemaBinding, ConnectorError> {
330 self.resolve_guarded("source", config, explicit.clone(), async {
331 let info = self.source_info(config.connector_type()).ok_or_else(|| {
332 ConnectorError::ConfigurationError("source factory is not registered".into())
333 })?;
334 info.schema_capabilities.validate_native_format(config)?;
335 if explicit.is_none()
336 && info.schema_capabilities.discovery
337 == crate::schema::resolution::SchemaDiscovery::Explicit
338 && !info.schema_capabilities.built_in_formats.iter().any(|format| {
339 format.eq_ignore_ascii_case(config.get("format").unwrap_or("native"))
340 })
341 {
342 return Err(ConnectorError::FeatureUnsupported(format!(
343 "{} source format '{}': authoritative schema discovery is unavailable; declare columns explicitly",
344 config.connector_type(), config.get("format").unwrap_or("native")
345 )));
346 }
347 let mut source = self.create_source(config, None)?;
348 let mut binding = source.resolve_schema(config, explicit).await?;
349 if binding.origin == crate::schema::resolution::SchemaOrigin::BuiltIn
350 && info.schema_capabilities.discovery == crate::schema::resolution::SchemaDiscovery::Metadata
351 && !info.schema_capabilities.built_in_formats.iter().any(|format| {
352 format.eq_ignore_ascii_case(config.get("format").unwrap_or("native"))
353 })
354 {
355 binding.origin = crate::schema::resolution::SchemaOrigin::Metadata;
356 }
357 Ok(binding)
358 }).await
359 }
360
361 pub async fn resolve_sink_schema(
366 &self,
367 config: &ConnectorConfig,
368 input: SchemaRef,
369 ) -> Result<crate::schema::resolution::SchemaBinding, ConnectorError> {
370 self.resolve_guarded("sink", config, Some(Arc::clone(&input)), async {
371 self.sink_info(config.connector_type())
372 .ok_or_else(|| {
373 ConnectorError::ConfigurationError("sink factory is not registered".into())
374 })?
375 .schema_capabilities
376 .validate_native_format(config)?;
377 let mut sink = self.create_sink(config, None)?;
378 sink.resolve_schema(config, input).await
379 })
380 .await
381 }
382
383 pub fn create_source(
396 &self,
397 config: &ConnectorConfig,
398 registry: Option<&Arc<prometheus::Registry>>,
399 ) -> Result<Box<dyn SourceConnector>, ConnectorError> {
400 let factory = {
401 let sources = self.sources.read();
402 let (_, factory) = sources.get(config.connector_type()).ok_or_else(|| {
403 ConnectorError::ConfigurationError(format!(
404 "unknown source connector type: '{}'",
405 config.connector_type()
406 ))
407 })?;
408 Arc::clone(factory)
409 };
410 factory(registry)
411 }
412
413 pub fn source_recovery_identity_options(
424 &self,
425 config: &ConnectorConfig,
426 ) -> Result<Option<BTreeMap<String, String>>, ConnectorError> {
427 self.create_source(config, None)?
428 .recovery_identity_options(config)
429 }
430
431 pub fn source_recency(
439 &self,
440 config: &ConnectorConfig,
441 ) -> Result<Option<Arc<dyn crate::connector::SourceRecency>>, ConnectorError> {
442 self.create_source(config, None)?.recency(config)
443 }
444
445 pub fn create_sink(
457 &self,
458 config: &ConnectorConfig,
459 registry: Option<&Arc<prometheus::Registry>>,
460 ) -> Result<Box<dyn SinkConnector>, ConnectorError> {
461 let factory = {
462 let sinks = self.sinks.read();
463 let (_, factory) = sinks.get(config.connector_type()).ok_or_else(|| {
464 ConnectorError::ConfigurationError(format!(
465 "unknown sink connector type: '{}'",
466 config.connector_type()
467 ))
468 })?;
469 Arc::clone(factory)
470 };
471 factory(config, registry)
472 }
473
474 pub fn register_table_source(
480 &self,
481 name: impl Into<String>,
482 info: ConnectorInfo,
483 factory: TableSourceFactory,
484 ) -> Result<(), ConnectorError> {
485 let name = name.into();
486 let frozen = self.frozen.read();
487 if *frozen {
488 return Err(ConnectorError::RegistryFrozen {
489 kind: "table source",
490 name,
491 });
492 }
493 let mut table_sources = self.table_sources.write();
494 if table_sources.contains_key(&name) {
495 return Err(ConnectorError::FactoryAlreadyRegistered {
496 kind: "table source",
497 name,
498 });
499 }
500 table_sources.insert(name, (info, factory));
501 drop(frozen);
502 Ok(())
503 }
504
505 pub async fn resolve_table_schema(
510 &self,
511 config: &ConnectorConfig,
512 explicit: Option<SchemaRef>,
513 ) -> Result<crate::schema::resolution::SchemaBinding, ConnectorError> {
514 self.resolve_guarded("reference", config, explicit.clone(), async {
515 let info = self.table_sources.read()
516 .get(config.connector_type()).map(|(info, _)| info.clone())
517 .ok_or_else(|| ConnectorError::ConfigurationError(format!(
518 "connector type '{}' has no registered snapshot-capable table source (reference-table source)",
519 config.connector_type()
520 )))?;
521 info.schema_capabilities.validate_native_format(config)?;
522 if info.schema_capabilities.discovery == crate::schema::resolution::SchemaDiscovery::Explicit {
523 let schema = explicit.ok_or_else(|| ConnectorError::FeatureUnsupported(
524 "reference-table discovery is unsupported; declare explicit fields".into()
525 ))?;
526 return crate::schema::resolution::logical_binding(
527 config, crate::schema::resolution::SchemaDirection::Source,
528 crate::schema::resolution::SchemaOrigin::Explicit, &schema
529 );
530 }
531 let mut source = self.create_table_source(
532 config,
533 explicit
534 .clone()
535 .unwrap_or_else(|| Arc::new(arrow_schema::Schema::empty())),
536 )?;
537 source.resolve_schema(config, explicit).await
538 })
539 .await
540 }
541
542 pub async fn resolve_lookup_schema(
547 &self,
548 config: &ConnectorConfig,
549 explicit: Option<SchemaRef>,
550 ) -> Result<crate::schema::resolution::SchemaBinding, ConnectorError> {
551 let (info, factory) = self
552 .lookup_sources
553 .read()
554 .get(config.connector_type())
555 .map(|(info, factory)| (info.clone(), Arc::clone(factory)))
556 .ok_or_else(|| {
557 ConnectorError::ConfigurationError("lookup factory is not registered".into())
558 })?;
559 if self.has_table_source(config.connector_type()) {
560 return self.resolve_table_schema(config, explicit).await;
561 }
562 info.schema_capabilities.validate_native_format(config)?;
563 self.resolve_guarded(
564 "lookup",
565 config,
566 explicit.clone(),
567 factory.resolve_schema(config, explicit),
568 )
569 .await
570 }
571
572 pub fn create_table_source(
582 &self,
583 config: &ConnectorConfig,
584 declared_schema: SchemaRef,
585 ) -> Result<Box<dyn ReferenceTableSource>, ConnectorError> {
586 let factory = {
587 let table_sources = self.table_sources.read();
588 let (_, factory) = table_sources.get(config.connector_type()).ok_or_else(|| {
589 ConnectorError::ConfigurationError(format!(
590 "connector type '{}' has no registered snapshot-capable table source (reference-table source)",
591 config.connector_type()
592 ))
593 })?;
594 Arc::clone(factory)
595 };
596 factory(config, declared_schema)
597 }
598
599 #[must_use]
601 pub fn list_table_sources(&self) -> Vec<String> {
602 let mut names: Vec<_> = self.table_sources.read().keys().cloned().collect();
603 names.sort_unstable();
604 names
605 }
606
607 #[must_use]
609 pub fn has_table_source(&self, name: &str) -> bool {
610 self.table_sources.read().contains_key(name)
611 }
612
613 pub fn register_lookup_source(
619 &self,
620 name: impl Into<String>,
621 info: ConnectorInfo,
622 factory: Arc<dyn LookupSourceFactory>,
623 ) -> Result<(), ConnectorError> {
624 let name = name.into();
625 let frozen = self.frozen.read();
626 if *frozen {
627 return Err(ConnectorError::RegistryFrozen {
628 kind: "lookup source",
629 name,
630 });
631 }
632 let mut lookup_sources = self.lookup_sources.write();
633 if lookup_sources.contains_key(&name) {
634 return Err(ConnectorError::FactoryAlreadyRegistered {
635 kind: "lookup source",
636 name,
637 });
638 }
639 lookup_sources.insert(name, (info, factory));
640 drop(frozen);
641 Ok(())
642 }
643
644 #[must_use]
646 pub fn has_lookup_source(&self, name: &str) -> bool {
647 self.lookup_sources.read().contains_key(name)
648 }
649
650 pub async fn create_lookup_source(
655 &self,
656 config: ConnectorConfig,
657 declared_schema: Option<arrow_schema::SchemaRef>,
658 ) -> Option<Result<Arc<dyn laminar_core::lookup::source::LookupSourceDyn>, ConnectorError>>
659 {
660 let factory = {
661 let lookup_sources = self.lookup_sources.read();
662 Arc::clone(&lookup_sources.get(config.connector_type())?.1)
663 };
664 Some(
665 async {
666 let binding = match config.schema_binding() {
667 Some(binding) => binding.clone(),
668 None => {
669 self.resolve_lookup_schema(&config, declared_schema.clone())
670 .await?
671 }
672 };
673 let source = factory
674 .build(config.clone(), Some(Arc::new(binding.logical.clone())))
675 .await?;
676 let bound = crate::schema::lookup_binding::BoundLookup::new(
677 source,
678 self.clone(),
679 config,
680 binding,
681 )?;
682 Ok(Arc::new(bound) as Arc<dyn laminar_core::lookup::source::LookupSourceDyn>)
683 }
684 .await,
685 )
686 }
687
688 #[must_use]
690 pub fn source_info(&self, name: &str) -> Option<ConnectorInfo> {
691 self.sources.read().get(name).map(|(info, _)| info.clone())
692 }
693
694 #[must_use]
696 pub fn sink_info(&self, name: &str) -> Option<ConnectorInfo> {
697 self.sinks.read().get(name).map(|(info, _)| info.clone())
698 }
699
700 #[must_use]
702 pub fn list_sources(&self) -> Vec<String> {
703 let mut names: Vec<_> = self.sources.read().keys().cloned().collect();
704 names.sort_unstable();
705 names
706 }
707
708 #[must_use]
710 pub fn list_sinks(&self) -> Vec<String> {
711 let mut names: Vec<_> = self.sinks.read().keys().cloned().collect();
712 names.sort_unstable();
713 names
714 }
715
716 pub fn create_deserializer(
722 &self,
723 format: &str,
724 ) -> Result<Box<dyn RecordDeserializer>, ConnectorError> {
725 let fmt = Format::parse(format).map_err(ConnectorError::Serde)?;
726 serde::create_deserializer(fmt).map_err(ConnectorError::Serde)
727 }
728
729 pub fn create_serializer(
735 &self,
736 format: &str,
737 ) -> Result<Box<dyn RecordSerializer>, ConnectorError> {
738 let fmt = Format::parse(format).map_err(ConnectorError::Serde)?;
739 serde::create_serializer(fmt).map_err(ConnectorError::Serde)
740 }
741}
742
743impl Default for ConnectorRegistry {
744 fn default() -> Self {
745 Self::new()
746 }
747}
748
749impl std::fmt::Debug for ConnectorRegistry {
750 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
751 f.debug_struct("ConnectorRegistry")
752 .field("sources", &self.list_sources())
753 .field("sinks", &self.list_sinks())
754 .field("table_sources", &self.list_table_sources())
755 .finish()
756 }
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762 use crate::reference::MockReferenceTableSource;
763 use crate::testing::*;
764
765 fn mock_info(name: &str, is_source: bool, is_sink: bool) -> ConnectorInfo {
766 ConnectorInfo {
767 schema_capabilities: crate::schema::resolution::SchemaCapabilities::declared(false),
768 name: name.to_string(),
769 display_name: name.to_string(),
770 version: "0.1.0".to_string(),
771 is_source,
772 is_sink,
773 config_keys: vec![],
774 }
775 }
776
777 fn declared_schema() -> SchemaRef {
778 Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new(
779 "id",
780 arrow_schema::DataType::Int64,
781 false,
782 )]))
783 }
784
785 #[tokio::test]
786 async fn malformed_declared_contracts_fail_before_polling_metadata_work() {
787 let registry = ConnectorRegistry::new();
788 let config = ConnectorConfig::new("custom");
789 let malformed = Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new(
790 "bad",
791 arrow_schema::DataType::Decimal128(0, 0),
792 false,
793 )]));
794 let polled = std::sync::atomic::AtomicBool::new(false);
795 let error = registry
796 .resolve_guarded("source", &config, Some(malformed), async {
797 polled.store(true, std::sync::atomic::Ordering::SeqCst);
798 unreachable!("invalid Arrow metadata must fail before connector I/O")
799 })
800 .await
801 .unwrap_err();
802 assert!(!polled.load(std::sync::atomic::Ordering::SeqCst));
803 assert!(matches!(error, ConnectorError::SchemaMismatch(_)));
804 assert_eq!(registry.resolution_slots.available_permits(), 8);
805 }
806
807 #[tokio::test]
808 async fn custom_resolvers_cannot_return_another_connector_or_direction() {
809 use crate::schema::resolution::{SchemaBinding, SchemaDirection, SchemaOrigin};
810 let registry = ConnectorRegistry::new();
811 let config = ConnectorConfig::new("custom");
812 for (connector, direction) in [
813 ("custom", SchemaDirection::Sink),
814 ("other", SchemaDirection::Source),
815 ] {
816 let binding = SchemaBinding::logical(
817 connector,
818 direction,
819 SchemaOrigin::Explicit,
820 declared_schema().as_ref().clone(),
821 )
822 .unwrap();
823 let error = registry
824 .resolve_guarded("source", &config, None, async { Ok(binding) })
825 .await
826 .unwrap_err();
827 assert!(matches!(error, ConnectorError::SchemaMismatch(_)));
828 }
829 }
830
831 #[tokio::test]
832 async fn custom_resolvers_preserve_declared_fields_and_the_entire_sink_query() {
833 use crate::schema::resolution::{SchemaBinding, SchemaDirection, SchemaOrigin};
834 let registry = ConnectorRegistry::new();
835 let config = ConnectorConfig::new("custom");
836 let requested = declared_schema();
837 for direction in [SchemaDirection::Source, SchemaDirection::Sink] {
838 let changed = arrow_schema::Schema::new(vec![arrow_schema::Field::new(
839 "id",
840 arrow_schema::DataType::Utf8,
841 false,
842 )]);
843 let binding =
844 SchemaBinding::logical("custom", direction, SchemaOrigin::Explicit, changed)
845 .unwrap();
846 let label = if direction == SchemaDirection::Sink {
847 "sink"
848 } else {
849 "source"
850 };
851 let error = registry
852 .resolve_guarded(label, &config, Some(requested.clone()), async {
853 Ok(binding)
854 })
855 .await
856 .unwrap_err();
857 assert!(error.to_string().contains("declared fields"));
858 }
859 let extended = arrow_schema::Schema::new(vec![
860 requested.field(0).clone(),
861 arrow_schema::Field::new("extra", arrow_schema::DataType::Boolean, true),
862 ]);
863 let binding = SchemaBinding::logical(
864 "custom",
865 SchemaDirection::Sink,
866 SchemaOrigin::Query,
867 extended,
868 )
869 .unwrap();
870 assert!(registry
871 .resolve_guarded("sink", &config, Some(requested), async { Ok(binding) })
872 .await
873 .is_err());
874 }
875
876 #[tokio::test]
877 async fn cancelling_resolution_releases_its_global_slot_and_metric() {
878 let registry = ConnectorRegistry::new();
879 let metrics = prometheus::Registry::new();
880 registry
881 .register_schema_resolution_metrics(&metrics)
882 .unwrap();
883 let config = ConnectorConfig::new("custom");
884 let started = Arc::new(tokio::sync::Notify::new());
885 let notification = started.clone();
886 {
887 let future = registry.resolve_guarded("source", &config, None, async {
888 notification.notify_one();
889 std::future::pending().await
890 });
891 tokio::pin!(future);
892 tokio::select! { result = &mut future => panic!("unexpected result: {result:?}"), () = started.notified() => {} }
893 assert_eq!(registry.resolution_slots.available_permits(), 7);
894 }
895 assert_eq!(registry.resolution_slots.available_permits(), 8);
896 let active = metrics
897 .gather()
898 .into_iter()
899 .find(|family| family.name() == "laminar_schema_resolution_active")
900 .unwrap();
901 assert_eq!(active.get_metric()[0].get_gauge().value(), 0.0);
902 }
903
904 #[test]
905 fn test_register_and_create_source() {
906 let registry = ConnectorRegistry::new();
907 registry
908 .register_source(
909 "mock",
910 mock_info("mock", true, false),
911 Arc::new(|_: Option<&Arc<prometheus::Registry>>| {
912 Ok(Box::new(MockSourceConnector::new()))
913 }),
914 )
915 .unwrap();
916
917 let config = ConnectorConfig::new("mock");
918 let connector = registry.create_source(&config, None);
919 assert!(connector.is_ok());
920 assert_eq!(
921 registry.source_recovery_identity_options(&config).unwrap(),
922 None
923 );
924 }
925
926 #[test]
927 fn test_register_and_create_sink() {
928 let registry = ConnectorRegistry::new();
929 registry
930 .register_sink(
931 "mock",
932 mock_info("mock", false, true),
933 Arc::new(|_config, _registry| Ok(Box::new(MockSinkConnector::new()))),
934 )
935 .unwrap();
936
937 let config = ConnectorConfig::new("mock");
938 let connector = registry.create_sink(&config, None);
939 assert!(connector.is_ok());
940 }
941
942 #[test]
943 fn factories_receive_shared_registry_identity() {
944 let connectors = ConnectorRegistry::new();
945 let metrics = Arc::new(prometheus::Registry::new());
946
947 let source_metrics = Arc::clone(&metrics);
948 connectors
949 .register_source(
950 "identity-source",
951 mock_info("identity-source", true, false),
952 Arc::new(move |registry: Option<&Arc<prometheus::Registry>>| {
953 assert!(
954 registry.is_some_and(|registry| { Arc::ptr_eq(registry, &source_metrics) })
955 );
956 Ok(Box::new(MockSourceConnector::new()))
957 }),
958 )
959 .unwrap();
960
961 let sink_metrics = Arc::clone(&metrics);
962 connectors
963 .register_sink(
964 "identity-sink",
965 mock_info("identity-sink", false, true),
966 Arc::new(
967 move |_config, registry: Option<&Arc<prometheus::Registry>>| {
968 assert!(registry
969 .is_some_and(|registry| { Arc::ptr_eq(registry, &sink_metrics) }));
970 Ok(Box::new(MockSinkConnector::new()))
971 },
972 ),
973 )
974 .unwrap();
975
976 connectors
977 .create_source(&ConnectorConfig::new("identity-source"), Some(&metrics))
978 .expect("source factory must receive the shared registry Arc");
979 connectors
980 .create_sink(&ConnectorConfig::new("identity-sink"), Some(&metrics))
981 .expect("sink factory must receive the shared registry Arc");
982 }
983
984 struct RejectLookupFactory;
985
986 #[async_trait]
987 impl LookupSourceFactory for RejectLookupFactory {
988 async fn build(
989 &self,
990 _config: ConnectorConfig,
991 _declared_schema: Option<SchemaRef>,
992 ) -> Result<Arc<dyn laminar_core::lookup::source::LookupSourceDyn>, ConnectorError>
993 {
994 Err(ConnectorError::ConfigurationError(
995 "test lookup factory has no backing source".into(),
996 ))
997 }
998 }
999
1000 #[test]
1001 fn sink_factory_receives_config_and_propagates_validation_errors() {
1002 let registry = ConnectorRegistry::new();
1003 registry
1004 .register_sink(
1005 "validated",
1006 mock_info("validated", false, true),
1007 Arc::new(|config, _registry| {
1008 if config.get("enabled") == Some("true") {
1009 Ok(Box::new(MockSinkConnector::new()))
1010 } else {
1011 Err(ConnectorError::ConfigurationError(
1012 "validated sink requires enabled = true".into(),
1013 ))
1014 }
1015 }),
1016 )
1017 .unwrap();
1018
1019 let mut config = ConnectorConfig::new("validated");
1020 let error = match registry.create_sink(&config, None) {
1021 Ok(_) => panic!("expected factory validation error"),
1022 Err(error) => error,
1023 };
1024 assert!(error.to_string().contains("enabled = true"));
1025
1026 config.set("enabled", "true");
1027 assert!(registry.create_sink(&config, None).is_ok());
1028 }
1029
1030 #[test]
1031 fn test_create_unknown_connector() {
1032 let registry = ConnectorRegistry::new();
1033 let config = ConnectorConfig::new("nonexistent");
1034
1035 assert!(registry.create_source(&config, None).is_err());
1036 assert!(registry.source_recovery_identity_options(&config).is_err());
1037 assert!(registry.create_sink(&config, None).is_err());
1038 }
1039
1040 #[test]
1041 fn test_list_connectors() {
1042 let registry = ConnectorRegistry::new();
1043 registry
1044 .register_source(
1045 "kafka",
1046 mock_info("kafka", true, false),
1047 Arc::new(|_: Option<&Arc<prometheus::Registry>>| {
1048 Ok(Box::new(MockSourceConnector::new()))
1049 }),
1050 )
1051 .unwrap();
1052 registry
1053 .register_sink(
1054 "delta",
1055 mock_info("delta", false, true),
1056 Arc::new(|_config, _registry| Ok(Box::new(MockSinkConnector::new()))),
1057 )
1058 .unwrap();
1059
1060 let sources = registry.list_sources();
1061 assert_eq!(sources.len(), 1);
1062 assert!(sources.contains(&"kafka".to_string()));
1063
1064 let sinks = registry.list_sinks();
1065 assert_eq!(sinks.len(), 1);
1066 assert!(sinks.contains(&"delta".to_string()));
1067 }
1068
1069 #[test]
1070 fn test_connector_info() {
1071 let registry = ConnectorRegistry::new();
1072 registry
1073 .register_source(
1074 "kafka",
1075 mock_info("kafka", true, false),
1076 Arc::new(|_: Option<&Arc<prometheus::Registry>>| {
1077 Ok(Box::new(MockSourceConnector::new()))
1078 }),
1079 )
1080 .unwrap();
1081
1082 let info = registry.source_info("kafka");
1083 assert!(info.is_some());
1084 assert_eq!(info.unwrap().name, "kafka");
1085
1086 assert!(registry.source_info("nonexistent").is_none());
1087 }
1088
1089 #[test]
1090 fn test_format_registry() {
1091 let registry = ConnectorRegistry::new();
1092
1093 assert!(registry.create_deserializer("json").is_ok());
1094 assert!(registry.create_serializer("csv").is_ok());
1095 assert!(registry.create_deserializer("unknown").is_err());
1096 }
1097
1098 #[tokio::test]
1099 async fn default_source_schema_some_when_discovered() {
1100 let registry = ConnectorRegistry::new();
1101 registry
1102 .register_source(
1103 "mock",
1104 mock_info("mock", true, false),
1105 Arc::new(|_: Option<&Arc<prometheus::Registry>>| {
1106 Ok(Box::new(MockSourceConnector::new()))
1107 }),
1108 )
1109 .unwrap();
1110 let schema = registry
1111 .default_source_schema("mock", &std::collections::HashMap::new())
1112 .await
1113 .expect("discovery must not fail");
1114 assert!(schema.is_some_and(|s| !s.fields().is_empty()));
1115 }
1116
1117 #[tokio::test]
1118 async fn default_source_schema_none_for_unknown_connector() {
1119 let registry = ConnectorRegistry::new();
1120 assert!(registry
1121 .default_source_schema("nope", &std::collections::HashMap::new())
1122 .await
1123 .expect("unknown connector is Ok(None), not Err")
1124 .is_none());
1125 }
1126
1127 #[tokio::test]
1128 async fn source_factory_errors_propagate_from_creation_and_discovery() {
1129 let registry = ConnectorRegistry::new();
1130 registry
1131 .register_source(
1132 "failing",
1133 mock_info("failing", true, false),
1134 Arc::new(
1135 |_: Option<&Arc<prometheus::Registry>>| -> Result<
1136 Box<dyn SourceConnector>,
1137 ConnectorError,
1138 > {
1139 Err(ConnectorError::Internal("source construction failed".into()))
1140 },
1141 ),
1142 )
1143 .unwrap();
1144
1145 let config = ConnectorConfig::new("failing");
1146 let Err(create_error) = registry.create_source(&config, None) else {
1147 panic!("source construction must fail");
1148 };
1149 assert!(create_error
1150 .to_string()
1151 .contains("source construction failed"));
1152
1153 let discovery_error = registry
1154 .default_source_schema("failing", &std::collections::HashMap::new())
1155 .await
1156 .expect_err("schema discovery must propagate source construction failure");
1157 assert!(discovery_error
1158 .to_string()
1159 .contains("source construction failed"));
1160 }
1161
1162 #[test]
1165 fn test_register_and_create_table_source() {
1166 use crate::reference::MockReferenceTableSource;
1167
1168 let registry = ConnectorRegistry::new();
1169 let observed_schema = Arc::new(parking_lot::Mutex::new(None));
1170 let factory_schema = Arc::clone(&observed_schema);
1171 registry
1172 .register_table_source(
1173 "mock",
1174 mock_info("mock", true, false),
1175 Arc::new(move |_config, declared_schema| {
1176 *factory_schema.lock() = Some(declared_schema);
1177 Ok(Box::new(MockReferenceTableSource::empty()))
1178 }),
1179 )
1180 .unwrap();
1181
1182 let config = ConnectorConfig::new("mock");
1183 let declared_schema = declared_schema();
1184 let source = registry.create_table_source(&config, Arc::clone(&declared_schema));
1185 assert!(source.is_ok());
1186 assert_eq!(observed_schema.lock().as_ref(), Some(&declared_schema));
1187 assert!(registry.has_table_source("mock"));
1188 assert!(!registry.has_table_source("missing"));
1189 }
1190
1191 #[test]
1192 fn test_create_unknown_table_source() {
1193 let registry = ConnectorRegistry::new();
1194 let config = ConnectorConfig::new("nonexistent");
1195 let result = registry.create_table_source(&config, declared_schema());
1196 match result {
1197 Err(e) => assert!(
1198 e.to_string().contains("snapshot-capable table source"),
1199 "got: {e}"
1200 ),
1201 Ok(_) => panic!("Expected error for unknown table source"),
1202 }
1203 }
1204
1205 #[test]
1206 fn test_list_table_sources() {
1207 let registry = ConnectorRegistry::new();
1208 assert!(registry.list_table_sources().is_empty());
1209
1210 registry
1211 .register_table_source(
1212 "mock-table",
1213 mock_info("mock-table", true, false),
1214 Arc::new(|_config, _declared_schema| {
1215 Ok(Box::new(MockReferenceTableSource::empty()))
1216 }),
1217 )
1218 .unwrap();
1219
1220 let names = registry.list_table_sources();
1221 assert_eq!(names.len(), 1);
1222 assert!(names.contains(&"mock-table".to_string()));
1223 }
1224
1225 #[test]
1226 fn duplicate_registration_is_rejected_in_every_category() {
1227 let registry = ConnectorRegistry::new();
1228 let source = || {
1229 Arc::new(|_: Option<&Arc<prometheus::Registry>>| {
1230 Ok(Box::new(MockSourceConnector::new()) as Box<dyn SourceConnector>)
1231 }) as SourceFactory
1232 };
1233 let sink = || {
1234 Arc::new(
1235 |_config: &ConnectorConfig, _registry: Option<&Arc<prometheus::Registry>>| {
1236 Ok(Box::new(MockSinkConnector::new()) as Box<dyn SinkConnector>)
1237 },
1238 ) as SinkFactory
1239 };
1240 let table = || {
1241 Arc::new(|_config: &ConnectorConfig, _declared_schema: SchemaRef| {
1242 Ok(Box::new(MockReferenceTableSource::empty()) as Box<dyn ReferenceTableSource>)
1243 }) as TableSourceFactory
1244 };
1245
1246 registry
1247 .register_source("same", mock_info("same", true, false), source())
1248 .unwrap();
1249 assert!(matches!(
1250 registry.register_source("same", mock_info("same", true, false), source()),
1251 Err(ConnectorError::FactoryAlreadyRegistered { kind: "source", .. })
1252 ));
1253
1254 registry
1255 .register_sink("same", mock_info("same", false, true), sink())
1256 .unwrap();
1257 assert!(matches!(
1258 registry.register_sink("same", mock_info("same", false, true), sink()),
1259 Err(ConnectorError::FactoryAlreadyRegistered { kind: "sink", .. })
1260 ));
1261
1262 registry
1263 .register_table_source("same", mock_info("same", true, false), table())
1264 .unwrap();
1265 assert!(matches!(
1266 registry.register_table_source("same", mock_info("same", true, false), table()),
1267 Err(ConnectorError::FactoryAlreadyRegistered {
1268 kind: "table source",
1269 ..
1270 })
1271 ));
1272
1273 registry
1274 .register_lookup_source(
1275 "same",
1276 mock_info("same", true, false),
1277 Arc::new(RejectLookupFactory),
1278 )
1279 .unwrap();
1280 assert!(matches!(
1281 registry.register_lookup_source(
1282 "same",
1283 mock_info("same", true, false),
1284 Arc::new(RejectLookupFactory)
1285 ),
1286 Err(ConnectorError::FactoryAlreadyRegistered {
1287 kind: "lookup source",
1288 ..
1289 })
1290 ));
1291 }
1292
1293 #[test]
1294 fn freeze_rejects_every_registration_category() {
1295 let registry = ConnectorRegistry::new();
1296 registry.freeze();
1297 registry.freeze();
1298 assert!(registry.is_frozen());
1299
1300 assert!(matches!(
1301 registry.register_source(
1302 "late-source",
1303 mock_info("late-source", true, false),
1304 Arc::new(|_: Option<&Arc<prometheus::Registry>>| {
1305 Ok(Box::new(MockSourceConnector::new()))
1306 })
1307 ),
1308 Err(ConnectorError::RegistryFrozen { kind: "source", .. })
1309 ));
1310 assert!(matches!(
1311 registry.register_sink(
1312 "late-sink",
1313 mock_info("late-sink", false, true),
1314 Arc::new(|_config, _registry| Ok(Box::new(MockSinkConnector::new())))
1315 ),
1316 Err(ConnectorError::RegistryFrozen { kind: "sink", .. })
1317 ));
1318 assert!(matches!(
1319 registry.register_table_source(
1320 "late-table",
1321 mock_info("late-table", true, false),
1322 Arc::new(|_config, _declared_schema| {
1323 Ok(Box::new(MockReferenceTableSource::empty()))
1324 })
1325 ),
1326 Err(ConnectorError::RegistryFrozen {
1327 kind: "table source",
1328 ..
1329 })
1330 ));
1331 assert!(matches!(
1332 registry.register_lookup_source(
1333 "late-lookup",
1334 mock_info("late-lookup", true, false),
1335 Arc::new(RejectLookupFactory)
1336 ),
1337 Err(ConnectorError::RegistryFrozen {
1338 kind: "lookup source",
1339 ..
1340 })
1341 ));
1342 }
1343}