Skip to main content

krabka_replicator/
source.rs

1//! Source side: a consumer on the source cluster that emits [`ReplicatedRecord`]s
2//! and snapshots all partition positions as a [`SourceOffset`].
3
4use 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
17/// A [`Source`] implementation backed by a Kafka consumer on the source cluster.
18///
19/// This type wraps a [`Consumer`] and translates each
20/// [`krabka_client_consumer::ConsumerRecord`] into a [`ReplicatedRecord`]. That
21/// record carries the full envelope of topic, partition, offset, timestamp, and
22/// headers next to the raw payload. The connect runtime receives the whole
23/// `ReplicatedRecord` value, including the source coordinates that offset-sync
24/// generation needs.
25pub struct SourceConsumer {
26    consumer: Option<Consumer>,
27    buf: VecDeque<ReplicatedRecord>,
28    /// Next offset to read per `"<topic>-<partition>"` key, that is,
29    /// `last_offset + 1`.
30    positions: BTreeMap<String, i64>,
31    poll_timeout: Time,
32}
33
34/// Split a `"<topic>-<partition>"` checkpoint key back into its parts.
35///
36/// [`SourceConsumer::poll`] and [`checkpoint`] build the key as
37/// `format!("{topic}-{partition}")`. Kafka topic names can themselves contain
38/// `-`, so this function splits on the **last** `-` and parses the suffix as
39/// the partition index. It returns `None` if there is no `-`, if the suffix is
40/// not a valid `i32`, or if the topic part is empty.
41///
42/// [`checkpoint`]: SourceConsumer::checkpoint
43fn 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    /// Build and start a [`SourceConsumer`] that subscribes to `topics` on the
54    /// cluster at `bootstrap` and joins `group_id`.
55    ///
56    /// Offsets reset to earliest, because the group has no previously committed
57    /// offset. Pass `security` when the source cluster needs authentication or
58    /// TLS.
59    ///
60    /// # Errors
61    ///
62    /// Returns [`ConnectError::Backend`] if the consumer cannot join the group.
63    #[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    /// Build with the deployment's client resource policy.
86    ///
87    /// # Errors
88    ///
89    /// Returns a [`ConnectError`] if the consumer cannot connect.
90    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    /// Poll the source cluster for the next record.
150    ///
151    /// Returns `Ok(None)` when the consumer is momentarily caught up. The
152    /// runtime should back off and retry. Returns `Ok(Some(_))` with the
153    /// next [`ReplicatedRecord`] in every other case.
154    ///
155    /// # Errors
156    ///
157    /// Returns [`ConnectError::Backend`] if the underlying consumer poll fails.
158    #[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        // Fetch may prefetch beyond the runtime's next commit boundary. Only
192        // records handed to the runtime belong in its durable checkpoint.
193        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    /// Snapshot the current read positions for all partitions seen so far.
201    ///
202    /// Returns `None` before the first successful poll, when there is nothing to
203    /// commit yet.
204    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    /// Restore the read position from a previously-checkpointed [`SourceOffset`].
217    ///
218    /// The runtime calls this method once before the first [`poll`](Self::poll)
219    /// and passes the position that it loaded from the durable checkpoint store
220    /// on the target. Each `position` entry maps the key
221    /// `"<topic>-<partition>"` to [`OffsetValue::Long`]`(next_offset)`, the
222    /// value that [`checkpoint`](Self::checkpoint) wrote as `last_consumed + 1`.
223    /// This method decodes each key back into `(topic, partition)` and hands the
224    /// offset to the [`seek`](krabka_client_consumer::Consumer::seek) of the
225    /// consumer.
226    ///
227    /// The consumer holds each seek as *pending*. It materialises the seek at
228    /// the top of the first `poll` that sees the partition assigned, after the
229    /// post-assignment offset prime of the group but before any `Fetch`. The
230    /// sought offset is therefore the offset that the consumer fetches. A
231    /// restart then resumes **from the last fully-committed record** and does
232    /// not re-read the topic from offset 0. No record below the sought offset is
233    /// re-delivered and no record above it is skipped, so there is no data gap.
234    /// An at-least-once flow can re-deliver a batch after a crash between the
235    /// sink flush and checkpoint save. An exactly-once flow writes this same
236    /// position inside the target producer transaction, so output and recovery
237    /// position become visible together.
238    ///
239    /// This method skips a malformed key with a warning and does not fail the
240    /// restore. A malformed key has no `-`, or a non-integer partition or
241    /// offset. One corrupt entry must not strand recovery for the partitions
242    /// that decoded cleanly.
243    ///
244    /// # Errors
245    ///
246    /// Returns [`ConnectError::Backend`] if the consumer is already closed.
247    #[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            // Seed our local position view too, so a `checkpoint()` taken before
272            // the first poll reflects the restored position rather than dropping
273            // back to empty.
274            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    /// Close the underlying consumer. The close sends `LeaveGroup`, so a
284    /// restarted replicator can rejoin the group immediately and does not wait
285    /// out the session timeout of the departed member.
286    ///
287    /// # Errors
288    ///
289    /// Returns [`ConnectError::Backend`] if the consumer fails to close cleanly.
290    #[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        // Poll until the produced record surfaces. Bounded (not an open `loop`)
332        // so the `poll -> Ok(None)` mutant — a source that never yields — fails
333        // this test fast instead of spinning to the cargo-mutants timeout.
334        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        // The checkpoint position is the NEXT offset to read: `last_offset + 1`.
350        // Having consumed offset 0, the position for `orders-0` must be exactly
351        // 1 (not 0 from `*1` or -1 from `-1`).
352        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        // A real close takes (and closes) the inner consumer; after that the
437        // consumer is `None`, so a subsequent poll must surface a backend error.
438        // The `close -> Ok(())` mutant skips the take, leaving the consumer live
439        // and the poll succeeding.
440        src.close().await.unwrap();
441        assert2::assert!(src.poll().await.is_err());
442    }
443}