Skip to main content

crabka_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};
5use std::time::Duration;
6
7use async_trait::async_trait;
8use crabka_client_consumer::{AutoOffsetReset, Consumer};
9use crabka_connect::{ConnectError, ConnectRecord, OffsetValue, Source, SourceOffset};
10
11use crate::record::ReplicatedRecord;
12
13/// A [`Source`] implementation backed by a Kafka consumer on the source cluster.
14///
15/// Wraps a [`Consumer`] and translates each [`crabka_client_consumer::ConsumerRecord`]
16/// into a [`ReplicatedRecord`] that carries the full envelope (topic, partition,
17/// offset, timestamp, headers) alongside the raw payload. The connect runtime
18/// never sees topic/partition directly — only the `ReplicatedRecord` value.
19pub struct SourceConsumer {
20    consumer: Option<Consumer>,
21    buf: VecDeque<ReplicatedRecord>,
22    /// Next-offset-to-read per `"<topic>-<partition>"` key (i.e. `last_offset + 1`).
23    positions: BTreeMap<String, i64>,
24}
25
26/// Split a `"<topic>-<partition>"` checkpoint key back into its parts.
27///
28/// The key is built by [`SourceConsumer::poll`] / [`checkpoint`] as
29/// `format!("{topic}-{partition}")`. Kafka topic names may themselves contain
30/// `-`, so we split on the **last** `-` and parse the suffix as the partition
31/// index. Returns `None` if there is no `-`, the suffix is not a valid `i32`,
32/// or the topic part is empty.
33///
34/// [`checkpoint`]: SourceConsumer::checkpoint
35fn split_topic_partition(key: &str) -> Option<(String, i32)> {
36    let (topic, part) = key.rsplit_once('-')?;
37    if topic.is_empty() {
38        return None;
39    }
40    let partition: i32 = part.parse().ok()?;
41    Some((topic.to_string(), partition))
42}
43
44impl SourceConsumer {
45    /// Build and start a [`SourceConsumer`] subscribed to `topics` on the
46    /// cluster at `bootstrap`, joining `group_id`.
47    ///
48    /// Offsets reset to earliest (no previously committed offset for the group).
49    /// Pass `security` when the source cluster requires authentication/TLS.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`ConnectError::Backend`] if the consumer cannot join the group.
54    pub async fn start(
55        bootstrap: &str,
56        group_id: &str,
57        topics: &[String],
58        security: Option<crabka_client_core::security::ClientSecurity>,
59    ) -> Result<Self, ConnectError> {
60        let builder = Consumer::builder()
61            .bootstrap(bootstrap)
62            .group_id(group_id)
63            .subscribe(topics.to_vec())
64            .auto_offset_reset(AutoOffsetReset::Earliest);
65
66        let consumer = match security {
67            Some(s) => builder.security(s).build().await,
68            None => builder.build().await,
69        }
70        .map_err(|e| ConnectError::Backend(e.to_string()))?;
71
72        Ok(Self {
73            consumer: Some(consumer),
74            buf: VecDeque::new(),
75            positions: BTreeMap::new(),
76        })
77    }
78}
79
80#[async_trait]
81impl Source<(), ReplicatedRecord> for SourceConsumer {
82    /// Poll the source cluster for the next record.
83    ///
84    /// Returns `Ok(None)` when the consumer is momentarily caught up (the
85    /// runtime should back off and retry).  Returns `Ok(Some(_))` with the
86    /// next [`ReplicatedRecord`] otherwise.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`ConnectError::Backend`] if the underlying consumer poll fails.
91    async fn poll(&mut self) -> Result<Option<ConnectRecord<(), ReplicatedRecord>>, ConnectError> {
92        if self.buf.is_empty() {
93            let recs = self
94                .consumer
95                .as_mut()
96                .ok_or_else(|| ConnectError::Backend("source consumer is closed".into()))?
97                .poll(Duration::from_millis(500))
98                .await
99                .map_err(|e| ConnectError::Backend(e.to_string()))?;
100
101            for r in recs {
102                // Track the next offset to read (committed position = offset + 1).
103                self.positions
104                    .insert(format!("{}-{}", r.topic, r.partition), r.offset + 1);
105
106                self.buf.push_back(ReplicatedRecord {
107                    topic: r.topic,
108                    partition: r.partition,
109                    offset: r.offset,
110                    timestamp: r.timestamp,
111                    key: r.key,
112                    value: r.value,
113                    headers: r.headers.into_iter().map(|h| (h.key, h.value)).collect(),
114                });
115            }
116        }
117
118        Ok(self
119            .buf
120            .pop_front()
121            .map(|payload| ConnectRecord::new(None, Some(payload))))
122    }
123
124    /// Snapshot the current read positions for all partitions seen so far.
125    ///
126    /// Returns `None` before the first successful poll (nothing to commit yet).
127    fn checkpoint(&self) -> Option<SourceOffset> {
128        if self.positions.is_empty() {
129            return None;
130        }
131        let position = self
132            .positions
133            .iter()
134            .map(|(k, v)| (k.clone(), OffsetValue::Long(*v)))
135            .collect();
136        Some(SourceOffset::new(BTreeMap::new(), position))
137    }
138
139    /// Restore the read position from a previously-checkpointed [`SourceOffset`].
140    ///
141    /// The runtime calls this once before the first [`poll`](Self::poll), passing
142    /// the position loaded from the durable checkpoint store on the target. Each
143    /// `position` entry is keyed `"<topic>-<partition>"` →
144    /// [`OffsetValue::Long`]`(next_offset)` (the value [`checkpoint`](Self::checkpoint)
145    /// wrote: `last_consumed + 1`). We decode each key back into `(topic,
146    /// partition)` and hand the offset to the consumer's
147    /// [`seek`](crabka_client_consumer::Consumer::seek).
148    ///
149    /// The consumer holds each seek as *pending* and materialises it at the top
150    /// of the first `poll` that sees the partition assigned — after the group's
151    /// post-assignment offset prime, but before any `Fetch` — so the sought
152    /// offset is the one fetched. That makes restart resume **from the last
153    /// fully-committed record** rather than re-reading the topic from offset 0:
154    /// no record below the sought offset is re-delivered, and none above it is
155    /// skipped (no data gap). Delivery remains **at-least-once** — a crash
156    /// between a sink flush and the checkpoint save can re-deliver the in-flight
157    /// batch, but never lose a record.
158    ///
159    /// A malformed key (no `-`, or a non-integer partition/offset) is skipped
160    /// with a warning rather than failing the restore: one corrupt entry must
161    /// not strand recovery for the partitions that decoded cleanly.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`ConnectError::Backend`] if the consumer is already closed.
166    async fn seek(&mut self, offset: SourceOffset) -> Result<(), ConnectError> {
167        let consumer = self
168            .consumer
169            .as_ref()
170            .ok_or_else(|| ConnectError::Backend("source consumer is closed".into()))?;
171
172        for (key, value) in &offset.position {
173            let OffsetValue::Long(next) = value else {
174                tracing::warn!(key, "checkpoint position value is not a Long; skipping");
175                continue;
176            };
177            let Some((topic, partition)) = split_topic_partition(key) else {
178                tracing::warn!(
179                    key,
180                    "checkpoint position key is not '<topic>-<partition>'; skipping"
181                );
182                continue;
183            };
184            // Seed our local position view too, so a `checkpoint()` taken before
185            // the first poll reflects the restored position rather than dropping
186            // back to empty.
187            self.positions.insert(key.clone(), *next);
188            consumer
189                .seek(topic, partition, *next)
190                .await
191                .map_err(|e| ConnectError::Backend(e.to_string()))?;
192        }
193        Ok(())
194    }
195
196    /// Close the underlying consumer, sending `LeaveGroup` so a restarted
197    /// replicator can rejoin the group immediately instead of waiting out the
198    /// departed member's session timeout.
199    ///
200    /// # Errors
201    ///
202    /// Returns [`ConnectError::Backend`] if the consumer fails to close cleanly.
203    async fn close(&mut self) -> Result<(), ConnectError> {
204        if let Some(consumer) = self.consumer.take() {
205            consumer
206                .close()
207                .await
208                .map_err(|e| ConnectError::Backend(e.to_string()))?;
209        }
210        Ok(())
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use assert2::assert;
217
218    use super::*;
219    use crabka_connect::Source;
220
221    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
222    async fn source_polls_records_with_topic_and_offset() {
223        let dir = tempfile::TempDir::new().unwrap();
224        let broker = crabka_broker::Broker::start(crabka_broker::BrokerConfig::for_tests(
225            dir.path().to_path_buf(),
226        ))
227        .await
228        .unwrap();
229        let bootstrap = broker.listen_addr().to_string();
230
231        crate::test_util::create_topic(&bootstrap, "orders", 1).await;
232        crate::test_util::produce(&bootstrap, "orders", b"k", b"v").await;
233
234        let mut src = SourceConsumer::start(
235            &bootstrap,
236            "crabka-replicator-flow1",
237            &["orders".to_string()],
238            None,
239        )
240        .await
241        .unwrap();
242
243        // Poll until the produced record surfaces. Bounded (not an open `loop`)
244        // so the `poll -> Ok(None)` mutant — a source that never yields — fails
245        // this test fast instead of spinning to the cargo-mutants timeout.
246        let mut rec = None;
247        for _ in 0..200 {
248            if let Some(r) = src.poll().await.unwrap() {
249                rec = Some(r);
250                break;
251            }
252        }
253        let rec = rec.expect("source did not yield the produced record");
254
255        let payload = rec.value.unwrap();
256        assert!(payload.topic == "orders");
257        assert!(payload.partition == 0);
258        assert!(payload.offset == 0);
259        assert!(payload.value.as_deref() == Some(b"v".as_slice()));
260
261        // The checkpoint position is the NEXT offset to read: `last_offset + 1`.
262        // Having consumed offset 0, the position for `orders-0` must be exactly
263        // 1 (not 0 from `*1` or -1 from `-1`).
264        let off = src.checkpoint().unwrap();
265        assert!(off.position.get("orders-0") == Some(&OffsetValue::Long(1)));
266    }
267
268    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
269    async fn close_takes_consumer_so_poll_fails_afterwards() {
270        let dir = tempfile::TempDir::new().unwrap();
271        let broker = crabka_broker::Broker::start(crabka_broker::BrokerConfig::for_tests(
272            dir.path().to_path_buf(),
273        ))
274        .await
275        .unwrap();
276        let bootstrap = broker.listen_addr().to_string();
277
278        crate::test_util::create_topic(&bootstrap, "orders", 1).await;
279
280        let mut src = SourceConsumer::start(
281            &bootstrap,
282            "crabka-replicator-flow-close",
283            &["orders".to_string()],
284            None,
285        )
286        .await
287        .unwrap();
288
289        // A real close takes (and closes) the inner consumer; after that the
290        // consumer is `None`, so a subsequent poll must surface a backend error.
291        // The `close -> Ok(())` mutant skips the take, leaving the consumer live
292        // and the poll succeeding.
293        src.close().await.unwrap();
294        assert!(src.poll().await.is_err());
295    }
296}