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}