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}