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    /// Resume from an exact durable engine checkpoint.
40    Resume {
41        /// Checkpoint attempt that owns the connector state.
42        attempt: laminar_core::checkpoint::CheckpointAttempt,
43        /// Connector cursor captured by `attempt`.
44        checkpoint: SourceCheckpoint,
45    },
46}
47
48/// Complete source startup request.
49///
50/// Startup is intentionally a single operation so a connector cannot become
51/// externally active between opening resources and restoring its position.
52#[derive(Debug, Clone)]
53pub struct SourceStart {
54    /// Fully resolved connector configuration.
55    config: ConnectorConfig,
56    /// Initial or exact recovery position.
57    position: SourcePosition,
58    /// Pipeline-wide delivery guarantee used for fail-closed cursor policy.
59    delivery: DeliveryGuarantee,
60}
61
62impl SourceStart {
63    /// Construct a source startup request before any connector I/O.
64    ///
65    /// # Errors
66    /// Returns a configuration error when a resume attempt is zero or split across two identities.
67    pub fn new(
68        config: ConnectorConfig,
69        position: SourcePosition,
70        delivery: DeliveryGuarantee,
71    ) -> Result<Self, ConnectorError> {
72        if matches!(
73            &position,
74            SourcePosition::Resume { attempt, .. } if !attempt.is_canonical()
75        ) {
76            return Err(ConnectorError::ConfigurationError(
77                "source resume must use one nonzero canonical checkpoint ID".into(),
78            ));
79        }
80        Ok(Self {
81            config,
82            position,
83            delivery,
84        })
85    }
86
87    /// Consume the request into connector-owned startup inputs.
88    #[must_use]
89    pub fn into_parts(self) -> (ConnectorConfig, SourcePosition, DeliveryGuarantee) {
90        (self.config, self.position, self.delivery)
91    }
92}
93
94/// Exact cluster transition for which a source must stop advancing input.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct SourceDrainRequest {
97    /// Compact predecessor/target/leader identity.
98    pub round: laminar_core::checkpoint::AssignmentDrainId,
99}
100
101impl SourceDrainRequest {
102    /// Construct a canonical source drain request.
103    ///
104    /// # Errors
105    /// Returns an error when the transition identity is not canonical.
106    pub fn new(round: laminar_core::checkpoint::AssignmentDrainId) -> Result<Self, ConnectorError> {
107        if !round.is_canonical() {
108            return Err(ConnectorError::ConfigurationError(
109                "source drain round is not canonical".into(),
110            ));
111        }
112        Ok(Self { round })
113    }
114}
115
116/// Terminal resolution of one exact source drain round.
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum SourceDrainOutcome {
119    /// The target assignment committed and the source may adopt its target input ownership.
120    Commit,
121    /// The transition aborted and the source must resume from the predecessor cut.
122    Abort,
123}
124
125/// Exact round resolution delivered to a source connector.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct SourceDrainResolution {
128    /// Round being resolved.
129    pub round: laminar_core::checkpoint::AssignmentDrainId,
130    /// Durable transition outcome.
131    pub outcome: SourceDrainOutcome,
132}
133
134/// Trait for source connectors that read data from external systems.
135///
136/// Source connectors operate in Ring 1 and push data into Ring 0 via
137/// the streaming `Source<ArrowRecord>::push_arrow()` API.
138///
139/// # Lifecycle
140///
141/// 1. `start()` — atomically install the configured or recovered cursor and initialize the reader
142/// 2. `poll_batch()` — read batches in a loop
143/// 3. `checkpoint()` — capture the current connector cursor
144/// 4. `close()` — clean shutdown
145#[async_trait]
146pub trait SourceConnector: Send {
147    /// Deadline behavior required by the underlying client implementation.
148    ///
149    /// Retirement is the conservative default: a new connector must not be
150    /// reused after cancellation until every lifecycle future has been audited.
151    fn cancellation_policy(&self) -> ConnectorCancellationPolicy {
152        ConnectorCancellationPolicy::RetireConnector
153    }
154
155    /// Observe detached tasks whose lifetime may outlast this connector value.
156    ///
157    /// A connector that spawns detached work must retain the matching
158    /// [`crate::connector::ConnectorTaskOwner`] and move a guard into every task. The runtime can
159    /// then wait for true terminal completion after dropping the connector.
160    fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
161        None
162    }
163
164    /// Opens the source and establishes its initial or resumed position as one
165    /// indivisible lifecycle transition.
166    ///
167    /// Implementations must not emit records or expose an externally active
168    /// consumer before the requested position has been applied successfully.
169    async fn start(&mut self, request: SourceStart) -> Result<(), ConnectorError>;
170
171    /// `Ok(None)` = no data currently available; runtime retries after a delay.
172    /// `max_records` is the normal batching target. A source may exceed it only
173    /// when one upstream atomic replay unit cannot be split without making its
174    /// checkpoint cursor invalid. Such sources must enforce independent hard
175    /// record and byte limits and fail before retained data can grow unbounded.
176    ///
177    /// The runtime may cancel this future at a shutdown or authority deadline.
178    /// [`ConnectorCancellationPolicy::CancelSafe`] implementations must not
179    /// advance external or checkpoint-visible position across an `.await`
180    /// unless dropping the future there preserves replay of every
181    /// not-yet-returned record. Stage work privately, then advance the cursor
182    /// and return without another cancellation point. The conservative
183    /// [`ConnectorCancellationPolicy::RetireConnector`] policy instead makes
184    /// the complete connector generation terminal after cancellation.
185    async fn poll_batch(
186        &mut self,
187        max_records: usize,
188    ) -> Result<Option<SourceBatch>, ConnectorError>;
189
190    /// Resolve the source schema from the connector and format properties before
191    /// DDL reaches the planner. Implementations that perform network I/O must
192    /// bound it with a timeout. Return `Err(ConnectorError::…)` on failure so
193    /// the runtime can surface the cause to DDL — do not log and swallow.
194    async fn discover_schema(
195        &mut self,
196        _properties: &std::collections::HashMap<String, String>,
197    ) -> Result<(), ConnectorError> {
198        Ok(())
199    }
200
201    /// Arrow schema of records this source produces.
202    fn schema(&self) -> SchemaRef;
203
204    /// Returned checkpoint must contain enough info to resume from the
205    /// current position after a restart.
206    fn checkpoint(&self) -> SourceCheckpoint;
207
208    /// Atomically attempt to capture a source cursor. `Ok(None)` means a
209    /// transient control-plane publication is not reconciled yet; the runtime
210    /// retains the barrier and retries without advancing it.
211    ///
212    /// # Errors
213    /// Returns a connector error when the source cannot produce a valid cursor for the current
214    /// ownership/configuration state.
215    fn try_checkpoint(&self) -> Result<Option<SourceCheckpoint>, ConnectorError> {
216        Ok(Some(self.checkpoint()))
217    }
218
219    /// Choose how the runtime progresses after [`Self::try_checkpoint`] returns `Ok(None)`.
220    ///
221    /// The default holds intake because polling across an unreconciled
222    /// control-plane cursor could move data past the retained barrier.
223    /// The result must remain stable after the connector starts.
224    fn checkpoint_unavailable_policy(&self) -> SourceCheckpointUnavailablePolicy {
225        SourceCheckpointUnavailablePolicy::HoldIntake
226    }
227
228    /// Whether this source's data-plane cursor is reconciled with the current
229    /// control-plane ownership publication. The runtime does not poll records
230    /// or admit checkpoint barriers while this is false.
231    ///
232    /// Non-partitioned and local sources are always ready. Cluster-aware
233    /// sources override this with a lock-free version fence.
234    ///
235    /// # Errors
236    /// Returns a connector error when control-plane reconciliation detects invalid or lost
237    /// ownership state that cannot be retried safely.
238    fn checkpoint_ready(&self) -> Result<bool, ConnectorError> {
239        Ok(true)
240    }
241
242    /// Start or advance connector-side control-plane work needed to become
243    /// [`checkpoint_ready`](Self::checkpoint_ready). Called even while data
244    /// polling and barriers are fenced.
245    fn drive_control_plane(&mut self) {}
246
247    /// Stop advancing external input for an exact cluster transition.
248    ///
249    /// Implementations start the provider operation without blocking and later expose readiness
250    /// through [`Self::poll_drain_ready`]. `deadline` is the engine-owned absolute deadline for
251    /// this drain attempt; provider retries must not continue beyond it. Actor-only sources are
252    /// fenced by the engine and never call this hook; assignment-scoped sources must implement it
253    /// explicitly.
254    ///
255    /// # Errors
256    /// Returns an error when the provider cannot start this exact drain round.
257    fn begin_drain(
258        &mut self,
259        _request: &SourceDrainRequest,
260        _deadline: tokio::time::Instant,
261    ) -> Result<(), ConnectorError> {
262        Err(ConnectorError::ConfigurationError(
263            "assignment-scoped source does not implement provider drain".into(),
264        ))
265    }
266
267    /// Whether the exact provider FIFO boundary has been consumed.
268    ///
269    /// Returns `false` while the reader is still pausing or while pre-boundary payloads remain.
270    ///
271    /// # Errors
272    /// Returns an error when drain progress cannot be observed safely.
273    fn poll_drain_ready(
274        &mut self,
275        _round: laminar_core::checkpoint::AssignmentDrainId,
276    ) -> Result<bool, ConnectorError> {
277        Err(ConnectorError::ConfigurationError(
278            "assignment-scoped source does not expose provider drain readiness".into(),
279        ))
280    }
281
282    /// Resolve an exact drain after target commit or abort.
283    ///
284    /// An abort must rewind any client-delivered but engine-unaccepted records before resuming.
285    /// A commit must reconcile target ownership before clearing its post-cut filter.
286    /// `deadline` is the engine-owned absolute deadline for the complete resolution; provider
287    /// retries and blocking client calls must be bounded by its remaining time.
288    async fn finish_drain(
289        &mut self,
290        _resolution: SourceDrainResolution,
291        _deadline: tokio::time::Instant,
292    ) -> Result<(), ConnectorError> {
293        Err(ConnectorError::ConfigurationError(
294            "assignment-scoped source does not implement provider drain resolution".into(),
295        ))
296    }
297
298    /// Install the cluster vnode assignment for a source that advertises
299    /// [`crate::connector::SourceTopology::Splittable`]. The source identity is the stable,
300    /// canonical catalog object name and must be part of any external split
301    /// mapping ABI.
302    ///
303    /// Embedded, single-node, singleton, and node-local sources are not sent
304    /// this hook. The default fails closed so an extension cannot advertise
305    /// splittable placement while every cluster node reads the full input.
306    ///
307    /// # Errors
308    /// Returns a configuration error unless the connector implements exact
309    /// vnode-scoped input ownership.
310    fn set_vnode_assignment(
311        &mut self,
312        _source_identity: &str,
313        _registry: Arc<laminar_core::state::VnodeRegistry>,
314        _self_id: laminar_core::state::NodeId,
315    ) -> Result<(), ConnectorError> {
316        Err(ConnectorError::ConfigurationError(
317            "source advertises splittable placement but does not implement vnode assignment".into(),
318        ))
319    }
320
321    /// Close the connection and release resources.
322    async fn close(&mut self) -> Result<(), ConnectorError>;
323
324    /// Returns a [`Notify`] handle that is signalled when new data is available.
325    ///
326    /// When `Some`, the pipeline coordinator awaits the notification instead of
327    /// polling on a timer, eliminating idle CPU usage. Push-driven sources should
328    /// return `Some` and call `notify.notify_one()` when data arrives.
329    ///
330    /// The default implementation returns `None`, which causes the pipeline to
331    /// fall back to timer-based polling (suitable for batch/file sources).
332    fn data_ready_notify(&self) -> Option<Arc<Notify>> {
333        None
334    }
335
336    /// Declare recovery and placement semantics for this exact configuration.
337    ///
338    /// # Errors
339    ///
340    /// Returns an error when the concrete configuration cannot provide a valid
341    /// recovery, placement, or input contract.
342    fn contract(&self, config: &ConnectorConfig) -> Result<SourceContract, ConnectorError>;
343
344    /// Return connector-owned semantic options for durable recovery identity.
345    ///
346    /// The hook must be deterministic, configuration-only, and free of external
347    /// I/O. `Some` replaces the raw property map in the pipeline identity;
348    /// `None` asks the runtime to use its conservative sanitized-property
349    /// fallback. A connector may omit operational endpoints or credentials only
350    /// when its checkpoint independently binds the exact external object.
351    ///
352    /// # Errors
353    ///
354    /// Returns a configuration error when the semantic identity cannot be
355    /// derived from the supplied source configuration.
356    fn recovery_identity_options(
357        &self,
358        _config: &ConnectorConfig,
359    ) -> Result<Option<std::collections::BTreeMap<String, String>>, ConnectorError> {
360        Ok(None)
361    }
362
363    /// Acknowledge that `epoch` has been durably committed.
364    ///
365    /// Called after the manifest and exact engine commit decision are durable. Coordinated
366    /// external sink publication may complete asynchronously afterward. The `checkpoint` is the exact
367    /// per-source `SourceCheckpoint` that was persisted into the manifest
368    /// for this epoch — sources can rely on it to advance external offset
369    /// state (broker group offsets, lookup-DB cursors, ack tokens) using
370    /// values that match what's durable.
371    ///
372    /// May be called with an empty `checkpoint` for timer-driven commits
373    /// where no per-source state was captured; implementations should
374    /// treat that as a no-op for any externally-visible advancement.
375    ///
376    /// Idempotent — a retry after cancellation is legal.
377    ///
378    /// # Errors
379    ///
380    /// The epoch is already durable and cannot be rolled back. During normal processing an error
381    /// faults replay-capable pipelines so recovery retries the advisory upstream commit; during
382    /// shutdown it is logged and the durable `LaminarDB` checkpoint remains authoritative.
383    async fn notify_epoch_committed(
384        &mut self,
385        _epoch: u64,
386        _checkpoint: &SourceCheckpoint,
387    ) -> Result<(), ConnectorError> {
388        Ok(())
389    }
390}