use std::{
marker::PhantomData,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use async_trait::async_trait;
use qubit_clock::sleep::{AsyncSleeper, SystemSleeper};
use tokio::{sync::watch, task::JoinHandle};
use crate::{error::ConnectError, record::SourceOffset, sink::Sink, source::Source};
#[async_trait]
pub trait CheckpointStore: Send + Sync + 'static {
async fn save(&self, offset: &SourceOffset) -> Result<(), ConnectError>;
async fn load(&self) -> Result<Option<SourceOffset>, ConnectError>;
}
#[derive(Debug, Default)]
pub struct InMemoryCheckpointStore {
offset: Mutex<Option<SourceOffset>>,
}
#[async_trait]
impl CheckpointStore for InMemoryCheckpointStore {
async fn save(&self, offset: &SourceOffset) -> Result<(), ConnectError> {
*self.offset.lock().expect("checkpoint mutex poisoned") = Some(offset.clone());
Ok(())
}
async fn load(&self) -> Result<Option<SourceOffset>, ConnectError> {
Ok(self
.offset
.lock()
.expect("checkpoint mutex poisoned")
.clone())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeState {
Starting,
Running,
Paused,
Draining,
Stopped,
Failed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Control {
Run,
Pause,
Shutdown,
}
#[derive(Debug, Clone, Copy)]
struct Config {
commit_interval: Duration,
max_batch: usize,
poll_backoff: Duration,
}
impl Default for Config {
fn default() -> Self {
Self {
commit_interval: Duration::from_secs(5),
max_batch: 500,
poll_backoff: Duration::from_millis(100),
}
}
}
pub struct ConnectorRuntime<K, V, S = NoSource, T = NoSink> {
source: S,
sink: T,
checkpoints: Arc<dyn CheckpointStore>,
config: Config,
sleeper: Arc<dyn AsyncSleeper>,
_marker: PhantomData<(K, V)>,
}
pub struct NoSource;
pub struct HasSource<K, V>(Box<dyn Source<K, V>>);
pub struct NoSink;
pub struct HasSink<K, V>(Box<dyn Sink<K, V>>);
impl<K, V> Default for ConnectorRuntime<K, V, NoSource, NoSink> {
fn default() -> Self {
Self {
source: NoSource,
sink: NoSink,
checkpoints: Arc::new(InMemoryCheckpointStore::default()),
config: Config::default(),
sleeper: Arc::new(SystemSleeper::new()),
_marker: PhantomData,
}
}
}
impl<K, V> ConnectorRuntime<K, V, NoSource, NoSink>
where
K: Send + 'static,
V: Send + 'static,
{
#[must_use]
pub fn new() -> Self {
Self::default()
}
}
impl<K, V, S, T> ConnectorRuntime<K, V, S, T>
where
K: Send + 'static,
V: Send + 'static,
{
#[must_use]
pub fn add_source<Src: Source<K, V>>(
self,
source: Src,
) -> ConnectorRuntime<K, V, HasSource<K, V>, T> {
ConnectorRuntime {
source: HasSource(Box::new(source)),
sink: self.sink,
checkpoints: self.checkpoints,
config: self.config,
sleeper: self.sleeper,
_marker: PhantomData,
}
}
#[must_use]
pub fn add_sink<Snk: Sink<K, V>>(self, sink: Snk) -> ConnectorRuntime<K, V, S, HasSink<K, V>> {
ConnectorRuntime {
source: self.source,
sink: HasSink(Box::new(sink)),
checkpoints: self.checkpoints,
config: self.config,
sleeper: self.sleeper,
_marker: PhantomData,
}
}
#[must_use]
pub fn checkpoint_store(mut self, store: Arc<dyn CheckpointStore>) -> Self {
self.checkpoints = store;
self
}
#[must_use]
pub fn commit_interval(mut self, interval: Duration) -> Self {
self.config.commit_interval = interval;
self
}
#[must_use]
pub fn max_batch(mut self, max_batch: usize) -> Self {
self.config.max_batch = max_batch.max(1);
self
}
#[must_use]
pub fn poll_backoff(mut self, backoff: Duration) -> Self {
self.config.poll_backoff = backoff;
self
}
#[must_use]
pub fn sleeper(mut self, sleeper: Arc<dyn AsyncSleeper>) -> Self {
self.sleeper = sleeper;
self
}
}
impl<K, V> ConnectorRuntime<K, V, HasSource<K, V>, HasSink<K, V>>
where
K: Send + 'static,
V: Send + 'static,
{
#[must_use]
pub fn run(self) -> ConnectorHandle {
let source = self.source.0;
let sink = self.sink.0;
let (control_tx, control_rx) = watch::channel(Control::Run);
let (state_tx, state_rx) = watch::channel(RuntimeState::Starting);
let driver = Driver {
source,
sink,
checkpoints: self.checkpoints,
config: self.config,
sleeper: self.sleeper,
control: control_rx,
state: state_tx,
};
let join = tokio::spawn(driver.run());
ConnectorHandle {
control: control_tx,
state: state_rx,
join: Some(join),
}
}
}
pub struct ConnectorHandle {
control: watch::Sender<Control>,
state: watch::Receiver<RuntimeState>,
join: Option<JoinHandle<Result<(), ConnectError>>>,
}
impl ConnectorHandle {
pub fn pause(&self) {
let _ = self.control.send(Control::Pause);
}
pub fn resume(&self) {
let _ = self.control.send(Control::Run);
}
#[must_use]
pub fn state(&self) -> RuntimeState {
*self.state.borrow()
}
#[tracing::instrument(level = "info", skip_all, err)]
pub async fn shutdown(mut self) -> Result<(), ConnectError> {
let _ = self.control.send(Control::Shutdown);
let join = self.join.take().expect("join handle taken once");
match join.await {
Ok(result) => result,
Err(e) => Err(ConnectError::Backend(format!(
"connector task panicked: {e}"
))),
}
}
}
impl Drop for ConnectorHandle {
fn drop(&mut self) {
let _ = self.control.send(Control::Shutdown);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Progress {
Wrote,
CaughtUp,
}
struct Driver<K, V> {
source: Box<dyn Source<K, V>>,
sink: Box<dyn Sink<K, V>>,
checkpoints: Arc<dyn CheckpointStore>,
config: Config,
sleeper: Arc<dyn AsyncSleeper>,
control: watch::Receiver<Control>,
state: watch::Sender<RuntimeState>,
}
impl<K, V> Driver<K, V>
where
K: Send + 'static,
V: Send + 'static,
{
#[tracing::instrument(level = "info", skip_all, err)]
async fn run(mut self) -> Result<(), ConnectError> {
let result = self.seek_and_loop().await;
let close = self.close_ends().await;
let result = result.and(close);
let _ = self.state.send(if result.is_ok() {
RuntimeState::Stopped
} else {
RuntimeState::Failed
});
result
}
#[tracing::instrument(level = "info", skip_all, err)]
async fn seek_and_loop(&mut self) -> Result<(), ConnectError> {
if let Some(offset) = self.checkpoints.load().await? {
tracing::debug!(?offset, "seeking source to restored checkpoint");
self.source.seek(offset).await?;
}
self.main_loop().await
}
async fn main_loop(&mut self) -> Result<(), ConnectError> {
loop {
let control = *self.control.borrow_and_update();
match control {
Control::Run => {}
Control::Pause => {
let _ = self.state.send(RuntimeState::Paused);
if self.control.changed().await.is_err() {
break;
}
continue;
}
Control::Shutdown => break,
}
let _ = self.state.send(RuntimeState::Running);
if self.run_once().await? == Progress::CaughtUp {
let sleeper = self.sleeper.clone();
tokio::select! {
() = sleeper.sleep_for_async(self.config.poll_backoff) => {}
_ = self.control.changed() => {}
}
}
}
let _ = self.state.send(RuntimeState::Draining);
self.run_once().await?;
Ok(())
}
#[tracing::instrument(
level = "debug",
skip_all,
fields(batch = tracing::field::Empty, caught_up = tracing::field::Empty),
err,
)]
async fn run_once(&mut self) -> Result<Progress, ConnectError> {
let deadline = Instant::now() + self.config.commit_interval;
let mut batch = Vec::new();
let mut caught_up = false;
while batch.len() < self.config.max_batch {
if let Some(record) = self.source.poll().await? {
batch.push(record);
} else {
caught_up = true;
break;
}
if Instant::now() >= deadline {
break;
}
}
let span = tracing::Span::current();
span.record("batch", batch.len());
span.record("caught_up", caught_up);
if !batch.is_empty() {
self.write_committed(batch).await?;
}
Ok(if caught_up {
Progress::CaughtUp
} else {
Progress::Wrote
})
}
#[tracing::instrument(
level = "debug",
skip_all,
fields(records = batch.len(), transactional = tracing::field::Empty),
err,
)]
async fn write_committed(
&mut self,
batch: Vec<crate::record::ConnectRecord<K, V>>,
) -> Result<(), ConnectError> {
let count = batch.len();
let transactional = self.sink.supports_transactions();
tracing::Span::current().record("transactional", transactional);
if transactional {
self.sink.begin().await?;
}
if let Err(e) = self.deliver(batch).await {
if transactional {
if let Err(abort_err) = self.sink.abort().await {
tracing::warn!(error = %abort_err, "sink abort failed after delivery error");
}
}
return Err(e);
}
if let Some(offset) = self.source.checkpoint() {
self.checkpoints.save(&offset).await?;
self.source.acknowledge(&offset).await?;
}
tracing::debug!(records = count, transactional, "committed connector batch");
Ok(())
}
async fn deliver(
&mut self,
batch: Vec<crate::record::ConnectRecord<K, V>>,
) -> Result<(), ConnectError> {
self.sink.put(batch).await?;
self.sink.commit().await
}
#[tracing::instrument(level = "info", skip_all, err)]
async fn close_ends(&mut self) -> Result<(), ConnectError> {
let source_close = self.source.close().await;
let sink_close = self.sink.close().await;
source_close.and(sink_close)
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use assert2::check;
use async_trait::async_trait;
use bytes::Bytes;
use qubit_clock::{MockTimeline, sleep::MockSleeper};
use tokio::sync::mpsc;
use super::*;
use crate::record::{ConnectRecord, OffsetMap, OffsetValue};
async fn await_until(what: &str, mut cond: impl FnMut() -> bool) {
for _ in 0..100_000 {
if cond() {
return;
}
tokio::task::yield_now().await;
}
panic!("condition never held: {what}");
}
struct VecSource {
records: Vec<Bytes>,
pos: usize,
}
impl VecSource {
fn new(values: &[&'static [u8]]) -> Self {
Self {
records: values.iter().map(|v| Bytes::from_static(v)).collect(),
pos: 0,
}
}
}
#[async_trait]
impl Source<Bytes, Bytes> for VecSource {
async fn poll(&mut self) -> Result<Option<ConnectRecord<Bytes, Bytes>>, ConnectError> {
let Some(v) = self.records.get(self.pos).cloned() else {
return Ok(None);
};
self.pos += 1;
Ok(Some(ConnectRecord::new(None, Some(v))))
}
fn checkpoint(&self) -> Option<SourceOffset> {
if self.pos == 0 {
return None;
}
let mut position = OffsetMap::new();
#[allow(clippy::cast_possible_wrap)]
position.insert("index".into(), OffsetValue::Long(self.pos as i64));
Some(SourceOffset::new(OffsetMap::new().into(), position.into()))
}
async fn seek(&mut self, offset: SourceOffset) -> Result<(), ConnectError> {
match offset.position.get("index") {
Some(OffsetValue::Long(i)) => {
self.pos = usize::try_from(*i).unwrap();
Ok(())
}
_ => Err(ConnectError::Offset("missing index".into())),
}
}
}
struct CountingIdleSource {
polls: Arc<AtomicUsize>,
}
#[async_trait]
impl Source<Bytes, Bytes> for CountingIdleSource {
async fn poll(&mut self) -> Result<Option<ConnectRecord<Bytes, Bytes>>, ConnectError> {
self.polls.fetch_add(1, Ordering::SeqCst);
Ok(None)
}
fn checkpoint(&self) -> Option<SourceOffset> {
None
}
async fn seek(&mut self, _offset: SourceOffset) -> Result<(), ConnectError> {
Ok(())
}
}
struct ChannelSink {
tx: mpsc::UnboundedSender<Bytes>,
transactional: bool,
begins: Arc<AtomicUsize>,
commits: Arc<AtomicUsize>,
puts: Arc<Mutex<Vec<usize>>>,
staged: Vec<Bytes>,
}
#[async_trait]
impl Sink<Bytes, Bytes> for ChannelSink {
async fn put(
&mut self,
records: Vec<ConnectRecord<Bytes, Bytes>>,
) -> Result<(), ConnectError> {
self.puts.lock().unwrap().push(records.len());
self.staged
.extend(records.into_iter().filter_map(|r| r.value));
Ok(())
}
async fn flush(&mut self) -> Result<(), ConnectError> {
self.commits.fetch_add(1, Ordering::SeqCst);
for v in self.staged.drain(..) {
let _ = self.tx.send(v);
}
Ok(())
}
fn supports_transactions(&self) -> bool {
self.transactional
}
async fn begin(&mut self) -> Result<(), ConnectError> {
self.begins.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn abort(&mut self) -> Result<(), ConnectError> {
self.staged.clear();
Ok(())
}
}
fn channel_sink(transactional: bool) -> (ChannelSink, mpsc::UnboundedReceiver<Bytes>) {
let (tx, rx) = mpsc::unbounded_channel();
(
ChannelSink {
tx,
transactional,
begins: Arc::new(AtomicUsize::new(0)),
commits: Arc::new(AtomicUsize::new(0)),
puts: Arc::new(Mutex::new(Vec::new())),
staged: Vec::new(),
},
rx,
)
}
async fn collect(rx: &mut mpsc::UnboundedReceiver<Bytes>, n: usize) -> Vec<Bytes> {
let mut out = Vec::new();
for _ in 0..n {
let rec = tokio::time::timeout(Duration::from_secs(5), rx.recv())
.await
.expect("record delivered within 5s")
.expect("sink channel stayed open");
out.push(rec);
}
out
}
async fn shutdown(handle: ConnectorHandle) -> Result<(), ConnectError> {
tokio::time::timeout(Duration::from_secs(10), handle.shutdown())
.await
.expect("runtime shut down within 10s")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pipes_source_records_to_sink_in_order() {
let (sink, mut rx) = channel_sink(false);
let handle = ConnectorRuntime::new()
.add_source(VecSource::new(&[b"a", b"b", b"c"]))
.add_sink(sink)
.poll_backoff(Duration::from_millis(5))
.run();
let got = collect(&mut rx, 3).await;
check!(
got == vec![
Bytes::from_static(b"a"),
Bytes::from_static(b"b"),
Bytes::from_static(b"c")
]
);
shutdown(handle).await.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn transactional_sink_brackets_each_nonempty_commit() {
let (sink, mut rx) = channel_sink(true);
let begins = sink.begins.clone();
let commits = sink.commits.clone();
let handle = ConnectorRuntime::new()
.add_source(VecSource::new(&[b"x"]))
.add_sink(sink)
.poll_backoff(Duration::from_millis(5))
.run();
let got = collect(&mut rx, 1).await;
check!(got == vec![Bytes::from_static(b"x")]);
shutdown(handle).await.unwrap();
check!(begins.load(Ordering::SeqCst) == 1);
check!(commits.load(Ordering::SeqCst) == 1);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn checkpoint_persists_and_restart_resumes_after_it() {
let store = Arc::new(InMemoryCheckpointStore::default());
let (sink, mut rx) = channel_sink(false);
let handle = ConnectorRuntime::new()
.add_source(VecSource::new(&[b"a", b"b"]))
.add_sink(sink)
.checkpoint_store(store.clone())
.poll_backoff(Duration::from_millis(5))
.run();
let _ = collect(&mut rx, 2).await;
shutdown(handle).await.unwrap();
let saved = store.load().await.unwrap().expect("checkpoint saved");
check!(saved.position.get("index") == Some(&OffsetValue::Long(2)));
let (sink2, mut rx2) = channel_sink(false);
let handle2 = ConnectorRuntime::new()
.add_source(VecSource::new(&[b"a", b"b"]))
.add_sink(sink2)
.checkpoint_store(store.clone())
.poll_backoff(Duration::from_millis(5))
.run();
tokio::time::sleep(Duration::from_millis(50)).await;
shutdown(handle2).await.unwrap();
check!(rx2.try_recv().is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn pause_stops_polling_and_resume_restarts_it() {
let polls = Arc::new(AtomicUsize::new(0));
let (sink, _rx) = channel_sink(false);
let handle = ConnectorRuntime::new()
.add_source(CountingIdleSource {
polls: polls.clone(),
})
.add_sink(sink)
.poll_backoff(Duration::from_millis(5))
.run();
tokio::time::sleep(Duration::from_millis(40)).await;
handle.pause();
tokio::time::sleep(Duration::from_millis(20)).await;
check!(handle.state() == RuntimeState::Paused);
let paused_count = polls.load(Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(40)).await;
check!(polls.load(Ordering::SeqCst) == paused_count);
handle.resume();
tokio::time::sleep(Duration::from_millis(40)).await;
check!(polls.load(Ordering::SeqCst) > paused_count);
shutdown(handle).await.unwrap();
}
struct FailingSink {
aborts: Arc<AtomicUsize>,
}
#[async_trait]
impl Sink<Bytes, Bytes> for FailingSink {
async fn put(
&mut self,
_records: Vec<ConnectRecord<Bytes, Bytes>>,
) -> Result<(), ConnectError> {
Err(ConnectError::Backend("write rejected".into()))
}
async fn flush(&mut self) -> Result<(), ConnectError> {
Ok(())
}
fn supports_transactions(&self) -> bool {
true
}
async fn abort(&mut self) -> Result<(), ConnectError> {
self.aborts.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn delivery_failure_aborts_transaction_and_fails_runtime() {
let aborts = Arc::new(AtomicUsize::new(0));
let handle = ConnectorRuntime::new()
.add_source(VecSource::new(&[b"a"]))
.add_sink(FailingSink {
aborts: aborts.clone(),
})
.poll_backoff(Duration::from_millis(5))
.run();
let result = shutdown(handle).await;
check!(result.is_err());
check!(aborts.load(Ordering::SeqCst) == 1);
}
struct SeekFailSource;
#[async_trait]
impl Source<Bytes, Bytes> for SeekFailSource {
async fn poll(&mut self) -> Result<Option<ConnectRecord<Bytes, Bytes>>, ConnectError> {
Ok(None)
}
fn checkpoint(&self) -> Option<SourceOffset> {
None
}
async fn seek(&mut self, _offset: SourceOffset) -> Result<(), ConnectError> {
Err(ConnectError::Offset(
"upstream truncated past offset".into(),
))
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn restored_checkpoint_seek_failure_fails_runtime() {
let store = Arc::new(InMemoryCheckpointStore::default());
store.save(&SourceOffset::default()).await.unwrap();
let handle = ConnectorRuntime::new()
.add_source(SeekFailSource)
.add_sink(channel_sink(false).0)
.checkpoint_store(store)
.run();
check!(shutdown(handle).await.is_err());
}
struct FailingCheckpointStore {
fail_load: bool,
}
#[async_trait]
impl CheckpointStore for FailingCheckpointStore {
async fn save(&self, _offset: &SourceOffset) -> Result<(), ConnectError> {
if self.fail_load {
Ok(())
} else {
Err(ConnectError::Backend("save rejected".into()))
}
}
async fn load(&self) -> Result<Option<SourceOffset>, ConnectError> {
if self.fail_load {
Err(ConnectError::Backend("load rejected".into()))
} else {
Ok(None)
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn checkpoint_load_error_fails_runtime_at_startup() {
let handle = ConnectorRuntime::new()
.add_source(VecSource::new(&[b"a"]))
.add_sink(channel_sink(false).0)
.checkpoint_store(Arc::new(FailingCheckpointStore { fail_load: true }))
.run();
check!(shutdown(handle).await.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn checkpoint_save_error_after_commit_fails_runtime() {
let (sink, mut rx) = channel_sink(false);
let handle = ConnectorRuntime::new()
.add_source(VecSource::new(&[b"a"]))
.add_sink(sink)
.checkpoint_store(Arc::new(FailingCheckpointStore { fail_load: false }))
.poll_backoff(Duration::from_millis(5))
.run();
check!(collect(&mut rx, 1).await == vec![Bytes::from_static(b"a")]);
check!(shutdown(handle).await.is_err());
}
struct OrderingSource {
emitted: bool,
events: Arc<Mutex<Vec<&'static str>>>,
}
#[async_trait]
impl Source<Bytes, Bytes> for OrderingSource {
async fn poll(&mut self) -> Result<Option<ConnectRecord<Bytes, Bytes>>, ConnectError> {
if self.emitted {
return Ok(None);
}
self.emitted = true;
Ok(Some(ConnectRecord::new(
None,
Some(Bytes::from_static(b"a")),
)))
}
fn checkpoint(&self) -> Option<SourceOffset> {
self.emitted.then(|| {
let mut position = OffsetMap::new();
position.insert("index".into(), OffsetValue::Long(1));
SourceOffset::new(OffsetMap::new().into(), position.into())
})
}
async fn seek(&mut self, _offset: SourceOffset) -> Result<(), ConnectError> {
Ok(())
}
async fn acknowledge(&mut self, _offset: &SourceOffset) -> Result<(), ConnectError> {
self.events.lock().unwrap().push("acknowledge");
Ok(())
}
}
struct OrderingSink {
events: Arc<Mutex<Vec<&'static str>>>,
}
#[async_trait]
impl Sink<Bytes, Bytes> for OrderingSink {
async fn put(
&mut self,
_records: Vec<ConnectRecord<Bytes, Bytes>>,
) -> Result<(), ConnectError> {
self.events.lock().unwrap().push("put");
Ok(())
}
async fn flush(&mut self) -> Result<(), ConnectError> {
self.events.lock().unwrap().push("commit");
Ok(())
}
}
struct OrderingCheckpointStore {
events: Arc<Mutex<Vec<&'static str>>>,
saved: Mutex<Option<SourceOffset>>,
}
#[async_trait]
impl CheckpointStore for OrderingCheckpointStore {
async fn save(&self, offset: &SourceOffset) -> Result<(), ConnectError> {
self.events.lock().unwrap().push("checkpoint_save");
*self.saved.lock().unwrap() = Some(offset.clone());
Ok(())
}
async fn load(&self) -> Result<Option<SourceOffset>, ConnectError> {
Ok(self.saved.lock().unwrap().clone())
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn source_acknowledge_runs_after_sink_commit_and_checkpoint_save() {
let events = Arc::new(Mutex::new(Vec::new()));
let store = Arc::new(OrderingCheckpointStore {
events: events.clone(),
saved: Mutex::new(None),
});
let handle = ConnectorRuntime::new()
.add_source(OrderingSource {
emitted: false,
events: events.clone(),
})
.add_sink(OrderingSink {
events: events.clone(),
})
.checkpoint_store(store)
.poll_backoff(Duration::from_millis(5))
.run();
tokio::time::sleep(Duration::from_millis(50)).await;
shutdown(handle).await.unwrap();
check!(
events.lock().unwrap().as_slice()
== ["put", "commit", "checkpoint_save", "acknowledge"]
);
}
struct CloseFailSink;
#[async_trait]
impl Sink<Bytes, Bytes> for CloseFailSink {
async fn put(
&mut self,
_records: Vec<ConnectRecord<Bytes, Bytes>>,
) -> Result<(), ConnectError> {
Ok(())
}
async fn flush(&mut self) -> Result<(), ConnectError> {
Ok(())
}
async fn close(&mut self) -> Result<(), ConnectError> {
Err(ConnectError::Backend("close rejected".into()))
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn close_failure_surfaces_after_clean_run() {
let handle = ConnectorRuntime::new()
.add_source(VecSource::new(&[b"a"]))
.add_sink(CloseFailSink)
.poll_backoff(Duration::from_millis(5))
.run();
check!(shutdown(handle).await.is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn one_caught_up_interval_batches_all_available_records() {
let (sink, mut rx) = channel_sink(false);
let puts = sink.puts.clone();
let handle = ConnectorRuntime::new()
.add_source(VecSource::new(&[b"a", b"b", b"c"]))
.add_sink(sink)
.max_batch(100)
.commit_interval(Duration::from_secs(30))
.poll_backoff(Duration::from_millis(5))
.run();
let _ = collect(&mut rx, 3).await;
shutdown(handle).await.unwrap();
check!(*puts.lock().unwrap() == vec![3]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn max_batch_caps_each_put() {
let (sink, mut rx) = channel_sink(false);
let puts = sink.puts.clone();
let handle = ConnectorRuntime::new()
.add_source(VecSource::new(&[b"a", b"b", b"c", b"d"]))
.add_sink(sink)
.max_batch(2)
.commit_interval(Duration::from_secs(30))
.poll_backoff(Duration::from_millis(5))
.run();
let _ = collect(&mut rx, 4).await;
shutdown(handle).await.unwrap();
let sizes = puts.lock().unwrap().clone();
check!(sizes.iter().all(|&n| n <= 2));
check!(sizes.iter().sum::<usize>() == 4);
}
#[tokio::test]
async fn idle_source_backs_off_between_polls() {
const ADVANCES: usize = 5;
let polls = Arc::new(AtomicUsize::new(0));
let (sink, _rx) = channel_sink(false);
let backoff = Duration::from_millis(50);
let sleeper = MockSleeper::new();
let timeline: MockTimeline = sleeper.timeline();
let handle = ConnectorRuntime::new()
.add_source(CountingIdleSource {
polls: polls.clone(),
})
.add_sink(sink)
.poll_backoff(backoff)
.sleeper(Arc::new(sleeper))
.run();
await_until("first idle poll", || polls.load(Ordering::SeqCst) >= 1).await;
for i in 0..ADVANCES {
timeline.advance(backoff);
let expected = i + 2;
await_until("poll after backoff advance", || {
polls.load(Ordering::SeqCst) >= expected
})
.await;
}
check!(polls.load(Ordering::SeqCst) == ADVANCES + 1);
shutdown(handle).await.unwrap();
}
struct ClosingSource {
records: Vec<Bytes>,
pos: usize,
closed: mpsc::UnboundedSender<()>,
}
#[async_trait]
impl Source<Bytes, Bytes> for ClosingSource {
async fn poll(&mut self) -> Result<Option<ConnectRecord<Bytes, Bytes>>, ConnectError> {
let Some(v) = self.records.get(self.pos).cloned() else {
return Ok(None);
};
self.pos += 1;
Ok(Some(ConnectRecord::new(None, Some(v))))
}
fn checkpoint(&self) -> Option<SourceOffset> {
None
}
async fn seek(&mut self, _offset: SourceOffset) -> Result<(), ConnectError> {
Ok(())
}
async fn close(&mut self) -> Result<(), ConnectError> {
let _ = self.closed.send(());
Ok(())
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn dropping_handle_drains_and_closes_the_source() {
let (closed_tx, mut closed_rx) = mpsc::unbounded_channel();
let (sink, _rx) = channel_sink(false);
let handle = ConnectorRuntime::new()
.add_source(ClosingSource {
records: vec![Bytes::from_static(b"a")],
pos: 0,
closed: closed_tx,
})
.add_sink(sink)
.poll_backoff(Duration::from_millis(5))
.run();
drop(handle);
tokio::time::timeout(Duration::from_secs(5), closed_rx.recv())
.await
.expect("source closed within 5s of dropping the handle")
.expect("close signal received");
}
}