Skip to main content

laminar_connectors/
registry.rs

1//! Registry of connector factories, keyed by connector type string.
2
3use 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
16/// Factory function type for creating source connectors.
17///
18/// The optional shared Prometheus registry allows connectors to register
19/// metrics and retain stable registry identity when one is available.
20/// Construction failures, including metrics registration errors, are returned
21/// to the caller.
22pub type SourceFactory = Arc<
23    dyn Fn(Option<&Arc<prometheus::Registry>>) -> Result<Box<dyn SourceConnector>, ConnectorError>
24        + Send
25        + Sync,
26>;
27
28/// Factory function type for creating sink connectors.
29///
30/// The connector config is available during construction so factories can
31/// select a concrete implementation and reject invalid mode-specific options
32/// before `open()` performs external I/O.
33///
34/// The optional shared Prometheus registry allows connectors to register
35/// metrics and retain stable registry identity when one is available.
36pub 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
45/// Factory for finite reference-table snapshots constrained by the declared schema.
46pub type TableSourceFactory = Arc<
47    dyn Fn(&ConnectorConfig, SchemaRef) -> Result<Box<dyn ReferenceTableSource>, ConnectorError>
48        + Send
49        + Sync,
50>;
51
52/// Factory for constructing a lookup source (async, for on-demand mode).
53#[async_trait]
54pub trait LookupSourceFactory: Send + Sync {
55    /// Resolve a lookup reader before opening its cache-miss data path.
56    ///
57    /// # Errors
58    /// The default requires explicit fields. Custom metadata-capable factories
59    /// override this hook without consuming data or creating external resources.
60    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    /// Build a lookup source instance from the given config.
80    ///
81    /// `declared_schema` is the table's declared Arrow schema, when known.
82    /// Schema-bearing sources may derive their own; schemaless sources use it
83    /// to project records into typed columns.
84    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/// Registry of available connector implementations. Connectors register
94/// a factory per type string; the runtime resolves the connector named by
95/// `FROM` in `CREATE SOURCE` or `INTO` in `CREATE SINK`.
96#[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    /// A registration holds a read guard through insertion; `freeze` takes the write guard.
103    /// This makes the freeze boundary linearizable with concurrent registration attempts.
104    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    /// Creates a new empty registry.
112    #[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    /// Attach bounded schema resolution metrics to the database's existing registry.
126    ///
127    /// # Errors
128    /// Rejects duplicate installation or metric-name conflicts.
129    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    /// Permanently closes this registry to factory mutation.
219    ///
220    /// The transition is idempotent. Once this call returns, every registration that began
221    /// before it is visible and every later registration is rejected.
222    pub fn freeze(&self) {
223        *self.frozen.write() = true;
224    }
225
226    /// Returns whether factory registration has been permanently disabled.
227    #[must_use]
228    pub fn is_frozen(&self) -> bool {
229        *self.frozen.read()
230    }
231
232    /// Registers a source connector factory.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if the source name is already registered or the registry is frozen.
237    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    /// Registers a sink connector factory.
264    ///
265    /// # Errors
266    ///
267    /// Returns an error if the sink name is already registered or the registry is frozen.
268    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    /// Run a connector's `discover_schema` against the given properties.
289    ///
290    /// Three outcomes:
291    /// - `Ok(Some(schema))` — discovery succeeded and produced fields.
292    /// - `Ok(None)` — connector type is unknown or does not support discovery.
293    /// - `Err(_)` — discovery was attempted and failed with a specific
294    ///   cause; callers should surface the message verbatim.
295    ///
296    /// # Errors
297    ///
298    /// Returns the source factory's construction error or the underlying
299    /// [`ConnectorError`] from [`SourceConnector::discover_schema`] when
300    /// discovery fails (bad config, unreachable network endpoint, timeout,
301    /// etc.).
302    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    /// Resolve a source contract on the control plane, without starting a reader.
322    ///
323    /// # Errors
324    /// Rejects unsupported discovery, malformed/empty contracts and metadata failures.
325    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    /// Bind query output to a destination's writer contract without external mutation.
362    ///
363    /// # Errors
364    /// Rejects incompatible writer fields, unsupported codecs and metadata failures.
365    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    /// Creates a new source connector instance.
384    ///
385    /// The factory creates a default-configured connector. The caller forwards
386    /// the resolved `FROM` and `FORMAT` configuration in the startup request.
387    ///
388    /// If a `prometheus::Registry` is provided, the connector will register
389    /// its metrics on it so they appear in the scrape output.
390    ///
391    /// # Errors
392    ///
393    /// Returns `ConnectorError::ConfigurationError` if not registered, or the
394    /// source factory's construction error.
395    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    /// Derive a registered source connector's semantic recovery identity.
414    ///
415    /// This constructs an unopened source instance and invokes its pure,
416    /// configuration-only identity hook. `None` requests the runtime's
417    /// conservative sanitized-property fallback.
418    ///
419    /// # Errors
420    ///
421    /// Returns an error when the source type is not registered or the connector
422    /// rejects the supplied semantic configuration.
423    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    /// How far the upstream of the source `config` configures has been written, for reads that
432    /// must include it. `None` when its connector cannot tell.
433    ///
434    /// # Errors
435    ///
436    /// Returns an error when the source type is not registered or the connector rejects the
437    /// configuration.
438    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    /// Creates a new sink connector instance.
446    ///
447    /// The connector type is determined by `config.connector_type()`.
448    ///
449    /// If a `prometheus::Registry` is provided, the connector will register
450    /// its metrics on it so they appear in the scrape output.
451    ///
452    /// # Errors
453    ///
454    /// Returns `ConnectorError::ConfigurationError` if the connector type is
455    /// not registered, or the selected sink configuration is invalid.
456    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    /// Registers a reference table source factory.
475    ///
476    /// # Errors
477    ///
478    /// Returns an error if the table-source name is already registered or the registry is frozen.
479    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    /// Resolve a reference-table contract without hydrating any rows.
506    ///
507    /// # Errors
508    /// Rejects unsupported discovery, invalid metadata and the bounded resolution deadline.
509    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    /// Resolve lookup fields from native metadata or the factory's explicit reader contract.
543    ///
544    /// # Errors
545    /// Rejects unregistered factories, missing authoritative fields and incompatible metadata.
546    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    /// Creates a new reference table source instance.
573    ///
574    /// The connector type is determined by `config.connector_type()`. The declared schema is the
575    /// authoritative field order, Arrow type, and nullability boundary for every returned batch.
576    ///
577    /// # Errors
578    ///
579    /// Returns `ConnectorError::ConfigurationError` if the connector type
580    /// is not registered as a table source.
581    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    /// Lists all registered table source connector names.
600    #[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    /// Returns whether a snapshot-capable table source is registered.
608    #[must_use]
609    pub fn has_table_source(&self, name: &str) -> bool {
610        self.table_sources.read().contains_key(name)
611    }
612
613    /// Registers a lookup source factory for on-demand/partial cache mode.
614    ///
615    /// # Errors
616    ///
617    /// Returns an error if the lookup name is already registered or the registry is frozen.
618    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    /// Returns whether an on-demand lookup source is registered.
645    #[must_use]
646    pub fn has_lookup_source(&self, name: &str) -> bool {
647        self.lookup_sources.read().contains_key(name)
648    }
649
650    /// Creates a lookup source for on-demand cache-miss fallback.
651    ///
652    /// Returns `None` if no lookup source factory is registered for
653    /// the given connector type.
654    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    /// Returns information about a registered source connector.
689    #[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    /// Returns information about a registered sink connector.
695    #[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    /// Lists all registered source connector names.
701    #[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    /// Lists all registered sink connector names.
709    #[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    /// Creates a deserializer for the given format string.
717    ///
718    /// # Errors
719    ///
720    /// Returns `ConnectorError::Serde` if the format is not supported.
721    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    /// Creates a serializer for the given format string.
730    ///
731    /// # Errors
732    ///
733    /// Returns `ConnectorError::Serde` if the format is not supported.
734    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    // ── Table source factory tests ──
1163
1164    #[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}