Skip to main content

crabka_connect/
runtime.rs

1//! The [`ConnectorRuntime`]: an async driver that owns one [`Source`] and one
2//! [`Sink`] and pipes records between them in a single process — no Connect
3//! worker protocol, no REST, no broker required between the two ends. This is
4//! the embeddable, single-binary shape (a ground-station / aircraft edge box):
5//! build it programmatically, [`run`](ConnectorRuntime::run) it, and drive its
6//! lifecycle through the returned [`ConnectorHandle`].
7//!
8//! ## The driver loop
9//!
10//! The runtime drives one sequential
11//! `poll → put → commit → checkpoint → acknowledge` loop on the source. It is
12//! deliberately *not* a two-task pipeline with a channel
13//! between poll and put: [`Source::checkpoint`] reads the source's *live*
14//! position, so the poll side must never run ahead of what the sink has made
15//! durable — otherwise a persisted checkpoint would name records that were
16//! never delivered. Backpressure is therefore intrinsic (the loop never polls
17//! the next batch until the current one is committed) and bounded by
18//! [`max_batch`](ConnectorRuntime::max_batch): at most that many records are buffered in
19//! memory before they are pushed to the sink.
20//!
21//! Each interval:
22//!
23//! 1. Poll the source into a batch — up to `max_batch` records, or until the
24//!    source reports caught-up ([`poll`](Source::poll) returns `None`), or the
25//!    [`commit_interval`](ConnectorRuntime::commit_interval) deadline elapses.
26//! 2. If the batch is non-empty: lazily [`begin`](Sink::begin) a transaction
27//!    (only when the sink supports one — so an idle interval opens none),
28//!    [`put`](Sink::put) the batch, [`commit`](Sink::commit) it (which for an
29//!    at-least-once sink delegates to [`flush`](Sink::flush)).
30//! 3. After the commit is durable, [`checkpoint`](Source::checkpoint) the source
31//!    and persist it through the [`CheckpointStore`]. Only after that save
32//!    succeeds does the runtime call [`acknowledge`](Source::acknowledge), so an
33//!    upstream cursor advances only after the durable checkpoint names it. On
34//!    restart the runtime [`seek`](Source::seek)s to this offset, so delivery
35//!    resumes from the last fully-committed record.
36//!
37//! A put/commit failure on a transactional sink triggers a best-effort
38//! [`abort`](Sink::abort) before the error propagates, so a half-written
39//! interval is rolled back rather than leaked.
40//!
41//! ## Lifecycle
42//!
43//! [`run`](ConnectorRuntime::run) spawns the loop and hands back a
44//! [`ConnectorHandle`]. The handle [`pause`](ConnectorHandle::pause)s and
45//! [`resume`](ConnectorHandle::resume)s between intervals (an in-flight batch
46//! always commits before the loop parks), and
47//! [`shutdown`](ConnectorHandle::shutdown) performs a graceful drain: it commits
48//! one final bounded batch of whatever is immediately available, checkpoints it,
49//! then [`close`](Source::close)s both ends. These pause/resume + drain hooks are
50//! the seam a contact-window scheduler plugs into.
51
52use std::{
53    marker::PhantomData,
54    sync::{Arc, Mutex},
55    time::{Duration, Instant},
56};
57
58use async_trait::async_trait;
59use qubit_clock::sleep::{AsyncSleeper, SystemSleeper};
60use tokio::{sync::watch, task::JoinHandle};
61
62use crate::{error::ConnectError, record::SourceOffset, sink::Sink, source::Source};
63
64/// Where the runtime persists source [`checkpoint`](Source::checkpoint)s so a
65/// restart can [`seek`](Source::seek) back to the last committed position.
66///
67/// The runtime owns a single source, so a store holds a single
68/// [`SourceOffset`]. The default [`InMemoryCheckpointStore`] keeps it in memory
69/// (durable only for the process lifetime); a real edge deployment supplies a
70/// file- or NVRAM-backed implementation.
71#[async_trait]
72pub trait CheckpointStore: Send + Sync + 'static {
73    /// Persist `offset` as the latest committed position. Called only after the
74    /// records it covers are durable in the sink.
75    ///
76    /// # Errors
77    ///
78    /// Returns [`ConnectError`] if the offset cannot be persisted.
79    async fn save(&self, offset: &SourceOffset) -> Result<(), ConnectError>;
80
81    /// Load the last persisted position, or `None` if none was ever saved (a
82    /// fresh start). Called once before the first [`poll`](Source::poll).
83    ///
84    /// # Errors
85    ///
86    /// Returns [`ConnectError`] if the stored offset cannot be read.
87    async fn load(&self) -> Result<Option<SourceOffset>, ConnectError>;
88}
89
90/// In-memory [`CheckpointStore`]: holds the latest offset in a mutex. Survives
91/// pause/resume and restart-within-process, but not a process restart — use it
92/// for tests and for sources that re-derive their position from the backend.
93#[derive(Debug, Default)]
94pub struct InMemoryCheckpointStore {
95    offset: Mutex<Option<SourceOffset>>,
96}
97
98#[async_trait]
99impl CheckpointStore for InMemoryCheckpointStore {
100    async fn save(&self, offset: &SourceOffset) -> Result<(), ConnectError> {
101        *self.offset.lock().expect("checkpoint mutex poisoned") = Some(offset.clone());
102        Ok(())
103    }
104
105    async fn load(&self) -> Result<Option<SourceOffset>, ConnectError> {
106        Ok(self
107            .offset
108            .lock()
109            .expect("checkpoint mutex poisoned")
110            .clone())
111    }
112}
113
114/// Observable lifecycle state of a running connector, read via
115/// [`ConnectorHandle::state`].
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum RuntimeState {
118    /// Spawned but not yet inside the loop (seeking to its checkpoint).
119    Starting,
120    /// Actively polling and writing.
121    Running,
122    /// Parked by [`pause`](ConnectorHandle::pause); committing nothing until
123    /// [`resume`](ConnectorHandle::resume)d.
124    Paused,
125    /// Draining the remaining available records on the way to a clean stop.
126    Draining,
127    /// Stopped cleanly — the source drained and both ends were closed.
128    Stopped,
129    /// Stopped because the loop returned an error.
130    Failed,
131}
132
133/// Internal control signal flowing from the handle to the driver loop.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135enum Control {
136    Run,
137    Pause,
138    Shutdown,
139}
140
141/// Tuning knobs for the driver loop.
142#[derive(Debug, Clone, Copy)]
143struct Config {
144    commit_interval: Duration,
145    max_batch: usize,
146    poll_backoff: Duration,
147}
148
149impl Default for Config {
150    fn default() -> Self {
151        Self {
152            commit_interval: Duration::from_secs(5),
153            max_batch: 500,
154            poll_backoff: Duration::from_millis(100),
155        }
156    }
157}
158
159/// A programmatically-assembled connector: one source, one sink, and the policy
160/// that brackets them. Build it with [`ConnectorRuntime::new`], wire the ends
161/// with [`add_source`](ConnectorRuntime::add_source) /
162/// [`add_sink`](ConnectorRuntime::add_sink), then
163/// [`run`](ConnectorRuntime::run) it.
164///
165/// ```no_run
166/// # use crabka_connect::runtime::ConnectorRuntime;
167/// # async fn demo<S, K, K2>(source: S, sink: K2)
168/// # where
169/// #     S: crabka_connect::Source<K, K>,
170/// #     K2: crabka_connect::Sink<K, K>,
171/// #     K: Send + 'static,
172/// # {
173/// let handle = ConnectorRuntime::new()
174///     .add_source(source)
175///     .add_sink(sink)
176///     .run();
177/// // ... later ...
178/// handle.shutdown().await.expect("clean drain");
179/// # }
180/// ```
181pub struct ConnectorRuntime<K, V, S = NoSource, T = NoSink> {
182    source: S,
183    sink: T,
184    checkpoints: Arc<dyn CheckpointStore>,
185    config: Config,
186    /// Drives the poll-backoff sleep. Kept out of `Config` (which is `Copy`) so
187    /// production uses real time via [`SystemSleeper`] while tests inject a mock
188    /// timeline.
189    sleeper: Arc<dyn AsyncSleeper>,
190    _marker: PhantomData<(K, V)>,
191}
192
193/// Typestate marker: no source has been added to the runtime yet.
194/// [`run`](ConnectorRuntime::run) does not exist in this state.
195pub struct NoSource;
196
197/// Typestate marker: a source has been added and is ready to run.
198pub struct HasSource<K, V>(Box<dyn Source<K, V>>);
199
200/// Typestate marker: no sink has been added to the runtime yet.
201/// [`run`](ConnectorRuntime::run) does not exist in this state.
202pub struct NoSink;
203
204/// Typestate marker: a sink has been added and is ready to run.
205pub struct HasSink<K, V>(Box<dyn Sink<K, V>>);
206
207impl<K, V> Default for ConnectorRuntime<K, V, NoSource, NoSink> {
208    fn default() -> Self {
209        Self {
210            source: NoSource,
211            sink: NoSink,
212            checkpoints: Arc::new(InMemoryCheckpointStore::default()),
213            config: Config::default(),
214            sleeper: Arc::new(SystemSleeper::new()),
215            _marker: PhantomData,
216        }
217    }
218}
219
220impl<K, V> ConnectorRuntime<K, V, NoSource, NoSink>
221where
222    K: Send + 'static,
223    V: Send + 'static,
224{
225    /// A new, empty runtime with default policy and an in-memory checkpoint
226    /// store. Add a source and a sink before [`run`](Self::run).
227    #[must_use]
228    pub fn new() -> Self {
229        Self::default()
230    }
231}
232
233impl<K, V, S, T> ConnectorRuntime<K, V, S, T>
234where
235    K: Send + 'static,
236    V: Send + 'static,
237{
238    /// Set the source the runtime polls. Replaces any previously-added source.
239    #[must_use]
240    pub fn add_source<Src: Source<K, V>>(
241        self,
242        source: Src,
243    ) -> ConnectorRuntime<K, V, HasSource<K, V>, T> {
244        ConnectorRuntime {
245            source: HasSource(Box::new(source)),
246            sink: self.sink,
247            checkpoints: self.checkpoints,
248            config: self.config,
249            sleeper: self.sleeper,
250            _marker: PhantomData,
251        }
252    }
253
254    /// Set the sink the runtime writes to. Replaces any previously-added sink.
255    #[must_use]
256    pub fn add_sink<Snk: Sink<K, V>>(self, sink: Snk) -> ConnectorRuntime<K, V, S, HasSink<K, V>> {
257        ConnectorRuntime {
258            source: self.source,
259            sink: HasSink(Box::new(sink)),
260            checkpoints: self.checkpoints,
261            config: self.config,
262            sleeper: self.sleeper,
263            _marker: PhantomData,
264        }
265    }
266
267    /// Persist source checkpoints through `store` instead of the default
268    /// in-memory one. Supply a durable store to survive process restarts.
269    #[must_use]
270    pub fn checkpoint_store(mut self, store: Arc<dyn CheckpointStore>) -> Self {
271        self.checkpoints = store;
272        self
273    }
274
275    /// How often to commit + checkpoint. The loop also commits early once a
276    /// batch fills [`max_batch`](Self::max_batch) or the source catches up.
277    /// Default: 5s.
278    #[must_use]
279    pub fn commit_interval(mut self, interval: Duration) -> Self {
280        self.config.commit_interval = interval;
281        self
282    }
283
284    /// The bounded-backpressure cap: the most records buffered in memory before
285    /// the loop must push them to the sink. Default: 500. Clamped to at least 1.
286    #[must_use]
287    pub fn max_batch(mut self, max_batch: usize) -> Self {
288        self.config.max_batch = max_batch.max(1);
289        self
290    }
291
292    /// How long to back off after the source reports caught-up before polling
293    /// again. Default: 100ms.
294    #[must_use]
295    pub fn poll_backoff(mut self, backoff: Duration) -> Self {
296        self.config.poll_backoff = backoff;
297        self
298    }
299
300    /// Drive the poll-backoff sleep through `sleeper` instead of the default
301    /// [`SystemSleeper`]. Production leaves this as real time; tests inject a
302    /// mock so the backoff cadence advances on a mock timeline deterministically
303    /// instead of on the wall clock.
304    #[must_use]
305    pub fn sleeper(mut self, sleeper: Arc<dyn AsyncSleeper>) -> Self {
306        self.sleeper = sleeper;
307        self
308    }
309}
310
311impl<K, V> ConnectorRuntime<K, V, HasSource<K, V>, HasSink<K, V>>
312where
313    K: Send + 'static,
314    V: Send + 'static,
315{
316    /// Spawn the driver loop and return a handle to control it.
317    ///
318    /// Must be called from within a Tokio runtime. The loop runs until
319    /// [`shutdown`](ConnectorHandle::shutdown) (graceful drain) or a fatal
320    /// error; an infinite source otherwise runs forever, backing off whenever it
321    /// is momentarily caught up.
322    #[must_use]
323    pub fn run(self) -> ConnectorHandle {
324        let source = self.source.0;
325        let sink = self.sink.0;
326
327        let (control_tx, control_rx) = watch::channel(Control::Run);
328        let (state_tx, state_rx) = watch::channel(RuntimeState::Starting);
329
330        let driver = Driver {
331            source,
332            sink,
333            checkpoints: self.checkpoints,
334            config: self.config,
335            sleeper: self.sleeper,
336            control: control_rx,
337            state: state_tx,
338        };
339        let join = tokio::spawn(driver.run());
340
341        ConnectorHandle {
342            control: control_tx,
343            state: state_rx,
344            join: Some(join),
345        }
346    }
347}
348
349/// A control handle for a running [`ConnectorRuntime`]. Pause, resume, observe
350/// state, and shut down gracefully. Dropping the handle without calling
351/// [`shutdown`](Self::shutdown) signals a graceful drain to the orphaned loop so
352/// it does not run forever.
353pub struct ConnectorHandle {
354    control: watch::Sender<Control>,
355    state: watch::Receiver<RuntimeState>,
356    join: Option<JoinHandle<Result<(), ConnectError>>>,
357}
358
359impl ConnectorHandle {
360    /// Park the loop after the current interval commits. Idempotent; a no-op if
361    /// the loop has already stopped.
362    pub fn pause(&self) {
363        let _ = self.control.send(Control::Pause);
364    }
365
366    /// Resume a [`pause`](Self::pause)d loop. Idempotent.
367    pub fn resume(&self) {
368        let _ = self.control.send(Control::Run);
369    }
370
371    /// The connector's current [`RuntimeState`].
372    #[must_use]
373    pub fn state(&self) -> RuntimeState {
374        *self.state.borrow()
375    }
376
377    /// Gracefully drain and stop: commit one final bounded batch of whatever is
378    /// immediately available, checkpoint it, close both ends, and await the
379    /// loop's result.
380    ///
381    /// # Errors
382    ///
383    /// Returns the error the loop failed with, or [`ConnectError::Backend`] if
384    /// the loop task panicked.
385    #[tracing::instrument(level = "info", skip_all, err)]
386    pub async fn shutdown(mut self) -> Result<(), ConnectError> {
387        let _ = self.control.send(Control::Shutdown);
388        let join = self.join.take().expect("join handle taken once");
389        match join.await {
390            Ok(result) => result,
391            Err(e) => Err(ConnectError::Backend(format!(
392                "connector task panicked: {e}"
393            ))),
394        }
395    }
396}
397
398impl Drop for ConnectorHandle {
399    fn drop(&mut self) {
400        // A handle dropped without `shutdown` would otherwise orphan a loop that
401        // runs forever. Signal a graceful drain so the loop observes it at the
402        // top of its next interval and stops on its own.
403        let _ = self.control.send(Control::Shutdown);
404    }
405}
406
407/// Whether an interval polled records or found the source caught up.
408#[derive(Debug, Clone, Copy, PartialEq, Eq)]
409enum Progress {
410    Wrote,
411    CaughtUp,
412}
413
414/// The owned state the spawned loop drives. Separated from the builder so the
415/// builder's generic surface stays clean.
416struct Driver<K, V> {
417    source: Box<dyn Source<K, V>>,
418    sink: Box<dyn Sink<K, V>>,
419    checkpoints: Arc<dyn CheckpointStore>,
420    config: Config,
421    sleeper: Arc<dyn AsyncSleeper>,
422    control: watch::Receiver<Control>,
423    state: watch::Sender<RuntimeState>,
424}
425
426impl<K, V> Driver<K, V>
427where
428    K: Send + 'static,
429    V: Send + 'static,
430{
431    /// The full lifecycle: seek to the stored checkpoint, run the loop, then
432    /// close both ends regardless of how the loop ended.
433    #[tracing::instrument(level = "info", skip_all, err)]
434    async fn run(mut self) -> Result<(), ConnectError> {
435        let result = self.seek_and_loop().await;
436
437        // Close is best-effort cleanup; surface a close error only if the loop
438        // itself succeeded (a loop error is the more interesting failure).
439        let close = self.close_ends().await;
440        let result = result.and(close);
441
442        let _ = self.state.send(if result.is_ok() {
443            RuntimeState::Stopped
444        } else {
445            RuntimeState::Failed
446        });
447        result
448    }
449
450    #[tracing::instrument(level = "info", skip_all, err)]
451    async fn seek_and_loop(&mut self) -> Result<(), ConnectError> {
452        if let Some(offset) = self.checkpoints.load().await? {
453            tracing::debug!(?offset, "seeking source to restored checkpoint");
454            self.source.seek(offset).await?;
455        }
456        self.main_loop().await
457    }
458
459    async fn main_loop(&mut self) -> Result<(), ConnectError> {
460        loop {
461            let control = *self.control.borrow_and_update();
462            match control {
463                Control::Run => {}
464                Control::Pause => {
465                    let _ = self.state.send(RuntimeState::Paused);
466                    // Park until the control signal changes (resume / shutdown)
467                    // or the handle is dropped (Err → treat as shutdown).
468                    if self.control.changed().await.is_err() {
469                        break;
470                    }
471                    continue;
472                }
473                Control::Shutdown => break,
474            }
475
476            let _ = self.state.send(RuntimeState::Running);
477            if self.run_once().await? == Progress::CaughtUp {
478                // Caught up: back off, but wake immediately on a control change.
479                // The backoff sleep goes through the injected `AsyncSleeper`
480                // (production: real time; tests: a mock timeline). The sleeper is
481                // cloned into a local so its future borrows the local rather than
482                // `self`, leaving `self.control` free for the `&mut self` wait.
483                let sleeper = self.sleeper.clone();
484                tokio::select! {
485                    () = sleeper.sleep_for_async(self.config.poll_backoff) => {}
486                    _ = self.control.changed() => {}
487                }
488            }
489        }
490
491        // Graceful drain: capture and commit one final bounded batch of
492        // whatever is immediately available, advancing the checkpoint, then
493        // stop. Each interval already commits atomically, so at most one batch
494        // can be pending; a single pass suffices and guarantees termination
495        // even for an unbounded source that never reports caught-up.
496        let _ = self.state.send(RuntimeState::Draining);
497        self.run_once().await?;
498        Ok(())
499    }
500
501    /// Poll one bounded batch and, if non-empty, write + commit + checkpoint it.
502    /// Returns whether the batch wrote anything or the source was caught up.
503    #[tracing::instrument(
504        level = "debug",
505        skip_all,
506        fields(batch = tracing::field::Empty, caught_up = tracing::field::Empty),
507        err,
508    )]
509    async fn run_once(&mut self) -> Result<Progress, ConnectError> {
510        let deadline = Instant::now() + self.config.commit_interval;
511        let mut batch = Vec::new();
512        let mut caught_up = false;
513
514        while batch.len() < self.config.max_batch {
515            if let Some(record) = self.source.poll().await? {
516                batch.push(record);
517            } else {
518                caught_up = true;
519                break;
520            }
521            if Instant::now() >= deadline {
522                break;
523            }
524        }
525
526        let span = tracing::Span::current();
527        span.record("batch", batch.len());
528        span.record("caught_up", caught_up);
529
530        if !batch.is_empty() {
531            self.write_committed(batch).await?;
532        }
533
534        Ok(if caught_up {
535            Progress::CaughtUp
536        } else {
537            Progress::Wrote
538        })
539    }
540
541    /// Write a non-empty batch inside the transactional gate (lazy `begin`),
542    /// commit it, persist the source checkpoint, then acknowledge that offset.
543    /// Aborts on failure when the sink is transactional.
544    #[tracing::instrument(
545        level = "debug",
546        skip_all,
547        fields(records = batch.len(), transactional = tracing::field::Empty),
548        err,
549    )]
550    async fn write_committed(
551        &mut self,
552        batch: Vec<crate::record::ConnectRecord<K, V>>,
553    ) -> Result<(), ConnectError> {
554        let count = batch.len();
555        let transactional = self.sink.supports_transactions();
556        tracing::Span::current().record("transactional", transactional);
557
558        // The gate is lazy: only a non-empty batch opens a transaction, so an
559        // idle interval never churns an empty txn.
560        if transactional {
561            self.sink.begin().await?;
562        }
563
564        if let Err(e) = self.deliver(batch).await {
565            if transactional {
566                // Best-effort rollback of the half-written interval; surface the
567                // original delivery error, not a secondary abort failure.
568                if let Err(abort_err) = self.sink.abort().await {
569                    tracing::warn!(error = %abort_err, "sink abort failed after delivery error");
570                }
571            }
572            return Err(e);
573        }
574
575        // Only once the records are durable may the checkpoint advance — so a
576        // restart resumes from the last fully-committed record, never past it.
577        if let Some(offset) = self.source.checkpoint() {
578            self.checkpoints.save(&offset).await?;
579            self.source.acknowledge(&offset).await?;
580        }
581        tracing::debug!(records = count, transactional, "committed connector batch");
582        Ok(())
583    }
584
585    /// `put` then `commit` the batch (commit delegates to `flush` for an
586    /// at-least-once sink), as one fallible unit so the caller can roll back.
587    async fn deliver(
588        &mut self,
589        batch: Vec<crate::record::ConnectRecord<K, V>>,
590    ) -> Result<(), ConnectError> {
591        self.sink.put(batch).await?;
592        self.sink.commit().await
593    }
594
595    #[tracing::instrument(level = "info", skip_all, err)]
596    async fn close_ends(&mut self) -> Result<(), ConnectError> {
597        let source_close = self.source.close().await;
598        let sink_close = self.sink.close().await;
599        source_close.and(sink_close)
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use std::sync::atomic::{AtomicUsize, Ordering};
606
607    use assert2::check;
608    use async_trait::async_trait;
609    use bytes::Bytes;
610    use qubit_clock::{MockTimeline, sleep::MockSleeper};
611    use tokio::sync::mpsc;
612
613    use super::*;
614    use crate::record::{ConnectRecord, OffsetMap, OffsetValue};
615
616    /// Yield until `cond` holds, letting a spawned task make progress between
617    /// checks without sleeping on real time. The bound makes a stuck condition
618    /// fail the test fast instead of hanging it.
619    async fn await_until(what: &str, mut cond: impl FnMut() -> bool) {
620        for _ in 0..100_000 {
621            if cond() {
622                return;
623            }
624            tokio::task::yield_now().await;
625        }
626        panic!("condition never held: {what}");
627    }
628
629    /// A source that yields a fixed list of values once, tracking its position
630    /// as the index, then reports caught-up forever.
631    struct VecSource {
632        records: Vec<Bytes>,
633        pos: usize,
634    }
635
636    impl VecSource {
637        fn new(values: &[&'static [u8]]) -> Self {
638            Self {
639                records: values.iter().map(|v| Bytes::from_static(v)).collect(),
640                pos: 0,
641            }
642        }
643    }
644
645    #[async_trait]
646    impl Source<Bytes, Bytes> for VecSource {
647        async fn poll(&mut self) -> Result<Option<ConnectRecord<Bytes, Bytes>>, ConnectError> {
648            let Some(v) = self.records.get(self.pos).cloned() else {
649                return Ok(None);
650            };
651            self.pos += 1;
652            Ok(Some(ConnectRecord::new(None, Some(v))))
653        }
654
655        fn checkpoint(&self) -> Option<SourceOffset> {
656            if self.pos == 0 {
657                return None;
658            }
659            let mut position = OffsetMap::new();
660            #[allow(clippy::cast_possible_wrap)]
661            position.insert("index".into(), OffsetValue::Long(self.pos as i64));
662            Some(SourceOffset::new(OffsetMap::new().into(), position.into()))
663        }
664
665        async fn seek(&mut self, offset: SourceOffset) -> Result<(), ConnectError> {
666            match offset.position.get("index") {
667                Some(OffsetValue::Long(i)) => {
668                    self.pos = usize::try_from(*i).unwrap();
669                    Ok(())
670                }
671                _ => Err(ConnectError::Offset("missing index".into())),
672            }
673        }
674    }
675
676    /// A source that never produces but counts how often it was polled — used to
677    /// prove pause stops polling.
678    struct CountingIdleSource {
679        polls: Arc<AtomicUsize>,
680    }
681
682    #[async_trait]
683    impl Source<Bytes, Bytes> for CountingIdleSource {
684        async fn poll(&mut self) -> Result<Option<ConnectRecord<Bytes, Bytes>>, ConnectError> {
685            self.polls.fetch_add(1, Ordering::SeqCst);
686            Ok(None)
687        }
688        fn checkpoint(&self) -> Option<SourceOffset> {
689            None
690        }
691        async fn seek(&mut self, _offset: SourceOffset) -> Result<(), ConnectError> {
692            Ok(())
693        }
694    }
695
696    /// A sink that forwards every delivered value over a channel and records the
697    /// transactional bracket calls + the size of each `put`, so a test can
698    /// assert on both the gate and the batch boundaries the loop chose.
699    struct ChannelSink {
700        tx: mpsc::UnboundedSender<Bytes>,
701        transactional: bool,
702        begins: Arc<AtomicUsize>,
703        commits: Arc<AtomicUsize>,
704        puts: Arc<Mutex<Vec<usize>>>,
705        staged: Vec<Bytes>,
706    }
707
708    #[async_trait]
709    impl Sink<Bytes, Bytes> for ChannelSink {
710        async fn put(
711            &mut self,
712            records: Vec<ConnectRecord<Bytes, Bytes>>,
713        ) -> Result<(), ConnectError> {
714            self.puts.lock().unwrap().push(records.len());
715            self.staged
716                .extend(records.into_iter().filter_map(|r| r.value));
717            Ok(())
718        }
719
720        async fn flush(&mut self) -> Result<(), ConnectError> {
721            self.commits.fetch_add(1, Ordering::SeqCst);
722            for v in self.staged.drain(..) {
723                let _ = self.tx.send(v);
724            }
725            Ok(())
726        }
727
728        fn supports_transactions(&self) -> bool {
729            self.transactional
730        }
731
732        async fn begin(&mut self) -> Result<(), ConnectError> {
733            self.begins.fetch_add(1, Ordering::SeqCst);
734            Ok(())
735        }
736
737        async fn abort(&mut self) -> Result<(), ConnectError> {
738            self.staged.clear();
739            Ok(())
740        }
741    }
742
743    fn channel_sink(transactional: bool) -> (ChannelSink, mpsc::UnboundedReceiver<Bytes>) {
744        let (tx, rx) = mpsc::unbounded_channel();
745        (
746            ChannelSink {
747                tx,
748                transactional,
749                begins: Arc::new(AtomicUsize::new(0)),
750                commits: Arc::new(AtomicUsize::new(0)),
751                puts: Arc::new(Mutex::new(Vec::new())),
752                staged: Vec::new(),
753            },
754            rx,
755        )
756    }
757
758    /// Receive `n` records, failing fast if any does not arrive promptly. The
759    /// bound is what makes a delivery-suppressing regression fail the test in
760    /// seconds rather than hang it — without it a no-op `put`/`commit` would
761    /// block `recv` forever.
762    async fn collect(rx: &mut mpsc::UnboundedReceiver<Bytes>, n: usize) -> Vec<Bytes> {
763        let mut out = Vec::new();
764        for _ in 0..n {
765            let rec = tokio::time::timeout(Duration::from_secs(5), rx.recv())
766                .await
767                .expect("record delivered within 5s")
768                .expect("sink channel stayed open");
769            out.push(rec);
770        }
771        out
772    }
773
774    /// Shut down with a bound, so a regression that makes the drain loop never
775    /// terminate fails the test fast instead of hanging it.
776    async fn shutdown(handle: ConnectorHandle) -> Result<(), ConnectError> {
777        tokio::time::timeout(Duration::from_secs(10), handle.shutdown())
778            .await
779            .expect("runtime shut down within 10s")
780    }
781
782    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
783    async fn pipes_source_records_to_sink_in_order() {
784        let (sink, mut rx) = channel_sink(false);
785        let handle = ConnectorRuntime::new()
786            .add_source(VecSource::new(&[b"a", b"b", b"c"]))
787            .add_sink(sink)
788            .poll_backoff(Duration::from_millis(5))
789            .run();
790
791        let got = collect(&mut rx, 3).await;
792        check!(
793            got == vec![
794                Bytes::from_static(b"a"),
795                Bytes::from_static(b"b"),
796                Bytes::from_static(b"c")
797            ]
798        );
799        shutdown(handle).await.unwrap();
800    }
801
802    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
803    async fn transactional_sink_brackets_each_nonempty_commit() {
804        let (sink, mut rx) = channel_sink(true);
805        let begins = sink.begins.clone();
806        let commits = sink.commits.clone();
807        let handle = ConnectorRuntime::new()
808            .add_source(VecSource::new(&[b"x"]))
809            .add_sink(sink)
810            .poll_backoff(Duration::from_millis(5))
811            .run();
812
813        let got = collect(&mut rx, 1).await;
814        check!(got == vec![Bytes::from_static(b"x")]);
815        shutdown(handle).await.unwrap();
816
817        // The one non-empty interval opened exactly one transaction and
818        // committed it; idle backoff intervals opened none (begins == commits).
819        check!(begins.load(Ordering::SeqCst) == 1);
820        check!(commits.load(Ordering::SeqCst) == 1);
821    }
822
823    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
824    async fn checkpoint_persists_and_restart_resumes_after_it() {
825        let store = Arc::new(InMemoryCheckpointStore::default());
826
827        let (sink, mut rx) = channel_sink(false);
828        let handle = ConnectorRuntime::new()
829            .add_source(VecSource::new(&[b"a", b"b"]))
830            .add_sink(sink)
831            .checkpoint_store(store.clone())
832            .poll_backoff(Duration::from_millis(5))
833            .run();
834        let _ = collect(&mut rx, 2).await;
835        shutdown(handle).await.unwrap();
836
837        // The persisted checkpoint names the drained position.
838        let saved = store.load().await.unwrap().expect("checkpoint saved");
839        check!(saved.position.get("index") == Some(&OffsetValue::Long(2)));
840
841        // A fresh runtime over the same store + a full source seeks past the
842        // already-delivered records and produces nothing new.
843        let (sink2, mut rx2) = channel_sink(false);
844        let handle2 = ConnectorRuntime::new()
845            .add_source(VecSource::new(&[b"a", b"b"]))
846            .add_sink(sink2)
847            .checkpoint_store(store.clone())
848            .poll_backoff(Duration::from_millis(5))
849            .run();
850        // Give the loop time to seek + poll a couple of backoff cycles.
851        tokio::time::sleep(Duration::from_millis(50)).await;
852        shutdown(handle2).await.unwrap();
853        check!(rx2.try_recv().is_err());
854    }
855
856    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
857    async fn pause_stops_polling_and_resume_restarts_it() {
858        let polls = Arc::new(AtomicUsize::new(0));
859        let (sink, _rx) = channel_sink(false);
860        let handle = ConnectorRuntime::new()
861            .add_source(CountingIdleSource {
862                polls: polls.clone(),
863            })
864            .add_sink(sink)
865            .poll_backoff(Duration::from_millis(5))
866            .run();
867
868        // Let it poll a few times, then pause and let the loop park.
869        tokio::time::sleep(Duration::from_millis(40)).await;
870        handle.pause();
871        tokio::time::sleep(Duration::from_millis(20)).await;
872        check!(handle.state() == RuntimeState::Paused);
873
874        let paused_count = polls.load(Ordering::SeqCst);
875        tokio::time::sleep(Duration::from_millis(40)).await;
876        // No further polls while paused.
877        check!(polls.load(Ordering::SeqCst) == paused_count);
878
879        handle.resume();
880        tokio::time::sleep(Duration::from_millis(40)).await;
881        // Polling resumed.
882        check!(polls.load(Ordering::SeqCst) > paused_count);
883        shutdown(handle).await.unwrap();
884    }
885
886    /// A sink whose `put` always fails, to prove a transactional failure aborts.
887    struct FailingSink {
888        aborts: Arc<AtomicUsize>,
889    }
890
891    #[async_trait]
892    impl Sink<Bytes, Bytes> for FailingSink {
893        async fn put(
894            &mut self,
895            _records: Vec<ConnectRecord<Bytes, Bytes>>,
896        ) -> Result<(), ConnectError> {
897            Err(ConnectError::Backend("write rejected".into()))
898        }
899        async fn flush(&mut self) -> Result<(), ConnectError> {
900            Ok(())
901        }
902        fn supports_transactions(&self) -> bool {
903            true
904        }
905        async fn abort(&mut self) -> Result<(), ConnectError> {
906            self.aborts.fetch_add(1, Ordering::SeqCst);
907            Ok(())
908        }
909    }
910
911    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
912    async fn delivery_failure_aborts_transaction_and_fails_runtime() {
913        let aborts = Arc::new(AtomicUsize::new(0));
914        let handle = ConnectorRuntime::new()
915            .add_source(VecSource::new(&[b"a"]))
916            .add_sink(FailingSink {
917                aborts: aborts.clone(),
918            })
919            .poll_backoff(Duration::from_millis(5))
920            .run();
921
922        // The loop fails on the first delivery; shutdown surfaces that error.
923        let result = shutdown(handle).await;
924        check!(result.is_err());
925        check!(aborts.load(Ordering::SeqCst) == 1);
926    }
927
928    /// A source whose `seek` always fails — to prove a restored checkpoint the
929    /// source cannot resume from fails the runtime at startup.
930    struct SeekFailSource;
931
932    #[async_trait]
933    impl Source<Bytes, Bytes> for SeekFailSource {
934        async fn poll(&mut self) -> Result<Option<ConnectRecord<Bytes, Bytes>>, ConnectError> {
935            Ok(None)
936        }
937        fn checkpoint(&self) -> Option<SourceOffset> {
938            None
939        }
940        async fn seek(&mut self, _offset: SourceOffset) -> Result<(), ConnectError> {
941            Err(ConnectError::Offset(
942                "upstream truncated past offset".into(),
943            ))
944        }
945    }
946
947    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
948    async fn restored_checkpoint_seek_failure_fails_runtime() {
949        // A store with a saved offset triggers `seek` before the first poll.
950        let store = Arc::new(InMemoryCheckpointStore::default());
951        store.save(&SourceOffset::default()).await.unwrap();
952
953        let handle = ConnectorRuntime::new()
954            .add_source(SeekFailSource)
955            .add_sink(channel_sink(false).0)
956            .checkpoint_store(store)
957            .run();
958        check!(shutdown(handle).await.is_err());
959    }
960
961    /// A checkpoint store that fails the requested direction, to exercise the
962    /// runtime's load (startup) and save (post-commit) error paths.
963    struct FailingCheckpointStore {
964        fail_load: bool,
965    }
966
967    #[async_trait]
968    impl CheckpointStore for FailingCheckpointStore {
969        async fn save(&self, _offset: &SourceOffset) -> Result<(), ConnectError> {
970            if self.fail_load {
971                Ok(())
972            } else {
973                Err(ConnectError::Backend("save rejected".into()))
974            }
975        }
976        async fn load(&self) -> Result<Option<SourceOffset>, ConnectError> {
977            if self.fail_load {
978                Err(ConnectError::Backend("load rejected".into()))
979            } else {
980                Ok(None)
981            }
982        }
983    }
984
985    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
986    async fn checkpoint_load_error_fails_runtime_at_startup() {
987        let handle = ConnectorRuntime::new()
988            .add_source(VecSource::new(&[b"a"]))
989            .add_sink(channel_sink(false).0)
990            .checkpoint_store(Arc::new(FailingCheckpointStore { fail_load: true }))
991            .run();
992        check!(shutdown(handle).await.is_err());
993    }
994
995    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
996    async fn checkpoint_save_error_after_commit_fails_runtime() {
997        // The batch is delivered, but persisting its checkpoint fails — the
998        // runtime must surface that rather than silently advancing.
999        let (sink, mut rx) = channel_sink(false);
1000        let handle = ConnectorRuntime::new()
1001            .add_source(VecSource::new(&[b"a"]))
1002            .add_sink(sink)
1003            .checkpoint_store(Arc::new(FailingCheckpointStore { fail_load: false }))
1004            .poll_backoff(Duration::from_millis(5))
1005            .run();
1006        check!(collect(&mut rx, 1).await == vec![Bytes::from_static(b"a")]);
1007        check!(shutdown(handle).await.is_err());
1008    }
1009
1010    struct OrderingSource {
1011        emitted: bool,
1012        events: Arc<Mutex<Vec<&'static str>>>,
1013    }
1014
1015    #[async_trait]
1016    impl Source<Bytes, Bytes> for OrderingSource {
1017        async fn poll(&mut self) -> Result<Option<ConnectRecord<Bytes, Bytes>>, ConnectError> {
1018            if self.emitted {
1019                return Ok(None);
1020            }
1021            self.emitted = true;
1022            Ok(Some(ConnectRecord::new(
1023                None,
1024                Some(Bytes::from_static(b"a")),
1025            )))
1026        }
1027
1028        fn checkpoint(&self) -> Option<SourceOffset> {
1029            self.emitted.then(|| {
1030                let mut position = OffsetMap::new();
1031                position.insert("index".into(), OffsetValue::Long(1));
1032                SourceOffset::new(OffsetMap::new().into(), position.into())
1033            })
1034        }
1035
1036        async fn seek(&mut self, _offset: SourceOffset) -> Result<(), ConnectError> {
1037            Ok(())
1038        }
1039
1040        async fn acknowledge(&mut self, _offset: &SourceOffset) -> Result<(), ConnectError> {
1041            self.events.lock().unwrap().push("acknowledge");
1042            Ok(())
1043        }
1044    }
1045
1046    struct OrderingSink {
1047        events: Arc<Mutex<Vec<&'static str>>>,
1048    }
1049
1050    #[async_trait]
1051    impl Sink<Bytes, Bytes> for OrderingSink {
1052        async fn put(
1053            &mut self,
1054            _records: Vec<ConnectRecord<Bytes, Bytes>>,
1055        ) -> Result<(), ConnectError> {
1056            self.events.lock().unwrap().push("put");
1057            Ok(())
1058        }
1059
1060        async fn flush(&mut self) -> Result<(), ConnectError> {
1061            self.events.lock().unwrap().push("commit");
1062            Ok(())
1063        }
1064    }
1065
1066    struct OrderingCheckpointStore {
1067        events: Arc<Mutex<Vec<&'static str>>>,
1068        saved: Mutex<Option<SourceOffset>>,
1069    }
1070
1071    #[async_trait]
1072    impl CheckpointStore for OrderingCheckpointStore {
1073        async fn save(&self, offset: &SourceOffset) -> Result<(), ConnectError> {
1074            self.events.lock().unwrap().push("checkpoint_save");
1075            *self.saved.lock().unwrap() = Some(offset.clone());
1076            Ok(())
1077        }
1078
1079        async fn load(&self) -> Result<Option<SourceOffset>, ConnectError> {
1080            Ok(self.saved.lock().unwrap().clone())
1081        }
1082    }
1083
1084    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1085    async fn source_acknowledge_runs_after_sink_commit_and_checkpoint_save() {
1086        let events = Arc::new(Mutex::new(Vec::new()));
1087        let store = Arc::new(OrderingCheckpointStore {
1088            events: events.clone(),
1089            saved: Mutex::new(None),
1090        });
1091        let handle = ConnectorRuntime::new()
1092            .add_source(OrderingSource {
1093                emitted: false,
1094                events: events.clone(),
1095            })
1096            .add_sink(OrderingSink {
1097                events: events.clone(),
1098            })
1099            .checkpoint_store(store)
1100            .poll_backoff(Duration::from_millis(5))
1101            .run();
1102
1103        tokio::time::sleep(Duration::from_millis(50)).await;
1104        shutdown(handle).await.unwrap();
1105
1106        check!(
1107            events.lock().unwrap().as_slice()
1108                == ["put", "commit", "checkpoint_save", "acknowledge"]
1109        );
1110    }
1111
1112    /// A sink whose `close` fails, to prove a close error surfaces from an
1113    /// otherwise-clean run.
1114    struct CloseFailSink;
1115
1116    #[async_trait]
1117    impl Sink<Bytes, Bytes> for CloseFailSink {
1118        async fn put(
1119            &mut self,
1120            _records: Vec<ConnectRecord<Bytes, Bytes>>,
1121        ) -> Result<(), ConnectError> {
1122            Ok(())
1123        }
1124        async fn flush(&mut self) -> Result<(), ConnectError> {
1125            Ok(())
1126        }
1127        async fn close(&mut self) -> Result<(), ConnectError> {
1128            Err(ConnectError::Backend("close rejected".into()))
1129        }
1130    }
1131
1132    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1133    async fn close_failure_surfaces_after_clean_run() {
1134        let handle = ConnectorRuntime::new()
1135            .add_source(VecSource::new(&[b"a"]))
1136            .add_sink(CloseFailSink)
1137            .poll_backoff(Duration::from_millis(5))
1138            .run();
1139        // The run drains cleanly; closing the sink fails, so shutdown reports it.
1140        check!(shutdown(handle).await.is_err());
1141    }
1142
1143    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1144    async fn one_caught_up_interval_batches_all_available_records() {
1145        // With a commit interval far larger than the run, a source that catches
1146        // up mid-interval delivers everything it had in a SINGLE put — proving
1147        // the loop polls until caught-up (not one record per interval) and that
1148        // the deadline is in the future (`now + interval`), not the past.
1149        let (sink, mut rx) = channel_sink(false);
1150        let puts = sink.puts.clone();
1151        let handle = ConnectorRuntime::new()
1152            .add_source(VecSource::new(&[b"a", b"b", b"c"]))
1153            .add_sink(sink)
1154            .max_batch(100)
1155            .commit_interval(Duration::from_secs(30))
1156            .poll_backoff(Duration::from_millis(5))
1157            .run();
1158        let _ = collect(&mut rx, 3).await;
1159        shutdown(handle).await.unwrap();
1160        check!(*puts.lock().unwrap() == vec![3]);
1161    }
1162
1163    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1164    async fn max_batch_caps_each_put() {
1165        // A cap of 2 over four available records forces the loop to break the
1166        // batch at the bound, so no put exceeds 2 (and every record still flows).
1167        let (sink, mut rx) = channel_sink(false);
1168        let puts = sink.puts.clone();
1169        let handle = ConnectorRuntime::new()
1170            .add_source(VecSource::new(&[b"a", b"b", b"c", b"d"]))
1171            .add_sink(sink)
1172            .max_batch(2)
1173            .commit_interval(Duration::from_secs(30))
1174            .poll_backoff(Duration::from_millis(5))
1175            .run();
1176        let _ = collect(&mut rx, 4).await;
1177        shutdown(handle).await.unwrap();
1178        let sizes = puts.lock().unwrap().clone();
1179        check!(sizes.iter().all(|&n| n <= 2));
1180        check!(sizes.iter().sum::<usize>() == 4);
1181    }
1182
1183    #[tokio::test]
1184    async fn idle_source_backs_off_between_polls() {
1185        // A caught-up source must be polled on the backoff cadence, not spun on.
1186        // Injecting a mock timeline makes this exact rather than fuzzy: the loop
1187        // polls once, finds the source caught up, and parks on the backoff sleep;
1188        // thereafter each advance of one `poll_backoff` period releases exactly
1189        // one further poll. No real time elapses.
1190        const ADVANCES: usize = 5;
1191        let polls = Arc::new(AtomicUsize::new(0));
1192        let (sink, _rx) = channel_sink(false);
1193        let backoff = Duration::from_millis(50);
1194        let sleeper = MockSleeper::new();
1195        let timeline: MockTimeline = sleeper.timeline();
1196        let handle = ConnectorRuntime::new()
1197            .add_source(CountingIdleSource {
1198                polls: polls.clone(),
1199            })
1200            .add_sink(sink)
1201            .poll_backoff(backoff)
1202            .sleeper(Arc::new(sleeper))
1203            .run();
1204
1205        // First poll: the loop reaches the select and parks on the backoff sleep
1206        // (the mock waiter registers as the future is created).
1207        await_until("first idle poll", || polls.load(Ordering::SeqCst) >= 1).await;
1208
1209        // Each advance of one backoff period wakes the parked sleep, letting the
1210        // loop poll exactly once more before parking again.
1211        for i in 0..ADVANCES {
1212            timeline.advance(backoff);
1213            let expected = i + 2;
1214            await_until("poll after backoff advance", || {
1215                polls.load(Ordering::SeqCst) >= expected
1216            })
1217            .await;
1218        }
1219
1220        // Deterministic: exactly the initial poll plus one per advance — the loop
1221        // never spins, because it stays parked on a sleep the timeline has not
1222        // yet passed.
1223        check!(polls.load(Ordering::SeqCst) == ADVANCES + 1);
1224        shutdown(handle).await.unwrap();
1225    }
1226
1227    /// A finite source that signals over a channel when it is closed, so a test
1228    /// can observe that the loop reached its clean-shutdown path.
1229    struct ClosingSource {
1230        records: Vec<Bytes>,
1231        pos: usize,
1232        closed: mpsc::UnboundedSender<()>,
1233    }
1234
1235    #[async_trait]
1236    impl Source<Bytes, Bytes> for ClosingSource {
1237        async fn poll(&mut self) -> Result<Option<ConnectRecord<Bytes, Bytes>>, ConnectError> {
1238            let Some(v) = self.records.get(self.pos).cloned() else {
1239                return Ok(None);
1240            };
1241            self.pos += 1;
1242            Ok(Some(ConnectRecord::new(None, Some(v))))
1243        }
1244        fn checkpoint(&self) -> Option<SourceOffset> {
1245            None
1246        }
1247        async fn seek(&mut self, _offset: SourceOffset) -> Result<(), ConnectError> {
1248            Ok(())
1249        }
1250        async fn close(&mut self) -> Result<(), ConnectError> {
1251            let _ = self.closed.send(());
1252            Ok(())
1253        }
1254    }
1255
1256    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1257    async fn dropping_handle_drains_and_closes_the_source() {
1258        // Dropping the handle without `shutdown` must still signal a graceful
1259        // stop, so the loop drains, closes both ends, and does not run forever.
1260        let (closed_tx, mut closed_rx) = mpsc::unbounded_channel();
1261        let (sink, _rx) = channel_sink(false);
1262        let handle = ConnectorRuntime::new()
1263            .add_source(ClosingSource {
1264                records: vec![Bytes::from_static(b"a")],
1265                pos: 0,
1266                closed: closed_tx,
1267            })
1268            .add_sink(sink)
1269            .poll_backoff(Duration::from_millis(5))
1270            .run();
1271
1272        drop(handle);
1273        tokio::time::timeout(Duration::from_secs(5), closed_rx.recv())
1274            .await
1275            .expect("source closed within 5s of dropping the handle")
1276            .expect("close signal received");
1277    }
1278}