Skip to main content

laminar_connectors/connector/
source.rs

1//! Source startup, drain, polling, recovery, and assignment protocol.
2
3use std::sync::Arc;
4
5use arrow_schema::SchemaRef;
6use async_trait::async_trait;
7use tokio::sync::Notify;
8
9use crate::checkpoint::SourceCheckpoint;
10use crate::config::ConnectorConfig;
11use crate::error::ConnectorError;
12
13use super::{
14    ConnectorCancellationPolicy, ConnectorTaskTracker, DeliveryGuarantee, SourceBatch,
15    SourceContract,
16};
17
18/// Intake behavior when a source temporarily has no exact checkpoint cursor.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SourceCheckpointUnavailablePolicy {
21    /// Hold intake until control-plane reconciliation makes the cursor available.
22    HoldIntake,
23    /// Poll at most one batch before retrying a retained barrier.
24    ///
25    /// Sources using this policy must bind every returned batch to its exact
26    /// current cursor and stop at the first completed replay-unit boundary.
27    PollToReplayBoundary,
28}
29
30/// Atomic startup position for a source connector.
31///
32/// A resume request carries both the durable checkpoint attempt and the
33/// connector checkpoint captured by that attempt. Connectors must install the
34/// position before `start` returns and before `poll_batch` can emit records.
35#[derive(Debug, Clone)]
36pub enum SourcePosition {
37    /// Start from the connector's configured deterministic initial position.
38    Initial,
39    /// Install the complete unowned cursor sealed for a new topology source.
40    /// This boundary is not processed history and carries no checkpoint attempt or
41    /// acknowledgement. Only connectors with a certified sealed-start contract accept it.
42    /// The caller must retain intake until the committed topology's coordinated Release.
43    Initialized {
44        /// Exact global cursor, validated and filtered by current source ownership at startup.
45        checkpoint: SourceCheckpoint,
46    },
47    /// Resume from an exact durable engine checkpoint.
48    Resume {
49        /// Checkpoint attempt that owns the connector state.
50        attempt: laminar_core::checkpoint::CheckpointAttempt,
51        /// Connector cursor captured by `attempt`.
52        checkpoint: SourceCheckpoint,
53    },
54}
55
56/// Complete source startup request.
57///
58/// Startup is intentionally a single operation so a connector cannot become
59/// externally active between opening resources and restoring its position.
60#[derive(Debug, Clone)]
61pub struct SourceStart {
62    /// Fully resolved connector configuration.
63    config: ConnectorConfig,
64    /// Configured, sealed new-source, or exact recovery position.
65    position: SourcePosition,
66    /// Pipeline-wide delivery guarantee used for fail-closed cursor policy.
67    delivery: DeliveryGuarantee,
68}
69
70impl SourceStart {
71    /// Construct a source startup request before any connector I/O.
72    ///
73    /// # Errors
74    /// Returns a configuration error for a noncanonical resume, unsupported initialized delivery,
75    /// or an already-owned initialization.
76    pub fn new(
77        config: ConnectorConfig,
78        position: SourcePosition,
79        delivery: DeliveryGuarantee,
80    ) -> Result<Self, ConnectorError> {
81        if matches!(
82            &position,
83            SourcePosition::Resume { attempt, .. } if !attempt.is_canonical()
84        ) {
85            return Err(ConnectorError::ConfigurationError(
86                "source resume must use one nonzero canonical checkpoint ID".into(),
87            ));
88        }
89        if matches!(&position, SourcePosition::Initialized { .. })
90            && delivery == DeliveryGuarantee::BestEffort
91        {
92            return Err(ConnectorError::ConfigurationError(
93                "sealed topology initialization requires at-least-once or certified exactly-once source delivery".into(),
94            ));
95        }
96        if matches!(&position, SourcePosition::Initialized { checkpoint }
97            if checkpoint.assignment_version().is_some())
98        {
99            return Err(ConnectorError::ConfigurationError(
100                "sealed source initialization must be an unowned global cursor".into(),
101            ));
102        }
103        Ok(Self {
104            config,
105            position,
106            delivery,
107        })
108    }
109
110    /// Consume the request into connector-owned startup inputs.
111    #[must_use]
112    pub fn into_parts(self) -> (ConnectorConfig, SourcePosition, DeliveryGuarantee) {
113        (self.config, self.position, self.delivery)
114    }
115
116    #[cfg(feature = "kafka")]
117    pub(crate) fn initialized_checkpoint(&self) -> Option<(&ConnectorConfig, &SourceCheckpoint)> {
118        match &self.position {
119            SourcePosition::Initialized { checkpoint } => Some((&self.config, checkpoint)),
120            SourcePosition::Initial | SourcePosition::Resume { .. } => None,
121        }
122    }
123}
124
125/// Exact cluster transition for which a source must stop advancing input.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct SourceDrainRequest {
128    /// Compact predecessor/target/leader identity.
129    pub round: laminar_core::checkpoint::AssignmentDrainId,
130}
131
132impl SourceDrainRequest {
133    /// Construct a canonical source drain request.
134    ///
135    /// # Errors
136    /// Returns an error when the transition identity is not canonical.
137    pub fn new(round: laminar_core::checkpoint::AssignmentDrainId) -> Result<Self, ConnectorError> {
138        if !round.is_canonical() {
139            return Err(ConnectorError::ConfigurationError(
140                "source drain round is not canonical".into(),
141            ));
142        }
143        Ok(Self { round })
144    }
145}
146
147/// Terminal resolution of one exact source drain round.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum SourceDrainOutcome {
150    /// The target assignment committed and the source may adopt its target input ownership.
151    Commit,
152    /// The transition aborted and the source must resume from the predecessor cut.
153    Abort,
154}
155
156/// Exact round resolution delivered to a source connector.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub struct SourceDrainResolution {
159    /// Round being resolved.
160    pub round: laminar_core::checkpoint::AssignmentDrainId,
161    /// Durable transition outcome.
162    pub outcome: SourceDrainOutcome,
163}
164
165/// Trait for source connectors that read data from external systems.
166///
167/// Source connectors operate in Ring 1 and push data into Ring 0 via
168/// the streaming `Source<ArrowRecord>::push_arrow()` API.
169///
170/// # Lifecycle
171///
172/// 1. `start()` — atomically install the configured or recovered cursor and initialize the reader
173/// 2. `poll_batch()` — read batches in a loop
174/// 3. `checkpoint()` — capture the current connector cursor
175/// 4. `close()` — clean shutdown
176#[async_trait]
177pub trait SourceConnector: Send {
178    /// Deadline behavior required by the underlying client implementation.
179    ///
180    /// Retirement is the conservative default: a new connector must not be
181    /// reused after cancellation until every lifecycle future has been audited.
182    fn cancellation_policy(&self) -> ConnectorCancellationPolicy {
183        ConnectorCancellationPolicy::RetireConnector
184    }
185
186    /// Observe detached tasks whose lifetime may outlast this connector value.
187    ///
188    /// A connector that spawns detached work must retain the matching
189    /// [`crate::connector::ConnectorTaskOwner`] and move a guard into every task. The runtime can
190    /// then wait for true terminal completion after dropping the connector.
191    fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
192        None
193    }
194
195    /// Opens the source and establishes its initial or resumed position as one
196    /// indivisible lifecycle transition.
197    ///
198    /// Implementations must not emit records or expose an externally active
199    /// consumer before the requested position has been applied successfully.
200    async fn start(&mut self, request: SourceStart) -> Result<(), ConnectorError>;
201
202    /// Whether atomic `start` validates and installs `SourcePosition::Initialized` without
203    /// resolving its boundary again or acknowledging pre-boundary input. Discovery/validation
204    /// hooks alone do not certify startup. The runtime rejects unrecognized implementations
205    /// before I/O; this capability is neither topology authority nor actor readiness.
206    fn supports_initialized_start(&self) -> bool {
207        false
208    }
209
210    /// `Ok(None)` = no data currently available; runtime retries after a delay.
211    /// `max_records` is the normal batching target. A source may exceed it only
212    /// when one upstream atomic replay unit cannot be split without making its
213    /// checkpoint cursor invalid. Such sources must enforce independent hard
214    /// record and byte limits and fail before retained data can grow unbounded.
215    ///
216    /// The runtime may cancel this future at a shutdown or authority deadline.
217    /// [`ConnectorCancellationPolicy::CancelSafe`] implementations must not
218    /// advance external or checkpoint-visible position across an `.await`
219    /// unless dropping the future there preserves replay of every
220    /// not-yet-returned record. Stage work privately, then advance the cursor
221    /// and return without another cancellation point. The conservative
222    /// [`ConnectorCancellationPolicy::RetireConnector`] policy instead makes
223    /// the complete connector generation terminal after cancellation.
224    async fn poll_batch(
225        &mut self,
226        max_records: usize,
227    ) -> Result<Option<SourceBatch>, ConnectorError>;
228
229    /// Resolve an immutable reader contract before activation. Metadata adapters must
230    /// override this hook to retain native identity/definitions and validate projections.
231    /// The default preserves explicit columns and reuses pre-open discovery for fixed schemas.
232    async fn resolve_schema(
233        &mut self,
234        config: &ConnectorConfig,
235        explicit: Option<SchemaRef>,
236    ) -> Result<crate::schema::resolution::SchemaBinding, ConnectorError> {
237        use crate::schema::resolution::{logical_binding, SchemaDirection, SchemaOrigin};
238        if let Some(schema) = explicit {
239            return logical_binding(
240                config,
241                SchemaDirection::Source,
242                SchemaOrigin::Explicit,
243                &schema,
244            );
245        }
246        self.discover_schema(config.properties()).await?;
247        logical_binding(
248            config,
249            SchemaDirection::Source,
250            SchemaOrigin::BuiltIn,
251            &self.schema(),
252        )
253    }
254
255    /// Resolve the source schema from the connector and format properties before
256    /// DDL reaches the planner. Implementations that perform network I/O must
257    /// bound it with a timeout. Return `Err(ConnectorError::…)` on failure so
258    /// the runtime can surface the cause to DDL — do not log and swallow.
259    async fn discover_schema(
260        &mut self,
261        _properties: &std::collections::HashMap<String, String>,
262    ) -> Result<(), ConnectorError> {
263        Ok(())
264    }
265
266    /// Resolve a complete, unowned initial cursor without starting or consuming the source.
267    /// The topology control path seals the first successful vector and reuses it on every retry.
268    /// Implementations must bound metadata I/O and task ownership, avoid group joins, external
269    /// acknowledgements and resource creation, and return the existing connector cursor encoding.
270    /// The cursor is not a checkpoint of processed data or an intake permit. Ordinary startup and
271    /// recovery keep their current policy until target installation explicitly consumes this cut.
272    ///
273    /// # Errors
274    /// Rejects configurations without a certified initialization contract. The default fails
275    /// closed; adding a connector requires an implementation and recovery/activation evidence.
276    async fn resolve_initial_position(
277        &mut self,
278        _config: &ConnectorConfig,
279    ) -> Result<SourceCheckpoint, ConnectorError> {
280        Err(ConnectorError::ConfigurationError(
281            "connector has no sealed topology initialization contract".into(),
282        ))
283    }
284
285    /// Read-only validation of a previously sealed global new-source cursor.
286    /// Must preserve its numeric boundary and create no active reader, acknowledgement or sink effect.
287    /// Connectors must reject a changed inventory or expired position rather than silently reset.
288    /// This is preparation only; startup must validate again under final installation authority.
289    async fn validate_initial_position(
290        &mut self,
291        _config: &ConnectorConfig,
292        _checkpoint: &SourceCheckpoint,
293    ) -> Result<(), ConnectorError> {
294        Err(ConnectorError::ConfigurationError(
295            "connector has no sealed topology cursor validation contract".into(),
296        ))
297    }
298
299    /// Arrow schema of records this source produces.
300    fn schema(&self) -> SchemaRef;
301
302    /// Returned checkpoint must contain enough info to resume from the
303    /// current position after a restart.
304    fn checkpoint(&self) -> SourceCheckpoint;
305
306    /// Atomically attempt to capture a source cursor. `Ok(None)` means a
307    /// transient control-plane publication is not reconciled yet; the runtime
308    /// retains the barrier and retries without advancing it.
309    ///
310    /// # Errors
311    /// Returns a connector error when the source cannot produce a valid cursor for the current
312    /// ownership/configuration state.
313    fn try_checkpoint(&self) -> Result<Option<SourceCheckpoint>, ConnectorError> {
314        Ok(Some(self.checkpoint()))
315    }
316
317    /// Choose how the runtime progresses after [`Self::try_checkpoint`] returns `Ok(None)`.
318    ///
319    /// The default holds intake because polling across an unreconciled
320    /// control-plane cursor could move data past the retained barrier.
321    /// The result must remain stable after the connector starts.
322    fn checkpoint_unavailable_policy(&self) -> SourceCheckpointUnavailablePolicy {
323        SourceCheckpointUnavailablePolicy::HoldIntake
324    }
325
326    /// Whether this source's data-plane cursor is reconciled with the current
327    /// control-plane ownership publication. The runtime does not poll records
328    /// or admit checkpoint barriers while this is false.
329    ///
330    /// Non-partitioned and local sources are always ready. Cluster-aware
331    /// sources override this with a lock-free version fence.
332    ///
333    /// # Errors
334    /// Returns a connector error when control-plane reconciliation detects invalid or lost
335    /// ownership state that cannot be retried safely.
336    fn checkpoint_ready(&self) -> Result<bool, ConnectorError> {
337        Ok(true)
338    }
339
340    /// Start or advance connector-side control-plane work needed to become
341    /// [`checkpoint_ready`](Self::checkpoint_ready). Called even while data
342    /// polling and barriers are fenced.
343    fn drive_control_plane(&mut self) {}
344
345    /// Stop advancing external input for an exact cluster transition.
346    ///
347    /// Implementations start the provider operation without blocking and later expose readiness
348    /// through [`Self::poll_drain_ready`]. `deadline` is the engine-owned absolute deadline for
349    /// this drain attempt; provider retries must not continue beyond it. Actor-only sources are
350    /// fenced by the engine and never call this hook; assignment-scoped sources must implement it
351    /// explicitly.
352    ///
353    /// # Errors
354    /// Returns an error when the provider cannot start this exact drain round.
355    fn begin_drain(
356        &mut self,
357        _request: &SourceDrainRequest,
358        _deadline: tokio::time::Instant,
359    ) -> Result<(), ConnectorError> {
360        Err(ConnectorError::ConfigurationError(
361            "assignment-scoped source does not implement provider drain".into(),
362        ))
363    }
364
365    /// Whether the exact provider FIFO boundary has been consumed.
366    ///
367    /// Returns `false` while the reader is still pausing or while pre-boundary payloads remain.
368    ///
369    /// # Errors
370    /// Returns an error when drain progress cannot be observed safely.
371    fn poll_drain_ready(
372        &mut self,
373        _round: laminar_core::checkpoint::AssignmentDrainId,
374    ) -> Result<bool, ConnectorError> {
375        Err(ConnectorError::ConfigurationError(
376            "assignment-scoped source does not expose provider drain readiness".into(),
377        ))
378    }
379
380    /// Resolve an exact drain after target commit or abort.
381    ///
382    /// An abort must rewind any client-delivered but engine-unaccepted records before resuming.
383    /// A commit must reconcile target ownership before clearing its post-cut filter.
384    /// `deadline` is the engine-owned absolute deadline for the complete resolution; provider
385    /// retries and blocking client calls must be bounded by its remaining time.
386    async fn finish_drain(
387        &mut self,
388        _resolution: SourceDrainResolution,
389        _deadline: tokio::time::Instant,
390    ) -> Result<(), ConnectorError> {
391        Err(ConnectorError::ConfigurationError(
392            "assignment-scoped source does not implement provider drain resolution".into(),
393        ))
394    }
395
396    /// Install the cluster vnode assignment for a source that advertises
397    /// [`crate::connector::SourceTopology::Splittable`]. The source identity is the stable,
398    /// canonical catalog object name and must be part of any external split
399    /// mapping ABI.
400    ///
401    /// Embedded, single-node, singleton, and node-local sources are not sent
402    /// this hook. The default fails closed so an extension cannot advertise
403    /// splittable placement while every cluster node reads the full input.
404    ///
405    /// # Errors
406    /// Returns a configuration error unless the connector implements exact
407    /// vnode-scoped input ownership.
408    fn set_vnode_assignment(
409        &mut self,
410        _source_identity: &str,
411        _registry: Arc<laminar_core::state::VnodeRegistry>,
412        _self_id: laminar_core::state::NodeId,
413    ) -> Result<(), ConnectorError> {
414        Err(ConnectorError::ConfigurationError(
415            "source advertises splittable placement but does not implement vnode assignment".into(),
416        ))
417    }
418
419    /// Close the connection and release resources.
420    async fn close(&mut self) -> Result<(), ConnectorError>;
421
422    /// Returns a [`Notify`] handle that is signalled when new data is available.
423    ///
424    /// When `Some`, the pipeline coordinator awaits the notification instead of
425    /// polling on a timer, eliminating idle CPU usage. Push-driven sources should
426    /// return `Some` and call `notify.notify_one()` when data arrives.
427    ///
428    /// The default implementation returns `None`, which causes the pipeline to
429    /// fall back to timer-based polling (suitable for batch/file sources).
430    fn data_ready_notify(&self) -> Option<Arc<Notify>> {
431        None
432    }
433
434    /// Declare recovery and placement semantics for this exact configuration.
435    /// This hook must be deterministic, configuration-only, and free of external I/O;
436    /// admission and checkpoint identity inspect it before `start()`.
437    ///
438    /// # Errors
439    ///
440    /// Returns an error when the concrete configuration cannot provide a valid
441    /// recovery, placement, or input contract.
442    fn contract(&self, config: &ConnectorConfig) -> Result<SourceContract, ConnectorError>;
443
444    /// Return connector-owned semantic options for durable recovery identity.
445    ///
446    /// The hook must be deterministic, configuration-only, and free of external
447    /// I/O. `Some` replaces the raw property map in the pipeline identity;
448    /// `None` asks the runtime to use its conservative sanitized-property
449    /// fallback. A connector may omit operational endpoints or credentials only
450    /// when its checkpoint independently binds the exact external object.
451    ///
452    /// # Errors
453    ///
454    /// Returns a configuration error when the semantic identity cannot be
455    /// derived from the supplied source configuration.
456    fn recovery_identity_options(
457        &self,
458        _config: &ConnectorConfig,
459    ) -> Result<Option<std::collections::BTreeMap<String, String>>, ConnectorError> {
460        Ok(None)
461    }
462
463    /// How far the upstream of a source configured by `config` has been written, for reads
464    /// that must include it. Configuration only, no external I/O. The default, `None`, means the
465    /// connector cannot tell, and such reads are refused.
466    ///
467    /// # Errors
468    ///
469    /// Returns a configuration error when `config` is invalid for this connector.
470    fn recency(
471        &self,
472        _config: &ConnectorConfig,
473    ) -> Result<Option<Arc<dyn super::SourceRecency>>, ConnectorError> {
474        Ok(None)
475    }
476
477    /// Acknowledge that `epoch` has been durably committed.
478    ///
479    /// Called after the manifest and exact engine commit decision are durable. Coordinated
480    /// external sink publication may complete asynchronously afterward. The `checkpoint` is the exact
481    /// per-source `SourceCheckpoint` that was persisted into the manifest
482    /// for this epoch — sources can rely on it to advance external offset
483    /// state (broker group offsets, lookup-DB cursors, ack tokens) using
484    /// values that match what's durable.
485    ///
486    /// May be called with an empty `checkpoint` for timer-driven commits
487    /// where no per-source state was captured; implementations should
488    /// treat that as a no-op for any externally-visible advancement.
489    ///
490    /// Idempotent — a retry after cancellation is legal.
491    ///
492    /// # Errors
493    ///
494    /// The epoch is already durable and cannot be rolled back. During normal processing an error
495    /// faults replay-capable pipelines so recovery retries the advisory upstream commit; during
496    /// shutdown it is logged and the durable `LaminarDB` checkpoint remains authoritative.
497    async fn notify_epoch_committed(
498        &mut self,
499        _epoch: u64,
500        _checkpoint: &SourceCheckpoint,
501    ) -> Result<(), ConnectorError> {
502        Ok(())
503    }
504}