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}