use std::collections::{BTreeMap, VecDeque};
use std::time::Duration;
use async_trait::async_trait;
use crabka_client_consumer::{AutoOffsetReset, Consumer};
use crabka_connect::{ConnectError, ConnectRecord, OffsetValue, Source, SourceOffset};
use crate::record::ReplicatedRecord;
pub struct SourceConsumer {
consumer: Option<Consumer>,
buf: VecDeque<ReplicatedRecord>,
positions: BTreeMap<String, i64>,
}
fn split_topic_partition(key: &str) -> Option<(String, i32)> {
let (topic, part) = key.rsplit_once('-')?;
if topic.is_empty() {
return None;
}
let partition: i32 = part.parse().ok()?;
Some((topic.to_string(), partition))
}
impl SourceConsumer {
pub async fn start(
bootstrap: &str,
group_id: &str,
topics: &[String],
security: Option<crabka_client_core::security::ClientSecurity>,
) -> Result<Self, ConnectError> {
let builder = Consumer::builder()
.bootstrap(bootstrap)
.group_id(group_id)
.subscribe(topics.to_vec())
.auto_offset_reset(AutoOffsetReset::Earliest);
let consumer = match security {
Some(s) => builder.security(s).build().await,
None => builder.build().await,
}
.map_err(|e| ConnectError::Backend(e.to_string()))?;
Ok(Self {
consumer: Some(consumer),
buf: VecDeque::new(),
positions: BTreeMap::new(),
})
}
}
#[async_trait]
impl Source<(), ReplicatedRecord> for SourceConsumer {
async fn poll(&mut self) -> Result<Option<ConnectRecord<(), ReplicatedRecord>>, ConnectError> {
if self.buf.is_empty() {
let recs = self
.consumer
.as_mut()
.ok_or_else(|| ConnectError::Backend("source consumer is closed".into()))?
.poll(Duration::from_millis(500))
.await
.map_err(|e| ConnectError::Backend(e.to_string()))?;
for r in recs {
self.positions
.insert(format!("{}-{}", r.topic, r.partition), r.offset + 1);
self.buf.push_back(ReplicatedRecord {
topic: r.topic,
partition: r.partition,
offset: r.offset,
timestamp: r.timestamp,
key: r.key,
value: r.value,
headers: r.headers.into_iter().map(|h| (h.key, h.value)).collect(),
});
}
}
Ok(self
.buf
.pop_front()
.map(|payload| ConnectRecord::new(None, Some(payload))))
}
fn checkpoint(&self) -> Option<SourceOffset> {
if self.positions.is_empty() {
return None;
}
let position = self
.positions
.iter()
.map(|(k, v)| (k.clone(), OffsetValue::Long(*v)))
.collect();
Some(SourceOffset::new(BTreeMap::new(), position))
}
async fn seek(&mut self, offset: SourceOffset) -> Result<(), ConnectError> {
let consumer = self
.consumer
.as_ref()
.ok_or_else(|| ConnectError::Backend("source consumer is closed".into()))?;
for (key, value) in &offset.position {
let OffsetValue::Long(next) = value else {
tracing::warn!(key, "checkpoint position value is not a Long; skipping");
continue;
};
let Some((topic, partition)) = split_topic_partition(key) else {
tracing::warn!(
key,
"checkpoint position key is not '<topic>-<partition>'; skipping"
);
continue;
};
self.positions.insert(key.clone(), *next);
consumer
.seek(topic, partition, *next)
.await
.map_err(|e| ConnectError::Backend(e.to_string()))?;
}
Ok(())
}
async fn close(&mut self) -> Result<(), ConnectError> {
if let Some(consumer) = self.consumer.take() {
consumer
.close()
.await
.map_err(|e| ConnectError::Backend(e.to_string()))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use assert2::assert;
use super::*;
use crabka_connect::Source;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn source_polls_records_with_topic_and_offset() {
let dir = tempfile::TempDir::new().unwrap();
let broker = crabka_broker::Broker::start(crabka_broker::BrokerConfig::for_tests(
dir.path().to_path_buf(),
))
.await
.unwrap();
let bootstrap = broker.listen_addr().to_string();
crate::test_util::create_topic(&bootstrap, "orders", 1).await;
crate::test_util::produce(&bootstrap, "orders", b"k", b"v").await;
let mut src = SourceConsumer::start(
&bootstrap,
"crabka-replicator-flow1",
&["orders".to_string()],
None,
)
.await
.unwrap();
let mut rec = None;
for _ in 0..200 {
if let Some(r) = src.poll().await.unwrap() {
rec = Some(r);
break;
}
}
let rec = rec.expect("source did not yield the produced record");
let payload = rec.value.unwrap();
assert!(payload.topic == "orders");
assert!(payload.partition == 0);
assert!(payload.offset == 0);
assert!(payload.value.as_deref() == Some(b"v".as_slice()));
let off = src.checkpoint().unwrap();
assert!(off.position.get("orders-0") == Some(&OffsetValue::Long(1)));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn close_takes_consumer_so_poll_fails_afterwards() {
let dir = tempfile::TempDir::new().unwrap();
let broker = crabka_broker::Broker::start(crabka_broker::BrokerConfig::for_tests(
dir.path().to_path_buf(),
))
.await
.unwrap();
let bootstrap = broker.listen_addr().to_string();
crate::test_util::create_topic(&bootstrap, "orders", 1).await;
let mut src = SourceConsumer::start(
&bootstrap,
"crabka-replicator-flow-close",
&["orders".to_string()],
None,
)
.await
.unwrap();
src.close().await.unwrap();
assert!(src.poll().await.is_err());
}
}