1use std::collections::{BTreeMap, VecDeque};
5
6use async_trait::async_trait;
7use krabka_client_consumer::{AutoOffsetReset, Consumer, IsolationLevel};
8use krabka_connect::{ConnectError, ConnectRecord, OffsetMap, OffsetValue, Source, SourceOffset};
9use krabka_units::prelude::Time;
10
11use crate::{
12 config::{ClientResourcePolicy, Delivery, ReplicatorRuntimePolicy},
13 ids::{Offset, PartitionIndex, Timestamp},
14 record::ReplicatedRecord,
15};
16
17pub struct SourceConsumer {
26 consumer: Option<Consumer>,
27 buf: VecDeque<ReplicatedRecord>,
28 positions: BTreeMap<String, i64>,
31 poll_timeout: Time,
32}
33
34fn split_topic_partition(key: &str) -> Option<(String, i32)> {
44 let (topic, part) = key.rsplit_once('-')?;
45 if topic.is_empty() {
46 return None;
47 }
48 let partition: i32 = part.parse().ok()?;
49 Some((topic.to_string(), partition))
50}
51
52impl SourceConsumer {
53 #[tracing::instrument(
64 level = "info",
65 skip_all,
66 fields(bootstrap = %bootstrap, group_id = %group_id, topics = topics.len()),
67 err,
68 )]
69 pub async fn start(
70 bootstrap: &str,
71 group_id: &str,
72 topics: &[String],
73 security: Option<krabka_client_core::security::ClientSecurity>,
74 ) -> Result<Self, ConnectError> {
75 Self::start_with_policy(
76 bootstrap,
77 group_id,
78 topics,
79 security,
80 ClientResourcePolicy::default(),
81 )
82 .await
83 }
84
85 pub async fn start_with_policy(
91 bootstrap: &str,
92 group_id: &str,
93 topics: &[String],
94 security: Option<krabka_client_core::security::ClientSecurity>,
95 client_resource_policy: ClientResourcePolicy,
96 ) -> Result<Self, ConnectError> {
97 Self::start_with_runtime_policy(
98 bootstrap,
99 group_id,
100 topics,
101 security,
102 client_resource_policy,
103 &ReplicatorRuntimePolicy::default(),
104 Delivery::AtLeastOnce,
105 )
106 .await
107 }
108
109 pub(crate) async fn start_with_runtime_policy(
110 bootstrap: &str,
111 group_id: &str,
112 topics: &[String],
113 security: Option<krabka_client_core::security::ClientSecurity>,
114 client_resource_policy: ClientResourcePolicy,
115 runtime_policy: &ReplicatorRuntimePolicy,
116 delivery: Delivery,
117 ) -> Result<Self, ConnectError> {
118 let builder = Consumer::builder()
119 .bootstrap(bootstrap)
120 .dispatch_queue_capacity(client_resource_policy.dispatch_queue_capacity.get())
121 .frame_max(client_resource_policy.frame_max.size())
122 .request_timeout(runtime_policy.client_request_timeout)
123 .group_id(group_id)
124 .enable_auto_commit(false)
125 .subscribe(topics.to_vec())
126 .isolation_level(match delivery {
127 Delivery::AtLeastOnce => IsolationLevel::ReadUncommitted,
128 Delivery::ExactlyOnce => IsolationLevel::ReadCommitted,
129 })
130 .auto_offset_reset(AutoOffsetReset::Earliest);
131
132 let consumer = match security {
133 Some(s) => builder.security(s).build().await,
134 None => builder.build().await,
135 }
136 .map_err(|e| ConnectError::Backend(e.to_string()))?;
137
138 Ok(Self {
139 consumer: Some(consumer),
140 buf: VecDeque::new(),
141 positions: BTreeMap::new(),
142 poll_timeout: runtime_policy.source_poll_timeout,
143 })
144 }
145}
146
147#[async_trait]
148impl Source<(), ReplicatedRecord> for SourceConsumer {
149 #[tracing::instrument(
159 level = "debug",
160 skip_all,
161 fields(fetched = tracing::field::Empty),
162 err,
163 )]
164 async fn poll(&mut self) -> Result<Option<ConnectRecord<(), ReplicatedRecord>>, ConnectError> {
165 if self.buf.is_empty() {
166 let recs = self
167 .consumer
168 .as_mut()
169 .ok_or_else(|| ConnectError::Backend("source consumer is closed".into()))?
170 .poll(self.poll_timeout)
171 .await
172 .map_err(|e| ConnectError::Backend(e.to_string()))?;
173
174 tracing::Span::current().record("fetched", recs.len());
175 for r in recs {
176 self.buf.push_back(ReplicatedRecord {
177 topic: r.topic,
178 partition: PartitionIndex(r.partition),
179 offset: Offset(r.offset),
180 timestamp: Timestamp(r.timestamp),
181 key: r.key,
182 value: r.value,
183 headers: r.headers.into_iter().map(|h| (h.key, h.value)).collect(),
184 });
185 }
186 }
187
188 let Some(payload) = self.buf.pop_front() else {
189 return Ok(None);
190 };
191 self.positions.insert(
194 format!("{}-{}", payload.topic, payload.partition.0),
195 payload.offset.0 + 1,
196 );
197 Ok(Some(ConnectRecord::new(None, Some(payload))))
198 }
199
200 fn checkpoint(&self) -> Option<SourceOffset> {
205 if self.positions.is_empty() {
206 return None;
207 }
208 let position: OffsetMap = self
209 .positions
210 .iter()
211 .map(|(k, v)| (k.clone(), OffsetValue::Long(*v)))
212 .collect();
213 Some(SourceOffset::new(BTreeMap::new().into(), position.into()))
214 }
215
216 #[tracing::instrument(
248 level = "debug",
249 skip_all,
250 fields(positions = offset.position.len()),
251 err,
252 )]
253 async fn seek(&mut self, offset: SourceOffset) -> Result<(), ConnectError> {
254 let consumer = self
255 .consumer
256 .as_ref()
257 .ok_or_else(|| ConnectError::Backend("source consumer is closed".into()))?;
258
259 for (key, value) in offset.position.iter() {
260 let OffsetValue::Long(next) = value else {
261 tracing::warn!(key, "checkpoint position value is not a Long; skipping");
262 continue;
263 };
264 let Some((topic, partition)) = split_topic_partition(key) else {
265 tracing::warn!(
266 key,
267 "checkpoint position key is not '<topic>-<partition>'; skipping"
268 );
269 continue;
270 };
271 self.positions.insert(key.clone(), *next);
275 consumer
276 .seek(topic, partition, *next)
277 .await
278 .map_err(|e| ConnectError::Backend(e.to_string()))?;
279 }
280 Ok(())
281 }
282
283 #[tracing::instrument(level = "info", skip_all, err)]
291 async fn close(&mut self) -> Result<(), ConnectError> {
292 if let Some(consumer) = self.consumer.take() {
293 consumer
294 .close()
295 .await
296 .map_err(|e| ConnectError::Backend(e.to_string()))?;
297 }
298 Ok(())
299 }
300}
301
302#[cfg(test)]
303mod tests {
304
305 use krabka_connect::Source;
306
307 use super::*;
308
309 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
310 async fn source_polls_records_with_topic_and_offset() {
311 let dir = tempfile::TempDir::new().unwrap();
312 let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
313 dir.path().to_path_buf(),
314 ))
315 .await
316 .unwrap();
317 let bootstrap = broker.listen_addr().to_string();
318
319 crate::test_util::create_topic(&bootstrap, "orders", 1).await;
320 crate::test_util::produce(&bootstrap, "orders", b"k", b"v").await;
321
322 let mut src = SourceConsumer::start(
323 &bootstrap,
324 "krabka-replicator-flow1",
325 &["orders".to_string()],
326 None,
327 )
328 .await
329 .unwrap();
330
331 let mut rec = None;
335 for _ in 0..200 {
336 if let Some(r) = src.poll().await.unwrap() {
337 rec = Some(r);
338 break;
339 }
340 }
341 let rec = rec.expect("source did not yield the produced record");
342
343 let payload = rec.value.unwrap();
344 assert2::assert!(payload.topic.as_str() == "orders");
345 assert2::assert!(payload.partition == PartitionIndex(0));
346 assert2::assert!(payload.offset == Offset(0));
347 assert2::assert!(payload.value.as_deref() == Some(b"v".as_slice()));
348
349 let off = src.checkpoint().unwrap();
353 assert2::assert!(off.position.get("orders-0") == Some(&OffsetValue::Long(1)));
354
355 src.close().await.unwrap();
356 let mut admin = krabka_client_admin::AdminClient::connect(&[bootstrap])
357 .await
358 .unwrap();
359 let offsets = admin
360 .list_consumer_group_offsets("krabka-replicator-flow1")
361 .await
362 .unwrap();
363 assert2::assert!(offsets == BTreeMap::new());
364 }
365
366 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
367 async fn checkpoint_excludes_prefetched_records_and_restart_reads_them() {
368 let dir = tempfile::TempDir::new().unwrap();
369 let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
370 dir.path().to_path_buf(),
371 ))
372 .await
373 .unwrap();
374 let bootstrap = broker.listen_addr().to_string();
375 crate::test_util::create_topic(&bootstrap, "orders", 1).await;
376 for _ in 0..10 {
377 crate::test_util::produce(&bootstrap, "orders", b"k", b"v").await;
378 }
379 let mut src =
380 SourceConsumer::start(&bootstrap, "prefetch-checkpoint", &["orders".into()], None)
381 .await
382 .unwrap();
383 let mut delivered = 0;
384 for _ in 0..200 {
385 if src.poll().await.unwrap().is_some() {
386 delivered += 1;
387 if delivered == 3 {
388 break;
389 }
390 }
391 }
392 assert2::assert!(delivered == 3);
393 assert2::assert!(!src.buf.is_empty());
394 let checkpoint = src.checkpoint().unwrap();
395 assert2::assert!(checkpoint.position.get("orders-0") == Some(&OffsetValue::Long(3)));
396 src.close().await.unwrap();
397 let mut restarted =
398 SourceConsumer::start(&bootstrap, "prefetch-restarted", &["orders".into()], None)
399 .await
400 .unwrap();
401 restarted.seek(checkpoint).await.unwrap();
402 let mut offsets = Vec::new();
403 for _ in 0..200 {
404 if let Some(record) = restarted.poll().await.unwrap() {
405 offsets.push(record.value.unwrap().offset.0);
406 if offsets.len() == 7 {
407 break;
408 }
409 }
410 }
411 assert2::assert!(offsets == (3..10).collect::<Vec<_>>());
412 restarted.close().await.unwrap();
413 }
414
415 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
416 async fn close_takes_consumer_so_poll_fails_afterwards() {
417 let dir = tempfile::TempDir::new().unwrap();
418 let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
419 dir.path().to_path_buf(),
420 ))
421 .await
422 .unwrap();
423 let bootstrap = broker.listen_addr().to_string();
424
425 crate::test_util::create_topic(&bootstrap, "orders", 1).await;
426
427 let mut src = SourceConsumer::start(
428 &bootstrap,
429 "krabka-replicator-flow-close",
430 &["orders".to_string()],
431 None,
432 )
433 .await
434 .unwrap();
435
436 src.close().await.unwrap();
441 assert2::assert!(src.poll().await.is_err());
442 }
443}