Skip to main content

crabka_replicator/tasks/
checkpoint.rs

1//! Periodic checkpoint task: translates and emits consumer-group offset checkpoints.
2//!
3//! Reads committed consumer-group offsets from the SOURCE cluster, translates
4//! them to TARGET offsets via an [`OffsetSyncStore`], and writes MM2-compatible
5//! [`Checkpoint`] records to `<source>.checkpoints.internal` on the TARGET cluster.
6
7use bytes::Bytes;
8use crabka_client_admin::AdminClient;
9use crabka_client_producer::{Acks, Producer, ProducerRecord};
10use tracing::warn;
11
12use crate::config::NamingPolicy;
13use crate::error::ReplicatorError;
14use crate::mm2::{Checkpoint, OffsetSync};
15use crate::naming::Renamer;
16use crate::offset_sync_store::OffsetSyncStore;
17use crate::selector::Selector;
18
19/// Parameters required to run the checkpoint task.
20pub struct CheckpointParams {
21    /// Bootstrap address of the source cluster (where consumer-group offsets live).
22    pub source_bootstrap: String,
23    /// Bootstrap address of the target cluster (where checkpoints are written).
24    pub target_bootstrap: String,
25    /// Alias of the source cluster (used to derive MM2 topic names).
26    pub source_alias: String,
27    /// Naming policy for the flow, used to rename the checkpoint's topic to the
28    /// remote name a failed-over consumer reads (MM2 `RemoteClusterUtils` parity).
29    pub naming: NamingPolicy,
30    /// Selector for which consumer groups to checkpoint.
31    pub group_selector: Selector,
32    /// Optional TLS/SASL security applied to the target producer + admin.
33    pub security: Option<crabka_client_core::security::ClientSecurity>,
34}
35
36/// One translation pass: reads source group offsets, translates via `store`,
37/// and writes [`Checkpoint`] records to the target checkpoints topic.
38///
39/// # Errors
40/// Returns [`ReplicatorError`] on topic-ensure, admin-connect, or produce failures.
41pub async fn run_once(
42    params: &CheckpointParams,
43    store: &OffsetSyncStore,
44) -> Result<(), ReplicatorError> {
45    let checkpoint_topic = Checkpoint::topic_name(&params.source_alias);
46
47    // 1. Ensure the checkpoints topic exists on the target.
48    crate::admin_util::ensure_topic(
49        &params.target_bootstrap,
50        &checkpoint_topic,
51        1,
52        params.security.clone(),
53    )
54    .await
55    .map_err(|e| ReplicatorError::Client(format!("ensure checkpoints topic: {e}")))?;
56
57    // 2. Connect to the source cluster to list group offsets.
58    let mut admin = AdminClient::connect_secured(
59        std::slice::from_ref(&params.source_bootstrap),
60        None, // source cluster: no security (security param applies to target only)
61    )
62    .await
63    .map_err(|e| ReplicatorError::Client(format!("source admin connect: {e}")))?;
64
65    // 3. List and filter consumer groups; skip our own internal groups.
66    let all_groups = admin
67        .list_groups()
68        .await
69        .map_err(|e| ReplicatorError::Client(format!("list_groups: {e}")))?;
70
71    let groups: Vec<String> = all_groups
72        .into_iter()
73        .filter(|g| !g.starts_with("crabka-replicator-"))
74        .filter(|g| params.group_selector.matches(g))
75        .collect();
76
77    // 4. Build a producer to write checkpoint records to the target.
78    let producer = build_producer(&params.target_bootstrap, params.security.clone())
79        .await
80        .map_err(|e| ReplicatorError::Client(format!("build target producer: {e}")))?;
81
82    // 5. For each matched group, translate committed offsets and produce checkpoints.
83    // The checkpoint's topic is the REMOTE (renamed) topic a failed-over consumer
84    // reads on the target, matching MM2's `renameTopicPartition`. Offset-sync
85    // translation still keys on the SOURCE topic name.
86    let renamer = Renamer::new(params.naming, &params.source_alias);
87    for group in &groups {
88        let offsets = match admin.list_consumer_group_offsets(group).await {
89            Ok(o) => o,
90            Err(e) => {
91                warn!(group = %group, error = %e, "list_consumer_group_offsets failed; skipping group");
92                continue;
93            }
94        };
95
96        for ((topic, partition), committed) in offsets {
97            let Some(downstream) = store.translate(&topic, partition, committed) else {
98                continue;
99            };
100
101            let checkpoint = Checkpoint {
102                group: group.clone(),
103                topic: renamer.target_name(&topic),
104                partition,
105                upstream: committed,
106                downstream,
107                metadata: String::new(),
108            };
109
110            let _rx = producer
111                .send(ProducerRecord {
112                    topic: checkpoint_topic.clone(),
113                    partition: None,
114                    key: Some(Bytes::from(checkpoint.key_bytes())),
115                    value: Some(Bytes::from(checkpoint.value_bytes())),
116                    headers: Vec::new(),
117                    timestamp_ms: None,
118                })
119                .await;
120        }
121    }
122
123    // 6. Flush to ensure all checkpoint records are durably written.
124    producer
125        .flush()
126        .await
127        .map_err(|e| ReplicatorError::Client(format!("producer flush: {e}")))?;
128
129    Ok(())
130}
131
132/// Background task that periodically rebuilds the [`OffsetSyncStore`] from the
133/// target cluster's offset-syncs topic and then calls [`run_once`].
134pub struct CheckpointTask {
135    handle: tokio::task::JoinHandle<()>,
136    shutdown: tokio::sync::watch::Sender<bool>,
137}
138
139impl CheckpointTask {
140    /// Start the checkpoint loop with the given interval.
141    ///
142    /// Each cycle:
143    /// 1. Reads all records from `mm2-offset-syncs.<source>.internal` on the TARGET.
144    /// 2. Builds a fresh [`OffsetSyncStore`].
145    /// 3. Calls [`run_once`] to write translated checkpoints.
146    ///
147    /// Errors in any cycle are logged as warnings; the loop continues until shutdown.
148    ///
149    /// # Errors
150    /// Returns [`ReplicatorError`] only if initial setup fails (currently infallible;
151    /// error type is reserved for future validation).
152    #[allow(clippy::unused_async)]
153    pub async fn start(
154        params: CheckpointParams,
155        interval: std::time::Duration,
156    ) -> Result<Self, ReplicatorError> {
157        let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false);
158
159        let handle = tokio::spawn(async move {
160            loop {
161                // Build OffsetSyncStore from target offset-syncs topic.
162                let mut store = OffsetSyncStore::default();
163                let offset_syncs_topic = OffsetSync::topic_name(&params.source_alias);
164
165                match crate::admin_util::read_all(
166                    &params.target_bootstrap,
167                    &offset_syncs_topic,
168                    params.security.clone(),
169                )
170                .await
171                {
172                    Ok(records) => {
173                        for (k, v) in records {
174                            if let (Some(k), Some(v)) = (k, v) {
175                                match OffsetSync::from_bytes(&k, &v) {
176                                    Ok(os) => store.ingest(os),
177                                    Err(e) => {
178                                        warn!(error = %e, "failed to decode offset-sync record; skipping");
179                                    }
180                                }
181                            }
182                        }
183                    }
184                    Err(e) => {
185                        warn!(error = %e, "failed to read offset-syncs topic; skipping cycle");
186                    }
187                }
188
189                if let Err(e) = run_once(&params, &store).await {
190                    warn!(error = %e, "checkpoint run_once failed");
191                }
192
193                // Wait for the next interval or a shutdown signal.
194                tokio::select! {
195                    () = tokio::time::sleep(interval) => {}
196                    _ = shutdown_rx.changed() => {
197                        if *shutdown_rx.borrow() {
198                            break;
199                        }
200                    }
201                }
202            }
203        });
204
205        Ok(Self {
206            handle,
207            shutdown: shutdown_tx,
208        })
209    }
210
211    /// Signal the task to stop and await its completion.
212    // cargo-mutants: shutdown signal/join not asserted by unit tests
213    #[cfg_attr(test, mutants::skip)]
214    pub async fn shutdown(self) {
215        let _ = self.shutdown.send(true);
216        let _ = self.handle.await;
217    }
218}
219
220/// Build a non-idempotent producer with `acks=All` targeting the given bootstrap.
221async fn build_producer(
222    bootstrap: &str,
223    security: Option<crabka_client_core::security::ClientSecurity>,
224) -> Result<Producer, crabka_client_producer::ProducerError> {
225    let builder = Producer::builder()
226        .bootstrap(bootstrap)
227        .enable_idempotence(false)
228        .acks(Acks::All);
229    match security {
230        Some(s) => builder.security(s).build().await,
231        None => builder.build().await,
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use assert2::assert;
238
239    use super::*;
240    use crate::mm2::{Checkpoint, OffsetSync};
241    use crate::offset_sync_store::OffsetSyncStore;
242
243    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
244    async fn writes_translated_checkpoints() {
245        let s_dir = tempfile::TempDir::new().unwrap();
246        let t_dir = tempfile::TempDir::new().unwrap();
247        let source = crabka_broker::Broker::start(crabka_broker::BrokerConfig::for_tests(
248            s_dir.path().to_path_buf(),
249        ))
250        .await
251        .unwrap();
252        let target = crabka_broker::Broker::start(crabka_broker::BrokerConfig::for_tests(
253            t_dir.path().to_path_buf(),
254        ))
255        .await
256        .unwrap();
257        let sb = source.listen_addr().to_string();
258        let tb = target.listen_addr().to_string();
259
260        crate::test_util::create_topic(&sb, "orders", 1).await;
261        for _ in 0..200 {
262            crate::test_util::produce(&sb, "orders", b"k", b"v").await;
263        }
264        crate::test_util::commit_group(&sb, "g", "orders").await;
265
266        let mut syncs = OffsetSyncStore::default();
267        syncs.ingest(OffsetSync {
268            topic: "orders".into(),
269            partition: 0,
270            upstream: 0,
271            downstream: 0,
272        });
273        syncs.ingest(OffsetSync {
274            topic: "orders".into(),
275            partition: 0,
276            upstream: 200,
277            downstream: 165,
278        });
279
280        run_once(
281            &CheckpointParams {
282                source_bootstrap: sb,
283                target_bootstrap: tb.clone(),
284                source_alias: "us-east".into(),
285                naming: NamingPolicy::Default,
286                group_selector: Selector::compile(&["g".into()], &[]).unwrap(),
287                security: None,
288            },
289            &syncs,
290        )
291        .await
292        .unwrap();
293
294        let raw = crate::admin_util::read_last_value_for_key(
295            &tb,
296            &Checkpoint::topic_name("us-east"),
297            b"",
298            None,
299        )
300        .await
301        .unwrap()
302        .unwrap();
303        assert!(!raw.is_empty());
304    }
305}