Skip to main content

laminar_connectors/
connector.rs

1//! Connector traits — async `SourceConnector` / `SinkConnector`.
2
3use std::str::FromStr;
4use std::sync::atomic::{AtomicUsize, Ordering};
5use std::sync::{Arc, Weak};
6
7use arrow_array::RecordBatch;
8use arrow_schema::SchemaRef;
9use async_trait::async_trait;
10use sha2::{Digest, Sha256};
11use tokio::sync::Notify;
12
13use crate::checkpoint::SourceCheckpoint;
14use crate::config::ConnectorConfig;
15use crate::error::ConnectorError;
16
17/// Delivery guarantee level for the pipeline.
18///
19/// Configures the expected end-to-end delivery semantics. The pipeline
20/// validates at startup that all sources and sinks meet the requirements
21/// for the chosen guarantee level.
22#[derive(
23    Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize,
24)]
25#[serde(rename_all = "snake_case")]
26pub enum DeliveryGuarantee {
27    /// Best effort: no replay contract. Intended for bare-metal/embedded
28    /// low-latency pipelines that explicitly accept loss on failure.
29    #[default]
30    BestEffort,
31    /// At-least-once: records may be replayed on recovery. Requires
32    /// checkpointing and replayable sources.
33    AtLeastOnce,
34    /// Exactly-once: no duplicates or losses. Requires all sources to
35    /// support replay, all sinks to support exactly-once, and checkpoint
36    /// to be enabled.
37    ExactlyOnce,
38}
39
40impl std::fmt::Display for DeliveryGuarantee {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            DeliveryGuarantee::BestEffort => write!(f, "best-effort"),
44            DeliveryGuarantee::AtLeastOnce => write!(f, "at-least-once"),
45            DeliveryGuarantee::ExactlyOnce => write!(f, "exactly-once"),
46        }
47    }
48}
49
50impl FromStr for DeliveryGuarantee {
51    type Err = String;
52
53    fn from_str(s: &str) -> Result<Self, Self::Err> {
54        match s.to_lowercase().replace('-', "_").as_str() {
55            "best_effort" | "besteffort" | "none" => Ok(Self::BestEffort),
56            "at_least_once" | "atleastonce" => Ok(Self::AtLeastOnce),
57            "exactly_once" | "exactlyonce" => Ok(Self::ExactlyOnce),
58            other => Err(format!("unknown delivery guarantee: '{other}'")),
59        }
60    }
61}
62
63/// Recovery semantics provided by a source.
64///
65/// This is deliberately a small, ordered set of operational contracts rather
66/// than a collection of independent capability flags. A source must advertise
67/// the strongest contract its implementation can actually uphold.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
69pub enum SourceConsistency {
70    /// Events cannot be reconstructed after the runtime has accepted them.
71    #[default]
72    Ephemeral,
73    /// A persisted source position can be used to reproduce accepted events.
74    Replayable,
75    /// Replay is supported, and upstream progress/resources advance only when
76    /// the corresponding `LaminarDB` checkpoint is durably committed.
77    CommitCoupled,
78}
79
80impl SourceConsistency {
81    /// Whether a persisted source position can be replayed after recovery.
82    #[must_use]
83    pub const fn supports_replay(self) -> bool {
84        !matches!(self, Self::Ephemeral)
85    }
86
87    /// Whether checkpoint commits are required for safe upstream progress.
88    #[must_use]
89    pub const fn requires_checkpointing(self) -> bool {
90        matches!(self, Self::CommitCoupled)
91    }
92}
93
94/// How a source may be placed across runtime nodes.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
96pub enum SourceTopology {
97    /// Exactly one runtime instance owns the source.
98    #[default]
99    Singleton,
100    /// Input partitions can be assigned independently across runtime nodes.
101    Splittable,
102    /// Each runtime node receives a distinct, node-local input stream.
103    NodeLocalIngress,
104}
105
106/// Complete source admission contract for a concrete connector configuration.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
108pub struct SourceContract {
109    /// Recovery and external-progress semantics.
110    pub consistency: SourceConsistency,
111    /// Valid runtime placement model.
112    pub topology: SourceTopology,
113    exact_delivery_certified: bool,
114}
115
116impl SourceContract {
117    /// Construct a source contract from its recovery and placement dimensions.
118    /// Exactly-once certification defaults to fail-closed.
119    #[must_use]
120    pub const fn new(consistency: SourceConsistency, topology: SourceTopology) -> Self {
121        Self {
122            consistency,
123            topology,
124            exact_delivery_certified: false,
125        }
126    }
127
128    /// Mark a built-in connector whose exact-delivery suite is an engine release gate.
129    #[must_use]
130    pub(crate) const fn with_exact_delivery_certification(mut self) -> Self {
131        self.exact_delivery_certified = true;
132        self
133    }
134
135    /// Whether this source is certified for exactly-once delivery.
136    #[doc(hidden)]
137    #[must_use]
138    pub const fn is_exact_delivery_certified(self) -> bool {
139        self.exact_delivery_certified
140    }
141
142    /// Whether a persisted source position can be replayed after recovery.
143    #[must_use]
144    pub const fn supports_replay(self) -> bool {
145        self.consistency.supports_replay()
146    }
147
148    /// Whether checkpoint commits are required for safe upstream progress.
149    #[must_use]
150    pub const fn requires_checkpointing(self) -> bool {
151        self.consistency.requires_checkpointing()
152    }
153}
154
155/// Durability protocol provided by a sink.
156///
157/// This describes externally observable behaviour, not an implementation
158/// detail such as whether the client library buffers or retries writes.
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
160pub enum SinkConsistency {
161    /// An accepted write can be lost when the connector or peer fails.
162    #[default]
163    Ephemeral,
164    /// Successful writes are durably acknowledged, but replay may duplicate
165    /// them because visibility is not coupled to a `LaminarDB` checkpoint.
166    DurableAtLeastOnce,
167    /// Output can be staged and made visible by the checkpoint commit
168    /// protocol. This is necessary, but not sufficient, for exactly-once
169    /// certification: namespaces and recovery cursors must also be fenced.
170    CheckpointCommittable,
171}
172
173/// How a sink may be placed across runtime nodes.
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
175pub enum SinkTopology {
176    /// Only one fenced runtime writer may target the configured destination.
177    #[default]
178    Singleton,
179    /// Independent runtime writers can safely target the destination.
180    MultiWriter,
181    /// Each runtime node owns a distinct local egress endpoint or audience.
182    NodeLocalEgress,
183}
184
185/// The strongest input update model a configured sink understands.
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
187pub enum SinkInputMode {
188    /// Inserts only; retractions or deletes would be lost.
189    #[default]
190    AppendOnly,
191    /// Rows are reconciled by a configured key, but the connector does not
192    /// consume a native full-changelog envelope.
193    KeyedUpsert,
194    /// Inserts, updates, and deletes/retractions are represented faithfully.
195    FullChangelog,
196}
197
198impl SinkInputMode {
199    /// Whether this mode can faithfully consume a full Z-set changelog,
200    /// including deletes/retractions.
201    #[must_use]
202    pub const fn accepts_full_changelog(self) -> bool {
203        matches!(self, Self::FullChangelog)
204    }
205}
206
207/// Complete sink admission contract for a concrete connector configuration.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
209pub struct SinkContract {
210    /// Durability and checkpoint-commit semantics.
211    pub consistency: SinkConsistency,
212    /// Valid runtime placement model.
213    pub topology: SinkTopology,
214    /// Strongest supported input update model.
215    pub input_mode: SinkInputMode,
216}
217
218impl SinkContract {
219    /// Construct a sink contract from its three explicit dimensions.
220    #[must_use]
221    pub const fn new(
222        consistency: SinkConsistency,
223        topology: SinkTopology,
224        input_mode: SinkInputMode,
225    ) -> Self {
226        Self {
227            consistency,
228            topology,
229            input_mode,
230        }
231    }
232
233    /// Whether this contract participates in checkpoint-owned external commit.
234    #[must_use]
235    pub const fn is_checkpoint_committable(self) -> bool {
236        matches!(self.consistency, SinkConsistency::CheckpointCommittable)
237    }
238
239    /// Whether this contract faithfully consumes inserts, updates, and retractions.
240    #[must_use]
241    pub const fn accepts_full_changelog(self) -> bool {
242        self.input_mode.accepts_full_changelog()
243    }
244}
245
246/// A batch of records read from a source connector.
247#[derive(Debug, Clone)]
248pub struct SourceBatch {
249    /// Arrow batch carrying the records.
250    pub records: RecordBatch,
251}
252
253impl SourceBatch {
254    /// Construct a source batch.
255    #[must_use]
256    pub fn new(records: RecordBatch) -> Self {
257        Self { records }
258    }
259
260    /// Record count in the batch.
261    #[must_use]
262    pub fn num_rows(&self) -> usize {
263        self.records.num_rows()
264    }
265}
266
267/// Summary of a successful `write_batch` call.
268#[derive(Debug, Clone)]
269pub struct WriteResult {
270    /// Records accepted by the sink.
271    pub records_written: usize,
272    /// Bytes written to the underlying transport (may be estimated).
273    pub bytes_written: u64,
274}
275
276impl WriteResult {
277    /// Construct with raw counts.
278    #[must_use]
279    pub fn new(records_written: usize, bytes_written: u64) -> Self {
280        Self {
281            records_written,
282            bytes_written,
283        }
284    }
285}
286
287/// What the runtime must do when a started connector operation is cancelled.
288///
289/// This is an internal connector/driver capability, not a deployment option.
290/// Cancellation always respects the runtime-owned deadline. A connector may be
291/// reused only when dropping the exact future is known to preserve its state;
292/// otherwise the runtime retires the complete connector generation.
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum ConnectorCancellationPolicy {
295    /// Dropping an in-flight future leaves the connector valid for recovery or reuse.
296    CancelSafe,
297    /// Dropping an in-flight future may leave its external outcome unknown, so
298    /// the connector instance must not process another operation.
299    RetireConnector,
300}
301
302const CONNECTOR_TASK_OWNER_DROPPED: usize = 1usize << (usize::BITS - 1);
303
304struct ConnectorTaskState {
305    state: AtomicUsize,
306    terminated: Notify,
307}
308
309/// Sole admission authority for detached tasks owned by one connector generation.
310///
311/// The owner must live inside the connector. Dropping it seals the generation so
312/// terminal completion can be observed after every admitted task guard is gone.
313pub struct ConnectorTaskOwner {
314    inner: Arc<ConnectorTaskState>,
315}
316
317/// Cloneable, non-owning admission handle for dynamically spawned connector tasks.
318///
319/// The handle does not keep the task generation open. Admission fails after
320/// the sole [`ConnectorTaskOwner`] is dropped, including when existing task
321/// guards still keep the generation observable.
322#[derive(Clone)]
323pub struct ConnectorTaskAdmission {
324    inner: Weak<ConnectorTaskState>,
325}
326
327/// Cloneable observer for terminal completion of one connector generation.
328#[derive(Clone)]
329pub struct ConnectorTaskTracker {
330    inner: Arc<ConnectorTaskState>,
331}
332
333/// RAII proof that one connector-owned task is still active.
334///
335/// Move the guard into the task before spawning it and retain it for the task's
336/// full lifetime.
337#[must_use = "dropping the guard marks its connector task complete"]
338pub struct ConnectorTaskGuard {
339    inner: Arc<ConnectorTaskState>,
340}
341
342impl ConnectorTaskOwner {
343    /// Create the sole task owner and its cloneable terminal observer.
344    #[must_use]
345    pub fn new() -> (Self, ConnectorTaskTracker) {
346        let inner = Arc::new(ConnectorTaskState {
347            state: AtomicUsize::new(0),
348            terminated: Notify::new(),
349        });
350        (
351            Self {
352                inner: Arc::clone(&inner),
353            },
354            ConnectorTaskTracker { inner },
355        )
356    }
357
358    /// Create a non-owning admission handle for tasks discovered by owned work.
359    ///
360    /// This is intended for accept loops and similar dynamic task producers.
361    /// Cloning the handle never extends the connector generation's admission
362    /// lifetime.
363    #[must_use]
364    pub fn admission(&self) -> ConnectorTaskAdmission {
365        ConnectorTaskAdmission {
366            inner: Arc::downgrade(&self.inner),
367        }
368    }
369
370    /// Admit one task into this live connector generation.
371    ///
372    /// The returned guard must be created before the task is spawned and moved
373    /// into that task. `None` means the generation can no longer admit work.
374    #[must_use]
375    pub fn track(&self) -> Option<ConnectorTaskGuard> {
376        track_connector_task(&self.inner)
377    }
378}
379
380impl ConnectorTaskAdmission {
381    /// Admit one dynamic task while the connector generation remains open.
382    ///
383    /// Returns `None` once the sole owner has been dropped. A successful
384    /// admission remains tracked until its returned guard is dropped.
385    #[must_use]
386    pub fn track(&self) -> Option<ConnectorTaskGuard> {
387        let inner = self.inner.upgrade()?;
388        track_connector_task(&inner)
389    }
390}
391
392fn track_connector_task(inner: &Arc<ConnectorTaskState>) -> Option<ConnectorTaskGuard> {
393    let mut observed = inner.state.load(Ordering::Acquire);
394    loop {
395        if observed & CONNECTOR_TASK_OWNER_DROPPED != 0 {
396            return None;
397        }
398        let next = observed.checked_add(1)?;
399        if next & CONNECTOR_TASK_OWNER_DROPPED != 0 {
400            return None;
401        }
402        match inner
403            .state
404            .compare_exchange_weak(observed, next, Ordering::AcqRel, Ordering::Acquire)
405        {
406            Ok(_) => {
407                return Some(ConnectorTaskGuard {
408                    inner: Arc::clone(inner),
409                });
410            }
411            Err(actual) => observed = actual,
412        }
413    }
414}
415
416impl Drop for ConnectorTaskOwner {
417    fn drop(&mut self) {
418        let previous = self
419            .inner
420            .state
421            .fetch_or(CONNECTOR_TASK_OWNER_DROPPED, Ordering::AcqRel);
422        debug_assert_eq!(previous & CONNECTOR_TASK_OWNER_DROPPED, 0);
423        if previous == 0 {
424            self.inner.terminated.notify_waiters();
425        }
426    }
427}
428
429impl ConnectorTaskTracker {
430    /// Whether the owner and all task guards have been dropped.
431    #[must_use]
432    pub fn is_terminated(&self) -> bool {
433        self.inner.state.load(Ordering::Acquire) == CONNECTOR_TASK_OWNER_DROPPED
434    }
435
436    /// Wait until the owner and all task guards have been dropped.
437    pub async fn wait_terminated(&self) {
438        loop {
439            let notified = self.inner.terminated.notified();
440            tokio::pin!(notified);
441            notified.as_mut().enable();
442            if self.is_terminated() {
443                return;
444            }
445            notified.await;
446        }
447    }
448}
449
450impl Drop for ConnectorTaskGuard {
451    fn drop(&mut self) {
452        let previous = self.inner.state.fetch_sub(1, Ordering::AcqRel);
453        debug_assert_ne!(previous & !CONNECTOR_TASK_OWNER_DROPPED, 0);
454        if previous == CONNECTOR_TASK_OWNER_DROPPED | 1 {
455            self.inner.terminated.notify_waiters();
456        }
457    }
458}
459
460/// Atomic startup position for a source connector.
461///
462/// A resume request carries both the durable checkpoint attempt and the
463/// connector checkpoint captured by that attempt. Connectors must install the
464/// position before `start` returns and before `poll_batch` can emit records.
465#[derive(Debug, Clone)]
466pub enum SourcePosition {
467    /// Start from the connector's configured deterministic initial position.
468    Initial,
469    /// Resume from an exact durable engine checkpoint.
470    Resume {
471        /// Checkpoint attempt that owns the connector state.
472        attempt: laminar_core::state::CheckpointAttempt,
473        /// Connector cursor captured by `attempt`.
474        checkpoint: SourceCheckpoint,
475    },
476}
477
478/// Complete source startup request.
479///
480/// Startup is intentionally a single operation so a connector cannot become
481/// externally active between opening resources and restoring its position.
482#[derive(Debug, Clone)]
483pub struct SourceStart {
484    /// Fully resolved connector configuration.
485    config: ConnectorConfig,
486    /// Initial or exact recovery position.
487    position: SourcePosition,
488    /// Pipeline-wide delivery guarantee used for fail-closed cursor policy.
489    delivery: DeliveryGuarantee,
490}
491
492impl SourceStart {
493    /// Construct a source startup request before any connector I/O.
494    ///
495    /// # Errors
496    /// Returns a configuration error when a resume attempt is zero or split across two identities.
497    pub fn new(
498        config: ConnectorConfig,
499        position: SourcePosition,
500        delivery: DeliveryGuarantee,
501    ) -> Result<Self, ConnectorError> {
502        if matches!(
503            &position,
504            SourcePosition::Resume { attempt, .. } if !attempt.is_canonical()
505        ) {
506            return Err(ConnectorError::ConfigurationError(
507                "source resume must use one nonzero canonical checkpoint ID".into(),
508            ));
509        }
510        Ok(Self {
511            config,
512            position,
513            delivery,
514        })
515    }
516
517    /// Consume the request into connector-owned startup inputs.
518    #[must_use]
519    pub fn into_parts(self) -> (ConnectorConfig, SourcePosition, DeliveryGuarantee) {
520        (self.config, self.position, self.delivery)
521    }
522}
523
524/// Exact cluster transition for which a source must stop advancing input.
525#[derive(Debug, Clone, PartialEq, Eq)]
526pub struct SourceDrainRequest {
527    /// Compact predecessor/target/leader identity.
528    pub round: laminar_core::checkpoint::AssignmentDrainId,
529}
530
531impl SourceDrainRequest {
532    /// Construct a canonical source drain request.
533    ///
534    /// # Errors
535    /// Returns an error when the transition identity is not canonical.
536    pub fn new(round: laminar_core::checkpoint::AssignmentDrainId) -> Result<Self, ConnectorError> {
537        if !round.is_canonical() {
538            return Err(ConnectorError::ConfigurationError(
539                "source drain round is not canonical".into(),
540            ));
541        }
542        Ok(Self { round })
543    }
544}
545
546/// Terminal resolution of one exact source drain round.
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548pub enum SourceDrainOutcome {
549    /// The target assignment committed and the source may adopt its target input ownership.
550    Commit,
551    /// The transition aborted and the source must resume from the predecessor cut.
552    Abort,
553}
554
555/// Exact round resolution delivered to a source connector.
556#[derive(Debug, Clone, Copy, PartialEq, Eq)]
557pub struct SourceDrainResolution {
558    /// Round being resolved.
559    pub round: laminar_core::checkpoint::AssignmentDrainId,
560    /// Durable transition outcome.
561    pub outcome: SourceDrainOutcome,
562}
563
564/// Trait for source connectors that read data from external systems.
565///
566/// Source connectors operate in Ring 1 and push data into Ring 0 via
567/// the streaming `Source<ArrowRecord>::push_arrow()` API.
568///
569/// # Lifecycle
570///
571/// 1. `start()` — atomically install the configured or recovered cursor and initialize the reader
572/// 2. `poll_batch()` — read batches in a loop
573/// 3. `checkpoint()` — capture the current connector cursor
574/// 4. `close()` — clean shutdown
575#[async_trait]
576pub trait SourceConnector: Send {
577    /// Deadline behavior required by the underlying client implementation.
578    ///
579    /// Retirement is the conservative default: a new connector must not be
580    /// reused after cancellation until every lifecycle future has been audited.
581    fn cancellation_policy(&self) -> ConnectorCancellationPolicy {
582        ConnectorCancellationPolicy::RetireConnector
583    }
584
585    /// Observe detached tasks whose lifetime may outlast this connector value.
586    ///
587    /// A connector that spawns detached work must retain the matching
588    /// [`ConnectorTaskOwner`] and move a guard into every task. The runtime can
589    /// then wait for true terminal completion after dropping the connector.
590    fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
591        None
592    }
593
594    /// Opens the source and establishes its initial or resumed position as one
595    /// indivisible lifecycle transition.
596    ///
597    /// Implementations must not emit records or expose an externally active
598    /// consumer before the requested position has been applied successfully.
599    async fn start(&mut self, request: SourceStart) -> Result<(), ConnectorError>;
600
601    /// `Ok(None)` = no data currently available; runtime retries after a delay.
602    /// `max_records` is the normal batching target. A source may exceed it only
603    /// when one upstream atomic replay unit cannot be split without making its
604    /// checkpoint cursor invalid. Such sources must enforce independent hard
605    /// record and byte limits and fail before retained data can grow unbounded.
606    ///
607    /// The runtime may cancel this future at a shutdown or authority deadline.
608    /// [`ConnectorCancellationPolicy::CancelSafe`] implementations must not
609    /// advance external or checkpoint-visible position across an `.await`
610    /// unless dropping the future there preserves replay of every
611    /// not-yet-returned record. Stage work privately, then advance the cursor
612    /// and return without another cancellation point. The conservative
613    /// [`ConnectorCancellationPolicy::RetireConnector`] policy instead makes
614    /// the complete connector generation terminal after cancellation.
615    async fn poll_batch(
616        &mut self,
617        max_records: usize,
618    ) -> Result<Option<SourceBatch>, ConnectorError>;
619
620    /// Resolve the source schema from the `WITH (...)` properties before
621    /// DDL reaches the planner. Implementations that perform network I/O must
622    /// bound it with a timeout. Return `Err(ConnectorError::…)` on failure so
623    /// the runtime can surface the cause to DDL — do not log and swallow.
624    async fn discover_schema(
625        &mut self,
626        _properties: &std::collections::HashMap<String, String>,
627    ) -> Result<(), ConnectorError> {
628        Ok(())
629    }
630
631    /// Arrow schema of records this source produces.
632    fn schema(&self) -> SchemaRef;
633
634    /// Returned checkpoint must contain enough info to resume from the
635    /// current position after a restart.
636    fn checkpoint(&self) -> SourceCheckpoint;
637
638    /// Atomically attempt to capture a source cursor. `Ok(None)` means a
639    /// transient control-plane publication is not reconciled yet; the runtime
640    /// retains the barrier and retries without advancing it.
641    ///
642    /// # Errors
643    /// Returns a connector error when the source cannot produce a valid cursor for the current
644    /// ownership/configuration state.
645    fn try_checkpoint(&self) -> Result<Option<SourceCheckpoint>, ConnectorError> {
646        Ok(Some(self.checkpoint()))
647    }
648
649    /// Whether this source's data-plane cursor is reconciled with the current
650    /// control-plane ownership publication. The runtime does not poll records
651    /// or admit checkpoint barriers while this is false.
652    ///
653    /// Non-partitioned and local sources are always ready. Cluster-aware
654    /// sources override this with a lock-free version fence.
655    ///
656    /// # Errors
657    /// Returns a connector error when control-plane reconciliation detects invalid or lost
658    /// ownership state that cannot be retried safely.
659    fn checkpoint_ready(&self) -> Result<bool, ConnectorError> {
660        Ok(true)
661    }
662
663    /// Start or advance connector-side control-plane work needed to become
664    /// [`checkpoint_ready`](Self::checkpoint_ready). Called even while data
665    /// polling and barriers are fenced.
666    fn drive_control_plane(&mut self) {}
667
668    /// Stop advancing external input for an exact cluster transition.
669    ///
670    /// Implementations start the provider operation without blocking and later expose readiness
671    /// through [`Self::poll_drain_ready`]. `deadline` is the engine-owned absolute deadline for
672    /// this drain attempt; provider retries must not continue beyond it. Actor-only sources are
673    /// fenced by the engine and never call this hook; assignment-scoped sources must implement it
674    /// explicitly.
675    ///
676    /// # Errors
677    /// Returns an error when the provider cannot start this exact drain round.
678    fn begin_drain(
679        &mut self,
680        _request: &SourceDrainRequest,
681        _deadline: tokio::time::Instant,
682    ) -> Result<(), ConnectorError> {
683        Err(ConnectorError::ConfigurationError(
684            "assignment-scoped source does not implement provider drain".into(),
685        ))
686    }
687
688    /// Whether the exact provider FIFO boundary has been consumed.
689    ///
690    /// Returns `false` while the reader is still pausing or while pre-boundary payloads remain.
691    ///
692    /// # Errors
693    /// Returns an error when drain progress cannot be observed safely.
694    fn poll_drain_ready(
695        &mut self,
696        _round: laminar_core::checkpoint::AssignmentDrainId,
697    ) -> Result<bool, ConnectorError> {
698        Err(ConnectorError::ConfigurationError(
699            "assignment-scoped source does not expose provider drain readiness".into(),
700        ))
701    }
702
703    /// Resolve an exact drain after target commit or abort.
704    ///
705    /// An abort must rewind any client-delivered but engine-unaccepted records before resuming.
706    /// A commit must reconcile target ownership before clearing its post-cut filter.
707    /// `deadline` is the engine-owned absolute deadline for the complete resolution; provider
708    /// retries and blocking client calls must be bounded by its remaining time.
709    async fn finish_drain(
710        &mut self,
711        _resolution: SourceDrainResolution,
712        _deadline: tokio::time::Instant,
713    ) -> Result<(), ConnectorError> {
714        Err(ConnectorError::ConfigurationError(
715            "assignment-scoped source does not implement provider drain resolution".into(),
716        ))
717    }
718
719    /// Install the cluster vnode assignment for a source that advertises
720    /// [`SourceTopology::Splittable`]. The source identity is the stable,
721    /// canonical catalog object name and must be part of any external split
722    /// mapping ABI.
723    ///
724    /// Embedded, single-node, singleton, and node-local sources are not sent
725    /// this hook. The default fails closed so an extension cannot advertise
726    /// splittable placement while every cluster node reads the full input.
727    ///
728    /// # Errors
729    /// Returns a configuration error unless the connector implements exact
730    /// vnode-scoped input ownership.
731    fn set_vnode_assignment(
732        &mut self,
733        _source_identity: &str,
734        _registry: Arc<laminar_core::state::VnodeRegistry>,
735        _self_id: laminar_core::state::NodeId,
736    ) -> Result<(), ConnectorError> {
737        Err(ConnectorError::ConfigurationError(
738            "source advertises splittable placement but does not implement vnode assignment".into(),
739        ))
740    }
741
742    /// Close the connection and release resources.
743    async fn close(&mut self) -> Result<(), ConnectorError>;
744
745    /// Returns a [`Notify`] handle that is signalled when new data is available.
746    ///
747    /// When `Some`, the pipeline coordinator awaits the notification instead of
748    /// polling on a timer, eliminating idle CPU usage. Push-driven sources should
749    /// return `Some` and call `notify.notify_one()` when data arrives.
750    ///
751    /// The default implementation returns `None`, which causes the pipeline to
752    /// fall back to timer-based polling (suitable for batch/file sources).
753    fn data_ready_notify(&self) -> Option<Arc<Notify>> {
754        None
755    }
756
757    /// Declare recovery and placement semantics for this exact configuration.
758    ///
759    /// The fail-closed default is an ephemeral singleton. Durable or
760    /// distributed semantics must be opted into by the connector explicitly.
761    ///
762    /// # Errors
763    ///
764    /// Returns an error when the concrete configuration cannot provide a valid
765    /// recovery or placement contract. The default implementation never fails.
766    fn contract(&self, _config: &ConnectorConfig) -> Result<SourceContract, ConnectorError> {
767        Ok(SourceContract::default())
768    }
769
770    /// Return connector-owned semantic options for durable recovery identity.
771    ///
772    /// The hook must be deterministic, configuration-only, and free of external
773    /// I/O. `Some` replaces the raw property map in the pipeline identity;
774    /// `None` asks the runtime to use its conservative sanitized-property
775    /// fallback. A connector may omit operational endpoints or credentials only
776    /// when its checkpoint independently binds the exact external object.
777    ///
778    /// # Errors
779    ///
780    /// Returns a configuration error when the semantic identity cannot be
781    /// derived from the supplied source configuration.
782    fn recovery_identity_options(
783        &self,
784        _config: &ConnectorConfig,
785    ) -> Result<Option<std::collections::BTreeMap<String, String>>, ConnectorError> {
786        Ok(None)
787    }
788
789    /// Acknowledge that `epoch` has been durably committed.
790    ///
791    /// Called after the manifest and exact engine commit decision are durable. Coordinated
792    /// external sink publication may complete asynchronously afterward. The `checkpoint` is the exact
793    /// per-source `SourceCheckpoint` that was persisted into the manifest
794    /// for this epoch — sources can rely on it to advance external offset
795    /// state (broker group offsets, lookup-DB cursors, ack tokens) using
796    /// values that match what's durable.
797    ///
798    /// May be called with an empty `checkpoint` for timer-driven commits
799    /// where no per-source state was captured; implementations should
800    /// treat that as a no-op for any externally-visible advancement.
801    ///
802    /// Idempotent — a retry after cancellation is legal.
803    ///
804    /// # Errors
805    ///
806    /// The epoch is already durable and cannot be rolled back. During normal processing an error
807    /// faults replay-capable pipelines so recovery retries the advisory upstream commit; during
808    /// shutdown it is logged and the durable `LaminarDB` checkpoint remains authoritative.
809    async fn notify_epoch_committed(
810        &mut self,
811        _epoch: u64,
812        _checkpoint: &SourceCheckpoint,
813    ) -> Result<(), ConnectorError> {
814        Ok(())
815    }
816}
817
818/// Trait for sink connectors that write data to external systems.
819///
820/// Sink connectors operate in Ring 1, receiving data from Ring 0 and
821/// writing to external systems. Implementations whose contract is
822/// [`SinkConsistency::CheckpointCommittable`] prepare checkpoint-owned
823/// committables with `begin_epoch`/`pre_commit`, expose a
824/// [`CoordinatedCommitter`] for the single external commit, and implement
825/// `rollback_epoch`; the runtime drives them via the checkpoint coordinator.
826///
827/// All sinks follow `open()` → `write_batch()`/`flush()` → `close()`.
828/// Checkpoint-committable sinks additionally loop over `begin_epoch()`, staged
829/// writes, `pre_commit()`, and coordinated commit (or `rollback_epoch()` on a
830/// proven pre-decision failure).
831#[async_trait]
832pub trait SinkConnector: Send {
833    /// Deadline behavior required by the underlying client implementation.
834    ///
835    /// Retirement is the conservative default: a new connector must not be
836    /// reused after cancellation until every lifecycle future has been audited.
837    fn cancellation_policy(&self) -> ConnectorCancellationPolicy {
838        ConnectorCancellationPolicy::RetireConnector
839    }
840
841    /// Observe detached tasks whose lifetime may outlast this connector value.
842    ///
843    /// A connector that spawns detached work must retain the matching
844    /// [`ConnectorTaskOwner`] and move a guard into every task. The runtime can
845    /// then wait for true terminal completion after dropping the connector.
846    fn terminal_task_tracker(&self) -> Option<ConnectorTaskTracker> {
847        None
848    }
849
850    /// Declare durability, placement, and input semantics for this exact
851    /// configuration without opening files, sockets, clients, or transactions.
852    ///
853    /// The fail-closed default is an ephemeral append-only singleton. Durable
854    /// or distributed behaviour must be opted into explicitly.
855    ///
856    /// # Errors
857    ///
858    /// Returns an error when the concrete configuration cannot provide a valid
859    /// durability, placement, or input contract. The default implementation never fails.
860    fn contract(&self, _config: &ConnectorConfig) -> Result<SinkContract, ConnectorError> {
861        Ok(SinkContract::default())
862    }
863
864    /// Open the connection and prepare to accept writes.
865    async fn open(&mut self, config: &ConnectorConfig) -> Result<(), ConnectorError>;
866
867    /// Implementations using [`ConnectorCancellationPolicy::CancelSafe`] must
868    /// remain valid when this future is dropped at a deadline. For
869    /// [`ConnectorCancellationPolicy::RetireConnector`], cancellation makes the
870    /// complete connector instance terminal before later work can be processed.
871    async fn write_batch(&mut self, batch: &RecordBatch) -> Result<WriteResult, ConnectorError>;
872
873    /// Expected Arrow schema of input batches.
874    fn schema(&self) -> SchemaRef;
875
876    /// Begin checkpoint-owned staging. Called only for an admitted
877    /// checkpoint-committable contract; weaker sinks use the no-op default.
878    async fn begin_epoch(&mut self, _epoch: u64) -> Result<(), ConnectorError> {
879        Ok(())
880    }
881
882    /// Flush + prepare, but do not finalize externally. The runtime persists
883    /// the checkpoint decision before a designated committer finalizes the
884    /// collected descriptors; on failure it calls `rollback_epoch`.
885    ///
886    /// Returns an opaque commit descriptor for checkpoint-committable sinks (the
887    /// committables the designated committer will aggregate), else `None`.
888    /// Default delegates to `flush()` and returns `None`.
889    ///
890    /// # Errors
891    /// Returns `ConfigurationError` if the sink exposes a coordinated committer
892    /// yet relies on this default — it would seal epochs with no external commit.
893    async fn pre_commit(&mut self, _epoch: u64) -> Result<Option<Vec<u8>>, ConnectorError> {
894        if self.as_coordinated_committer().is_some() {
895            return Err(ConnectorError::ConfigurationError(
896                "sink exposes a coordinated committer but does not override pre_commit".into(),
897            ));
898        }
899        self.flush().await?;
900        Ok(None)
901    }
902
903    /// Must be idempotent. The runtime calls this on every
904    /// checkpoint-committable sink after proving a pre-decision failure,
905    /// including sinks that never completed `pre_commit`.
906    async fn rollback_epoch(&mut self, _epoch: u64) -> Result<(), ConnectorError> {
907        Ok(())
908    }
909
910    /// Default per-call `write_batch` I/O timeout. Users can override this via
911    /// the `sink.write.timeout.ms` connector property.
912    fn suggested_write_timeout(&self) -> std::time::Duration;
913
914    /// Maximum residence time for a non-empty sink buffer before the runtime
915    /// invokes [`flush`](Self::flush). Checkpoint-committable sinks ignore the
916    /// periodic timer and flush only through their checkpoint protocol.
917    fn flush_interval(&self) -> std::time::Duration {
918        std::time::Duration::from_secs(5)
919    }
920
921    /// Must be internally bounded — the sink task's periodic timer
922    /// calls this on every tick. Thorough drains belong in `pre_commit`
923    /// / coordinated commit / `close`, not here.
924    async fn flush(&mut self) -> Result<(), ConnectorError> {
925        Ok(())
926    }
927
928    /// Close the sink and release resources.
929    async fn close(&mut self) -> Result<(), ConnectorError>;
930
931    /// Leader-side committer for a checkpoint-committable contract; `None`
932    /// for every weaker contract.
933    fn as_coordinated_committer(&self) -> Option<&dyn CoordinatedCommitter> {
934        None
935    }
936}
937
938/// Fixed control-plane bound for one connector's coordinated-commit payload.
939///
940/// Connectors must keep prepared metadata at or below this limit before
941/// returning it to the checkpoint runtime. Bulk records belong in the sink's
942/// data plane, referenced by the bounded payload.
943pub const MAX_COORDINATED_COMMIT_PAYLOAD_BYTES: usize = 16 * 1024 * 1024;
944
945/// Fixed aggregate control-plane bound for one designated commit call.
946pub const MAX_COORDINATED_COMMIT_BATCH_BYTES: usize = 64 * 1024 * 1024;
947
948/// Fixed participant-marker bound for one designated commit call.
949pub const MAX_COORDINATED_COMMIT_BATCH_ENTRIES: usize = 4_096;
950
951/// Stable external commit namespace for one deployment incarnation of a logical pipeline sink.
952///
953/// The configured external target already scopes its metadata. The create-once deployment id
954/// prevents checkpoint-store resets or two
955/// identically configured deployments from sharing a cursor. Pipeline identity plus sink id then
956/// binds that deployment to one recovery-compatible logical writer.
957#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
958pub struct CoordinatedCommitNamespace {
959    /// Canonical logical-pipeline identity used by checkpoint recovery.
960    pub pipeline_identity: laminar_core::checkpoint::checkpoint_manifest::PipelineIdentity,
961    /// Create-once UUID stored with checkpoint decisions and shared by every cluster member.
962    pub deployment_id: String,
963    /// Stable sink registration id within the pipeline.
964    pub sink_id: String,
965}
966
967impl CoordinatedCommitNamespace {
968    /// Construct and validate a namespace before any external metadata lookup.
969    ///
970    /// # Errors
971    /// Returns a configuration error for a malformed pipeline digest or empty
972    /// sink id.
973    pub fn try_new(
974        pipeline_identity: laminar_core::checkpoint::checkpoint_manifest::PipelineIdentity,
975        deployment_id: impl Into<String>,
976        sink_id: impl Into<String>,
977    ) -> Result<Self, ConnectorError> {
978        let deployment_id = deployment_id.into();
979        let sink_id = sink_id.into();
980        if pipeline_identity.sha256.len() != 64
981            || !pipeline_identity
982                .sha256
983                .bytes()
984                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
985        {
986            return Err(ConnectorError::ConfigurationError(
987                "coordinated commit requires a canonical lowercase SHA-256 pipeline identity"
988                    .into(),
989            ));
990        }
991        if sink_id.is_empty() {
992            return Err(ConnectorError::ConfigurationError(
993                "coordinated commit sink id cannot be empty".into(),
994            ));
995        }
996        let parsed_deployment = uuid::Uuid::parse_str(&deployment_id).map_err(|error| {
997            ConnectorError::ConfigurationError(format!(
998                "coordinated commit deployment id is not a UUID: {error}"
999            ))
1000        })?;
1001        if parsed_deployment.is_nil() || parsed_deployment.to_string() != deployment_id {
1002            return Err(ConnectorError::ConfigurationError(
1003                "coordinated commit deployment id must be a canonical non-nil UUID".into(),
1004            ));
1005        }
1006        Ok(Self {
1007            pipeline_identity,
1008            deployment_id,
1009            sink_id,
1010        })
1011    }
1012
1013    /// Bounded, filesystem/catalog-safe key for external transaction metadata.
1014    #[must_use]
1015    pub fn external_key(&self) -> String {
1016        let mut digest = Sha256::new();
1017        digest.update(self.pipeline_identity.canonical_version.to_be_bytes());
1018        digest.update(self.pipeline_identity.sha256.as_bytes());
1019        digest.update([0]);
1020        digest.update(self.deployment_id.as_bytes());
1021        digest.update([0]);
1022        digest.update(self.sink_id.as_bytes());
1023        let digest = digest.finalize();
1024        format!("ldb-c3-{digest:x}")
1025    }
1026}
1027
1028/// Exact external commit position and the authority that published it.
1029#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1030pub struct CoordinatedCommitCursor {
1031    /// Highest globally unique checkpoint id atomically reflected by the sink.
1032    pub checkpoint_id: u64,
1033    /// Monotonic authority token that fenced earlier designated committers.
1034    pub fencing_token: u64,
1035}
1036
1037/// One participant's validated prepared marker for one exact attempt.
1038#[derive(Debug, Clone, PartialEq, Eq)]
1039pub struct CoordinatedCommitPayload {
1040    /// Exact checkpoint attempt that admitted this marker.
1041    pub attempt: laminar_core::state::CheckpointAttempt,
1042    /// Stable runtime participant id (`0` in local modes).
1043    pub participant_id: u64,
1044    /// Connector-specific committable, or `None` for an explicitly empty cut.
1045    pub payload: Option<Vec<u8>>,
1046}
1047
1048/// Exact batch submitted to a designated external-sink committer.
1049#[derive(Debug, Clone, PartialEq, Eq)]
1050pub struct CoordinatedCommitBatch {
1051    /// External cursor namespace.
1052    pub namespace: CoordinatedCommitNamespace,
1053    /// Exact external cursor that must precede this batch. The zero cursor names
1054    /// an empty target. A different authority at the predecessor checkpoint is
1055    /// a conflicting history and must fail closed.
1056    pub expected_predecessor: CoordinatedCommitCursor,
1057    /// Non-zero authority token that the external commit must persist atomically.
1058    pub fencing_token: u64,
1059    /// Highest exact attempt atomically covered by this commit.
1060    pub target: laminar_core::state::CheckpointAttempt,
1061    /// Every sealed participant marker through `target`, including empty ones.
1062    pub entries: Vec<CoordinatedCommitPayload>,
1063}
1064
1065/// Runtime-owned deadline for one designated external publication.
1066///
1067/// The deadline is created before the command enters the sink actor, so a
1068/// connector sees the actual budget left after queueing rather than a second,
1069/// connector-local timeout window.
1070#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1071pub struct CoordinatedCommitContext {
1072    deadline: tokio::time::Instant,
1073}
1074
1075impl CoordinatedCommitContext {
1076    /// Create a context from the sink actor's absolute end-to-end deadline.
1077    #[must_use]
1078    pub const fn new(deadline: tokio::time::Instant) -> Self {
1079        Self { deadline }
1080    }
1081
1082    /// Absolute monotonic publication deadline.
1083    #[must_use]
1084    pub const fn deadline(self) -> tokio::time::Instant {
1085        self.deadline
1086    }
1087
1088    /// Budget still available at the point the connector starts publication.
1089    #[must_use]
1090    pub fn remaining(self) -> std::time::Duration {
1091        self.deadline
1092            .saturating_duration_since(tokio::time::Instant::now())
1093    }
1094}
1095
1096impl CoordinatedCommitBatch {
1097    /// Collision-resistant identity for one exact ordered publication cut.
1098    /// Every variable-length field is length framed so distinct batches cannot
1099    /// share an input byte stream before hashing.
1100    #[must_use]
1101    pub fn exact_fingerprint(&self) -> [u8; 32] {
1102        fn update_length(hasher: &mut Sha256, length: usize) {
1103            let source = length.to_be_bytes();
1104            let mut encoded = [0_u8; 16];
1105            let start = encoded.len() - source.len();
1106            encoded[start..].copy_from_slice(&source);
1107            hasher.update(encoded);
1108        }
1109
1110        fn update_framed(hasher: &mut Sha256, bytes: &[u8]) {
1111            update_length(hasher, bytes.len());
1112            hasher.update(bytes);
1113        }
1114
1115        let mut hasher = Sha256::new();
1116        update_framed(&mut hasher, b"laminardb/coordinated-commit-batch/v1");
1117        update_framed(&mut hasher, self.namespace.external_key().as_bytes());
1118        hasher.update(self.expected_predecessor.checkpoint_id.to_be_bytes());
1119        hasher.update(self.expected_predecessor.fencing_token.to_be_bytes());
1120        hasher.update(self.fencing_token.to_be_bytes());
1121        hasher.update(self.target.epoch.to_be_bytes());
1122        hasher.update(self.target.checkpoint_id.to_be_bytes());
1123        update_length(&mut hasher, self.entries.len());
1124        for entry in &self.entries {
1125            hasher.update(entry.attempt.epoch.to_be_bytes());
1126            hasher.update(entry.attempt.checkpoint_id.to_be_bytes());
1127            hasher.update(entry.participant_id.to_be_bytes());
1128            match &entry.payload {
1129                Some(payload) => {
1130                    hasher.update([1]);
1131                    update_framed(&mut hasher, payload);
1132                }
1133                None => hasher.update([0]),
1134            }
1135        }
1136        hasher.finalize().into()
1137    }
1138
1139    /// Validate canonical attempt/participant order and all fixed control-plane bounds.
1140    /// This check is independent of external state and must run before connector I/O.
1141    ///
1142    /// # Errors
1143    /// Returns a diagnostic when the batch is malformed or exceeds a fixed bound.
1144    pub fn validate_shape(&self) -> Result<(), String> {
1145        use laminar_core::state::CheckpointAttemptRelation;
1146
1147        if !self.target.is_canonical() {
1148            return Err(
1149                "coordinated batch target must use one nonzero canonical checkpoint ID".into(),
1150            );
1151        }
1152        if let Some(entry) = self
1153            .entries
1154            .iter()
1155            .find(|entry| !entry.attempt.is_canonical())
1156        {
1157            return Err(format!(
1158                "coordinated batch entry for participant {} must use one nonzero canonical checkpoint ID",
1159                entry.participant_id
1160            ));
1161        }
1162        if self.expected_predecessor.checkpoint_id >= self.target.checkpoint_id {
1163            return Err(format!(
1164                "invalid coordinated batch predecessor {} for target {}",
1165                self.expected_predecessor.checkpoint_id, self.target.checkpoint_id
1166            ));
1167        }
1168        if (self.expected_predecessor.checkpoint_id == 0)
1169            != (self.expected_predecessor.fencing_token == 0)
1170        {
1171            return Err(
1172                "coordinated batch predecessor must be either an exact non-zero cursor or the zero cursor"
1173                    .into(),
1174            );
1175        }
1176        if self.fencing_token == 0 {
1177            return Err("coordinated batch fencing token must be non-zero".into());
1178        }
1179        if self.entries.is_empty() || self.entries.len() > MAX_COORDINATED_COMMIT_BATCH_ENTRIES {
1180            return Err(format!(
1181                "coordinated batch entry count must be in 1..={MAX_COORDINATED_COMMIT_BATCH_ENTRIES}"
1182            ));
1183        }
1184
1185        let mut total_payload_bytes = 0usize;
1186        let mut previous: Option<&CoordinatedCommitPayload> = None;
1187        for entry in &self.entries {
1188            if entry.attempt.checkpoint_id <= self.expected_predecessor.checkpoint_id
1189                || entry.attempt.checkpoint_id > self.target.checkpoint_id
1190            {
1191                return Err(
1192                    "coordinated batch entries do not cover the predecessor-to-target interval"
1193                        .into(),
1194                );
1195            }
1196            if let Some(payload) = &entry.payload {
1197                if payload.len() > MAX_COORDINATED_COMMIT_PAYLOAD_BYTES {
1198                    return Err(format!(
1199                        "coordinated participant payload exceeds the fixed {MAX_COORDINATED_COMMIT_PAYLOAD_BYTES} byte limit"
1200                    ));
1201                }
1202                total_payload_bytes = total_payload_bytes
1203                    .checked_add(payload.len())
1204                    .ok_or_else(|| "coordinated batch payload byte count overflow".to_owned())?;
1205                if total_payload_bytes > MAX_COORDINATED_COMMIT_BATCH_BYTES {
1206                    return Err(format!(
1207                        "coordinated batch payloads exceed the fixed {MAX_COORDINATED_COMMIT_BATCH_BYTES} byte limit"
1208                    ));
1209                }
1210            }
1211
1212            if let Some(previous) = previous {
1213                match entry.attempt.relation_to(previous.attempt) {
1214                    CheckpointAttemptRelation::Exact
1215                        if entry.participant_id > previous.participant_id => {}
1216                    CheckpointAttemptRelation::Newer => {}
1217                    CheckpointAttemptRelation::Exact => {
1218                        return Err(
1219                            "coordinated batch contains a duplicate or out-of-order attempt/participant key"
1220                                .into(),
1221                        );
1222                    }
1223                    CheckpointAttemptRelation::Older | CheckpointAttemptRelation::Conflict => {
1224                        return Err(
1225                            "coordinated batch attempts are not in coherent epoch/checkpoint order"
1226                                .into(),
1227                        );
1228                    }
1229                }
1230            }
1231            previous = Some(entry);
1232        }
1233        if previous.map(|entry| entry.attempt) != Some(self.target) {
1234            return Err("coordinated batch target is not its final exact attempt".into());
1235        }
1236        Ok(())
1237    }
1238
1239    /// Validate a cursor freshly read from the external target against this
1240    /// exact batch. Advancing overlap is safe only at an attempt named by the
1241    /// batch; rollback or an unproven gap would skip output.
1242    ///
1243    /// # Errors
1244    ///
1245    /// Returns a diagnostic when the batch is malformed, the observed cursor
1246    /// proves rollback, or an overlap cannot be tied to an exact batch entry.
1247    pub fn validate_observed_cursor(
1248        &self,
1249        observed: Option<CoordinatedCommitCursor>,
1250    ) -> Result<(), String> {
1251        self.validate_shape()?;
1252        let Some(observed) = observed else {
1253            return if self.expected_predecessor.checkpoint_id == 0 {
1254                Ok(())
1255            } else {
1256                Err(format!(
1257                    "external cursor is absent below expected predecessor {}",
1258                    self.expected_predecessor.checkpoint_id
1259                ))
1260            };
1261        };
1262        if observed.fencing_token == 0 {
1263            return Err("external cursor contains a zero fencing token".into());
1264        }
1265        if observed.fencing_token > self.fencing_token {
1266            return Err(format!(
1267                "external fencing token {} is newer than designated committer token {}",
1268                observed.fencing_token, self.fencing_token
1269            ));
1270        }
1271        if observed.checkpoint_id >= self.target.checkpoint_id
1272            && observed.fencing_token != self.fencing_token
1273        {
1274            return Err(format!(
1275                "external cursor at or above target {} has fencing token {}, expected {}",
1276                self.target.checkpoint_id, observed.fencing_token, self.fencing_token
1277            ));
1278        }
1279        if observed.checkpoint_id < self.expected_predecessor.checkpoint_id {
1280            return Err(format!(
1281                "external cursor rolled back from expected predecessor {} to {}",
1282                self.expected_predecessor.checkpoint_id, observed.checkpoint_id
1283            ));
1284        }
1285        if observed.checkpoint_id == self.expected_predecessor.checkpoint_id
1286            && observed != self.expected_predecessor
1287        {
1288            return Err(format!(
1289                "external cursor checkpoint {} has fencing token {}, expected predecessor token {}",
1290                observed.checkpoint_id,
1291                observed.fencing_token,
1292                self.expected_predecessor.fencing_token
1293            ));
1294        }
1295        if observed.checkpoint_id > self.expected_predecessor.checkpoint_id
1296            && observed.fencing_token < self.expected_predecessor.fencing_token
1297        {
1298            return Err(format!(
1299                "external cursor advanced past predecessor {} while fencing token regressed from {} to {}",
1300                self.expected_predecessor.checkpoint_id,
1301                self.expected_predecessor.fencing_token,
1302                observed.fencing_token
1303            ));
1304        }
1305        if observed.checkpoint_id < self.target.checkpoint_id
1306            && observed.checkpoint_id != self.expected_predecessor.checkpoint_id
1307            && !self
1308                .entries
1309                .iter()
1310                .any(|entry| entry.attempt.checkpoint_id == observed.checkpoint_id)
1311        {
1312            return Err(format!(
1313                "external cursor {} is not an exact attempt in batch {}..={}",
1314                observed.checkpoint_id,
1315                self.expected_predecessor.checkpoint_id,
1316                self.target.checkpoint_id
1317            ));
1318        }
1319        Ok(())
1320    }
1321}
1322
1323/// Leader-side commit for checkpoint-committable sinks.
1324///
1325/// The designated committer aggregates every writer's `pre_commit` descriptor
1326/// for an epoch into one external commit. Must be idempotent: re-running with
1327/// the same inputs after a leader failover is a no-op once the target already
1328/// reflects the epoch.
1329#[async_trait]
1330pub trait CoordinatedCommitter: Send + Sync {
1331    /// Atomically commit the validated participant markers and advance the
1332    /// namespaced external cursor to the batch's exact target. Empty markers
1333    /// still advance the cursor.
1334    async fn commit_aggregated(
1335        &self,
1336        batch: CoordinatedCommitBatch,
1337        context: CoordinatedCommitContext,
1338    ) -> Result<(), ConnectorError>;
1339
1340    /// Highest checkpoint and fencing authority committed in `namespace`.
1341    /// A metadata read error must be returned, never converted to an absent
1342    /// cursor, because that could duplicate a previously committed batch.
1343    async fn committed_cursor(
1344        &self,
1345        namespace: &CoordinatedCommitNamespace,
1346    ) -> Result<Option<CoordinatedCommitCursor>, ConnectorError>;
1347}
1348
1349#[cfg(test)]
1350#[allow(clippy::cast_possible_wrap)]
1351mod tests {
1352    use super::*;
1353    use arrow_array::Int64Array;
1354    use arrow_schema::{DataType, Field, Schema};
1355    use std::sync::Arc;
1356
1357    fn test_schema() -> SchemaRef {
1358        Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]))
1359    }
1360
1361    fn test_batch(n: usize) -> RecordBatch {
1362        #[allow(clippy::cast_possible_wrap)]
1363        let ids: Vec<i64> = (0..n as i64).collect();
1364        RecordBatch::try_new(test_schema(), vec![Arc::new(Int64Array::from(ids))]).unwrap()
1365    }
1366
1367    #[test]
1368    fn connector_task_generation_terminates_after_owner_and_every_guard() {
1369        let (owner, tracker) = ConnectorTaskOwner::new();
1370        let first = owner.track().expect("live generation");
1371        let second = owner.track().expect("live generation");
1372
1373        assert!(!tracker.is_terminated());
1374        drop(owner);
1375        assert!(!tracker.is_terminated());
1376        drop(first);
1377        assert!(!tracker.is_terminated());
1378        drop(second);
1379        assert!(tracker.is_terminated());
1380    }
1381
1382    #[test]
1383    fn connector_task_admission_is_sealed_by_owner_drop() {
1384        let (owner, tracker) = ConnectorTaskOwner::new();
1385        let admission = owner.admission();
1386        let admission_clone = admission.clone();
1387        let admitted = admission.track().expect("live generation");
1388
1389        drop(owner);
1390
1391        assert!(admission.track().is_none());
1392        assert!(admission_clone.track().is_none());
1393        assert!(!tracker.is_terminated());
1394        drop(admitted);
1395        assert!(tracker.is_terminated());
1396    }
1397
1398    #[test]
1399    fn connector_task_admission_does_not_retain_generation_state() {
1400        let (owner, tracker) = ConnectorTaskOwner::new();
1401        let admission = owner.admission();
1402
1403        drop(owner);
1404        drop(tracker);
1405
1406        assert!(admission.inner.upgrade().is_none());
1407        assert!(admission.track().is_none());
1408    }
1409
1410    #[tokio::test]
1411    async fn connector_task_wait_wakes_every_tracker_clone() {
1412        let (owner, tracker) = ConnectorTaskOwner::new();
1413        let guard = owner.track().expect("live generation");
1414        let first = tokio::spawn({
1415            let tracker = tracker.clone();
1416            async move { tracker.wait_terminated().await }
1417        });
1418        let second = tokio::spawn({
1419            let tracker = tracker.clone();
1420            async move { tracker.wait_terminated().await }
1421        });
1422
1423        drop(owner);
1424        assert!(!tracker.is_terminated());
1425        drop(guard);
1426
1427        tokio::time::timeout(std::time::Duration::from_secs(1), async {
1428            first.await.expect("first waiter task");
1429            second.await.expect("second waiter task");
1430        })
1431        .await
1432        .expect("tracker waiters must wake");
1433        tracker.wait_terminated().await;
1434    }
1435
1436    #[test]
1437    fn test_source_batch() {
1438        let batch = SourceBatch::new(test_batch(10));
1439        assert_eq!(batch.num_rows(), 10);
1440    }
1441
1442    #[test]
1443    fn test_write_result() {
1444        let result = WriteResult::new(100, 5000);
1445        assert_eq!(result.records_written, 100);
1446        assert_eq!(result.bytes_written, 5000);
1447    }
1448
1449    #[test]
1450    fn source_drain_request_requires_canonical_round() {
1451        let round = laminar_core::checkpoint::AssignmentDrainId {
1452            predecessor_version: 7,
1453            target_version: 8,
1454            digest: [9; 32],
1455        };
1456        assert_eq!(SourceDrainRequest::new(round).unwrap().round, round);
1457        assert!(
1458            SourceDrainRequest::new(laminar_core::checkpoint::AssignmentDrainId {
1459                predecessor_version: 8,
1460                target_version: 8,
1461                digest: [9; 32],
1462            })
1463            .is_err()
1464        );
1465    }
1466
1467    #[test]
1468    fn source_contract_defaults_fail_closed() {
1469        let contract = SourceContract::default();
1470        assert_eq!(contract.consistency, SourceConsistency::Ephemeral);
1471        assert_eq!(contract.topology, SourceTopology::Singleton);
1472        assert!(!contract.supports_replay());
1473        assert!(!contract.requires_checkpointing());
1474        assert!(!contract.is_exact_delivery_certified());
1475    }
1476
1477    #[test]
1478    fn commit_coupled_sources_are_replayable_and_require_checkpoints() {
1479        let contract = SourceContract::new(
1480            SourceConsistency::CommitCoupled,
1481            SourceTopology::NodeLocalIngress,
1482        );
1483        assert!(contract.supports_replay());
1484        assert!(contract.requires_checkpointing());
1485    }
1486
1487    #[test]
1488    fn source_start_rejects_split_and_zero_resume_before_connector_start() {
1489        use laminar_core::state::CheckpointAttempt;
1490
1491        for attempt in [CheckpointAttempt::new(7, 8), CheckpointAttempt::new(0, 0)] {
1492            let error = SourceStart::new(
1493                ConnectorConfig::new("test"),
1494                SourcePosition::Resume {
1495                    attempt,
1496                    checkpoint: SourceCheckpoint::new(),
1497                },
1498                DeliveryGuarantee::AtLeastOnce,
1499            )
1500            .unwrap_err();
1501            assert!(matches!(
1502                error,
1503                ConnectorError::ConfigurationError(message)
1504                    if message.contains("one nonzero canonical checkpoint ID")
1505            ));
1506        }
1507    }
1508
1509    #[test]
1510    fn source_start_accepts_initial_and_exposes_validated_parts() {
1511        let mut config = ConnectorConfig::new("test");
1512        config.set("endpoint", "local");
1513        let request = SourceStart::new(
1514            config,
1515            SourcePosition::Initial,
1516            DeliveryGuarantee::BestEffort,
1517        )
1518        .unwrap();
1519
1520        let (config, position, delivery) = request.into_parts();
1521        assert_eq!(config.get("endpoint"), Some("local"));
1522        assert!(matches!(position, SourcePosition::Initial));
1523        assert_eq!(delivery, DeliveryGuarantee::BestEffort);
1524    }
1525
1526    #[test]
1527    fn sink_contract_defaults_fail_closed() {
1528        let contract = SinkContract::default();
1529        assert_eq!(contract.consistency, SinkConsistency::Ephemeral);
1530        assert_eq!(contract.topology, SinkTopology::Singleton);
1531        assert_eq!(contract.input_mode, SinkInputMode::AppendOnly);
1532        assert!(!contract.input_mode.accepts_full_changelog());
1533    }
1534
1535    #[test]
1536    fn coordinated_namespace_is_bounded_stable_and_sink_scoped() {
1537        use laminar_core::storage::checkpoint_manifest::PipelineIdentity;
1538        const DEPLOYMENT: &str = "018f0000-0000-7000-8000-000000000001";
1539
1540        let first =
1541            CoordinatedCommitNamespace::try_new(PipelineIdentity::empty(), DEPLOYMENT, "orders")
1542                .unwrap();
1543        let same =
1544            CoordinatedCommitNamespace::try_new(PipelineIdentity::empty(), DEPLOYMENT, "orders")
1545                .unwrap();
1546        let other =
1547            CoordinatedCommitNamespace::try_new(PipelineIdentity::empty(), DEPLOYMENT, "audit")
1548                .unwrap();
1549        let other_deployment = CoordinatedCommitNamespace::try_new(
1550            PipelineIdentity::empty(),
1551            "018f0000-0000-7000-8000-000000000002",
1552            "orders",
1553        )
1554        .unwrap();
1555
1556        assert_eq!(first.external_key(), same.external_key());
1557        assert_ne!(first.external_key(), other.external_key());
1558        assert_ne!(first.external_key(), other_deployment.external_key());
1559        assert_eq!(first.external_key().len(), "ldb-c3-".len() + 64);
1560        assert!(first
1561            .external_key()
1562            .bytes()
1563            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'));
1564    }
1565
1566    #[test]
1567    fn coordinated_namespace_rejects_ambiguous_identity() {
1568        const DEPLOYMENT: &str = "018f0000-0000-7000-8000-000000000001";
1569
1570        use laminar_core::storage::checkpoint_manifest::{
1571            PipelineIdentity, PIPELINE_IDENTITY_VERSION,
1572        };
1573        let malformed = PipelineIdentity {
1574            canonical_version: PIPELINE_IDENTITY_VERSION,
1575            sha256: "NOT-A-DIGEST".into(),
1576        };
1577        assert!(CoordinatedCommitNamespace::try_new(malformed, DEPLOYMENT, "orders").is_err());
1578        assert!(
1579            CoordinatedCommitNamespace::try_new(PipelineIdentity::empty(), DEPLOYMENT, "").is_err()
1580        );
1581        assert!(CoordinatedCommitNamespace::try_new(
1582            PipelineIdentity::empty(),
1583            "not-a-uuid",
1584            "orders"
1585        )
1586        .is_err());
1587    }
1588
1589    #[test]
1590    fn coordinated_batch_fingerprint_covers_the_exact_ordered_cut() {
1591        use laminar_core::state::CheckpointAttempt;
1592        use laminar_core::storage::checkpoint_manifest::PipelineIdentity;
1593
1594        let namespace = CoordinatedCommitNamespace::try_new(
1595            PipelineIdentity::empty(),
1596            "018f0000-0000-7000-8000-000000000001",
1597            "orders",
1598        )
1599        .unwrap();
1600        let attempt = CheckpointAttempt::new(8, 108);
1601        let batch = CoordinatedCommitBatch {
1602            namespace,
1603            expected_predecessor: CoordinatedCommitCursor {
1604                checkpoint_id: 107,
1605                fencing_token: 3,
1606            },
1607            fencing_token: 4,
1608            target: attempt,
1609            entries: vec![CoordinatedCommitPayload {
1610                attempt,
1611                participant_id: 7,
1612                payload: None,
1613            }],
1614        };
1615        let expected = batch.exact_fingerprint();
1616        assert_eq!(expected, batch.clone().exact_fingerprint());
1617
1618        let mut variants = Vec::new();
1619        let mut variant = batch.clone();
1620        variant.namespace = CoordinatedCommitNamespace::try_new(
1621            PipelineIdentity::empty(),
1622            "018f0000-0000-7000-8000-000000000001",
1623            "audit",
1624        )
1625        .unwrap();
1626        variants.push(variant);
1627        let mut variant = batch.clone();
1628        variant.expected_predecessor.checkpoint_id -= 1;
1629        variants.push(variant);
1630        let mut variant = batch.clone();
1631        variant.fencing_token += 1;
1632        variants.push(variant);
1633        let mut variant = batch.clone();
1634        variant.target.epoch += 1;
1635        variants.push(variant);
1636        let mut variant = batch.clone();
1637        variant.entries[0].attempt.checkpoint_id += 1;
1638        variants.push(variant);
1639        let mut variant = batch.clone();
1640        variant.entries[0].participant_id += 1;
1641        variants.push(variant);
1642        let mut variant = batch;
1643        variant.entries[0].payload = Some(Vec::new());
1644        variants.push(variant);
1645
1646        assert!(variants
1647            .into_iter()
1648            .all(|variant| variant.exact_fingerprint() != expected));
1649    }
1650
1651    fn valid_coordinated_batch() -> CoordinatedCommitBatch {
1652        use laminar_core::state::CheckpointAttempt;
1653        use laminar_core::storage::checkpoint_manifest::PipelineIdentity;
1654
1655        let target = CheckpointAttempt::canonical(102);
1656        CoordinatedCommitBatch {
1657            namespace: CoordinatedCommitNamespace::try_new(
1658                PipelineIdentity::empty(),
1659                "018f0000-0000-7000-8000-000000000001",
1660                "orders",
1661            )
1662            .unwrap(),
1663            expected_predecessor: CoordinatedCommitCursor {
1664                checkpoint_id: 101,
1665                fencing_token: 1,
1666            },
1667            fencing_token: 2,
1668            target,
1669            entries: vec![CoordinatedCommitPayload {
1670                attempt: target,
1671                participant_id: 0,
1672                payload: None,
1673            }],
1674        }
1675    }
1676
1677    #[test]
1678    fn coordinated_batch_rejects_noncanonical_target_before_other_shape_checks() {
1679        use laminar_core::state::CheckpointAttempt;
1680
1681        for target in [
1682            CheckpointAttempt::new(102, 103),
1683            CheckpointAttempt::new(0, 0),
1684        ] {
1685            let mut batch = valid_coordinated_batch();
1686            batch.target = target;
1687            let error = batch.validate_shape().unwrap_err();
1688            assert!(
1689                error.contains("target must use one nonzero canonical checkpoint ID"),
1690                "unexpected validation error: {error}"
1691            );
1692        }
1693    }
1694
1695    #[test]
1696    fn coordinated_batch_rejects_noncanonical_entry_before_other_shape_checks() {
1697        use laminar_core::state::CheckpointAttempt;
1698
1699        for attempt in [
1700            CheckpointAttempt::new(101, 102),
1701            CheckpointAttempt::new(0, 0),
1702        ] {
1703            let mut batch = valid_coordinated_batch();
1704            batch.entries[0].attempt = attempt;
1705            let error = batch.validate_shape().unwrap_err();
1706            assert!(
1707                error.contains(
1708                    "entry for participant 0 must use one nonzero canonical checkpoint ID"
1709                ),
1710                "unexpected validation error: {error}"
1711            );
1712        }
1713    }
1714
1715    #[test]
1716    fn coordinated_batch_rejects_cursor_rollback_and_unproven_overlap() {
1717        use laminar_core::state::CheckpointAttempt;
1718        use laminar_core::storage::checkpoint_manifest::PipelineIdentity;
1719
1720        let first = CheckpointAttempt::canonical(108);
1721        let target = CheckpointAttempt::canonical(110);
1722        let batch = CoordinatedCommitBatch {
1723            namespace: CoordinatedCommitNamespace::try_new(
1724                PipelineIdentity::empty(),
1725                "018f0000-0000-7000-8000-000000000001",
1726                "orders",
1727            )
1728            .unwrap(),
1729            expected_predecessor: CoordinatedCommitCursor {
1730                checkpoint_id: 107,
1731                fencing_token: 3,
1732            },
1733            fencing_token: 4,
1734            target,
1735            entries: vec![
1736                CoordinatedCommitPayload {
1737                    attempt: first,
1738                    participant_id: 0,
1739                    payload: None,
1740                },
1741                CoordinatedCommitPayload {
1742                    attempt: target,
1743                    participant_id: 0,
1744                    payload: None,
1745                },
1746            ],
1747        };
1748
1749        let cursor = |checkpoint_id, fencing_token| {
1750            Some(CoordinatedCommitCursor {
1751                checkpoint_id,
1752                fencing_token,
1753            })
1754        };
1755        assert!(batch.validate_observed_cursor(cursor(106, 3)).is_err());
1756        assert!(batch.validate_observed_cursor(cursor(109, 3)).is_err());
1757        assert!(batch.validate_observed_cursor(cursor(107, 2)).is_err());
1758        assert!(batch.validate_observed_cursor(cursor(107, 3)).is_ok());
1759        assert!(batch.validate_observed_cursor(cursor(108, 3)).is_ok());
1760        assert!(batch.validate_observed_cursor(cursor(110, 4)).is_ok());
1761        assert!(batch.validate_observed_cursor(cursor(110, 3)).is_err());
1762        assert!(batch.validate_observed_cursor(cursor(108, 5)).is_err());
1763    }
1764
1765    #[test]
1766    fn coordinated_batch_requires_unique_canonical_attempt_participants() {
1767        use laminar_core::state::CheckpointAttempt;
1768        use laminar_core::storage::checkpoint_manifest::PipelineIdentity;
1769
1770        let namespace = CoordinatedCommitNamespace::try_new(
1771            PipelineIdentity::empty(),
1772            "018f0000-0000-7000-8000-000000000001",
1773            "orders",
1774        )
1775        .unwrap();
1776        let target = CheckpointAttempt::canonical(102);
1777        let batch = |entries| CoordinatedCommitBatch {
1778            namespace: namespace.clone(),
1779            expected_predecessor: CoordinatedCommitCursor {
1780                checkpoint_id: 100,
1781                fencing_token: 1,
1782            },
1783            fencing_token: 2,
1784            target,
1785            entries,
1786        };
1787        let payload = |attempt, participant_id| CoordinatedCommitPayload {
1788            attempt,
1789            participant_id,
1790            payload: None,
1791        };
1792
1793        let duplicate = batch(vec![payload(target, 0), payload(target, 0)]);
1794        assert!(duplicate
1795            .validate_shape()
1796            .unwrap_err()
1797            .contains("duplicate"));
1798
1799        let out_of_order = batch(vec![payload(target, 1), payload(target, 0)]);
1800        assert!(out_of_order
1801            .validate_shape()
1802            .unwrap_err()
1803            .contains("out-of-order"));
1804
1805        let noncanonical = batch(vec![
1806            payload(CheckpointAttempt::new(3, 101), 0),
1807            payload(target, 0),
1808        ]);
1809        assert!(noncanonical
1810            .validate_shape()
1811            .unwrap_err()
1812            .contains("one nonzero canonical checkpoint ID"));
1813    }
1814
1815    #[test]
1816    fn coordinated_batch_entry_limit_accepts_max_and_rejects_max_plus_one() {
1817        use laminar_core::state::CheckpointAttempt;
1818        use laminar_core::storage::checkpoint_manifest::PipelineIdentity;
1819
1820        let namespace = CoordinatedCommitNamespace::try_new(
1821            PipelineIdentity::empty(),
1822            "018f0000-0000-7000-8000-000000000001",
1823            "orders",
1824        )
1825        .unwrap();
1826        let target = CheckpointAttempt::canonical(101);
1827        let make_batch = |count: usize| CoordinatedCommitBatch {
1828            namespace: namespace.clone(),
1829            expected_predecessor: CoordinatedCommitCursor {
1830                checkpoint_id: 0,
1831                fencing_token: 0,
1832            },
1833            fencing_token: 1,
1834            target,
1835            entries: (0..count)
1836                .map(|participant_id| CoordinatedCommitPayload {
1837                    attempt: target,
1838                    participant_id: participant_id as u64,
1839                    payload: None,
1840                })
1841                .collect(),
1842        };
1843
1844        assert!(make_batch(MAX_COORDINATED_COMMIT_BATCH_ENTRIES - 1)
1845            .validate_shape()
1846            .is_ok());
1847        assert!(make_batch(MAX_COORDINATED_COMMIT_BATCH_ENTRIES)
1848            .validate_shape()
1849            .is_ok());
1850        assert!(make_batch(MAX_COORDINATED_COMMIT_BATCH_ENTRIES + 1)
1851            .validate_shape()
1852            .is_err());
1853    }
1854
1855    struct DefaultPreCommitSink {
1856        coordinated: bool,
1857    }
1858
1859    #[async_trait]
1860    impl SinkConnector for DefaultPreCommitSink {
1861        async fn open(&mut self, _config: &ConnectorConfig) -> Result<(), ConnectorError> {
1862            Ok(())
1863        }
1864        async fn write_batch(
1865            &mut self,
1866            _batch: &RecordBatch,
1867        ) -> Result<WriteResult, ConnectorError> {
1868            Ok(WriteResult::new(0, 0))
1869        }
1870        fn schema(&self) -> SchemaRef {
1871            test_schema()
1872        }
1873        fn suggested_write_timeout(&self) -> std::time::Duration {
1874            std::time::Duration::from_secs(5)
1875        }
1876        fn as_coordinated_committer(&self) -> Option<&dyn CoordinatedCommitter> {
1877            self.coordinated
1878                .then_some(self as &dyn CoordinatedCommitter)
1879        }
1880        async fn close(&mut self) -> Result<(), ConnectorError> {
1881            Ok(())
1882        }
1883    }
1884
1885    #[async_trait]
1886    impl CoordinatedCommitter for DefaultPreCommitSink {
1887        async fn commit_aggregated(
1888            &self,
1889            _batch: CoordinatedCommitBatch,
1890            _context: CoordinatedCommitContext,
1891        ) -> Result<(), ConnectorError> {
1892            Ok(())
1893        }
1894
1895        async fn committed_cursor(
1896            &self,
1897            _namespace: &CoordinatedCommitNamespace,
1898        ) -> Result<Option<CoordinatedCommitCursor>, ConnectorError> {
1899            Ok(None)
1900        }
1901    }
1902
1903    #[tokio::test]
1904    async fn default_pre_commit_rejects_coordinated_sink() {
1905        let mut sink = DefaultPreCommitSink { coordinated: true };
1906        assert!(matches!(
1907            sink.pre_commit(1).await,
1908            Err(ConnectorError::ConfigurationError(_))
1909        ));
1910    }
1911
1912    #[tokio::test]
1913    async fn default_pre_commit_ok_for_non_coordinated_sink() {
1914        let mut sink = DefaultPreCommitSink { coordinated: false };
1915        assert!(matches!(sink.pre_commit(1).await, Ok(None)));
1916    }
1917}