Skip to main content

krabka_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 with an [`OffsetSyncStore`], and writes MM2-compatible
5//! [`Checkpoint`] records to `<source>.checkpoints.internal` on the TARGET cluster.
6
7use std::collections::BTreeMap;
8
9use bytes::Bytes;
10use krabka_client_admin::{AdminClient, groups::ListGroupsOptions};
11use krabka_client_consumer::{AutoOffsetReset, Consumer, IsolationLevel};
12use krabka_client_producer::{Acks, Producer, ProducerRecord};
13use krabka_units::{
14    fmt::Human as _,
15    prelude::{Time, TimeExt as _},
16};
17use tracing::warn;
18
19use crate::{
20    config::{ClientResourcePolicy, NamingPolicy, ReplicatorRuntimePolicy},
21    error::ReplicatorError,
22    ids::{CommittedOffset, PartitionIndex},
23    mm2::{Checkpoint, OffsetSync},
24    naming::Renamer,
25    offset_sync_store::OffsetSyncStore,
26    selector::Selector,
27};
28
29/// Parameters required to run the checkpoint task.
30pub struct CheckpointParams {
31    /// Bootstrap address of the source cluster, where consumer-group offsets live.
32    pub source_bootstrap: String,
33    /// Bootstrap address of the target cluster, where the task writes checkpoints.
34    pub target_bootstrap: String,
35    /// Alias of the source cluster, used to derive MM2 topic names.
36    pub source_alias: String,
37    /// Naming policy for the flow. It renames the topic of the checkpoint to the
38    /// remote name that a failed-over consumer reads, for MM2
39    /// `RemoteClusterUtils` parity.
40    pub naming: NamingPolicy,
41    /// Selector for which consumer groups to checkpoint.
42    pub group_selector: Selector,
43    /// Optional TLS/SASL security applied to the target producer and admin.
44    pub security: Option<krabka_client_core::security::ClientSecurity>,
45}
46
47/// One translation pass: reads source group offsets, translates them with
48/// `store`, and writes [`Checkpoint`] records to the target checkpoints topic.
49///
50/// # Errors
51/// Returns [`ReplicatorError`] on topic-ensure, admin-connect, or produce failures.
52#[tracing::instrument(
53    level = "info",
54    skip_all,
55    fields(source_alias = %params.source_alias, groups = tracing::field::Empty),
56    err,
57)]
58pub async fn run_once(
59    params: &CheckpointParams,
60    store: &OffsetSyncStore,
61) -> Result<(), ReplicatorError> {
62    run_once_with_policy(params, store, ClientResourcePolicy::default()).await
63}
64
65/// Run one checkpoint pass with the deployment's client resource policy.
66///
67/// # Errors
68///
69/// Returns [`ReplicatorError`] on topic-ensure, admin-connect, or produce
70/// failures.
71pub async fn run_once_with_policy(
72    params: &CheckpointParams,
73    store: &OffsetSyncStore,
74    client_resource_policy: ClientResourcePolicy,
75) -> Result<(), ReplicatorError> {
76    run_once_with_runtime_policy(
77        params,
78        store,
79        client_resource_policy,
80        &ReplicatorRuntimePolicy::default(),
81    )
82    .await
83}
84
85async fn run_once_with_runtime_policy(
86    params: &CheckpointParams,
87    store: &OffsetSyncStore,
88    client_resource_policy: ClientResourcePolicy,
89    runtime_policy: &ReplicatorRuntimePolicy,
90) -> Result<(), ReplicatorError> {
91    let checkpoint_topic = Checkpoint::topic_name(&params.source_alias);
92
93    // 1. Ensure the checkpoints topic exists on the target.
94    crate::admin_util::ensure_topic_with_runtime_policy(
95        &params.target_bootstrap,
96        &checkpoint_topic,
97        1,
98        params.security.clone(),
99        client_resource_policy,
100        runtime_policy,
101        runtime_policy.internal_topic_replication_factor,
102    )
103    .await
104    .map_err(|e| ReplicatorError::Client(format!("ensure checkpoints topic: {e}")))?;
105
106    // 2. Connect to the source cluster to list group offsets.
107    let mut admin = AdminClient::connect_with_options(
108        std::slice::from_ref(&params.source_bootstrap),
109        krabka_client_core::ConnectionOptions {
110            dns_timeout: krabka_client_core::ClientDnsTimeout::new(
111                runtime_policy.client_dns_timeout,
112            )
113            .map_err(ReplicatorError::Client)?,
114            socket_connection_setup_timeout: runtime_policy.client_connect_timeout,
115            socket_connection_setup_timeout_max: runtime_policy.client_connect_timeout,
116            request_timeout: runtime_policy.client_request_timeout,
117            client_id: "krabka-operator".to_owned(),
118            dispatch_queue_capacity: client_resource_policy.dispatch_queue_capacity,
119            frame_max: client_resource_policy.frame_max,
120            security: None, // source cluster: target security does not apply
121            ..Default::default()
122        },
123    )
124    .await
125    .map_err(|e| ReplicatorError::Client(format!("source admin connect: {e}")))?;
126
127    // 3. List and filter consumer groups; skip our own internal groups.
128    let all_groups = admin
129        .list_groups(&ListGroupsOptions::default())
130        .await
131        .map_err(|e| ReplicatorError::Client(format!("list_groups: {e}")))?
132        .all()
133        .map_err(|error| ReplicatorError::Client(format!("list_groups: {error:?}")))?;
134
135    let groups: Vec<String> = all_groups
136        .into_iter()
137        .map(|group| group.group_id)
138        .filter(|g| !g.starts_with("krabka-replicator-"))
139        .filter(|g| params.group_selector.matches(g))
140        .collect();
141    tracing::Span::current().record("groups", groups.len());
142
143    // 4. Build a producer to write checkpoint records to the target.
144    let producer = build_producer(
145        &params.target_bootstrap,
146        params.security.clone(),
147        client_resource_policy,
148    )
149    .await
150    .map_err(|e| ReplicatorError::Client(format!("build target producer: {e}")))?;
151
152    // 5. For each matched group, translate committed offsets and produce checkpoints.
153    // The checkpoint's topic is the REMOTE (renamed) topic a failed-over consumer
154    // reads on the target, matching MM2's `renameTopicPartition`. Offset-sync
155    // translation still keys on the SOURCE topic name.
156    let renamer = Renamer::new(params.naming, &params.source_alias);
157    for group in &groups {
158        let offsets = match admin.list_consumer_group_offsets(group).await {
159            Ok(o) => o,
160            Err(e) => {
161                warn!(group = %group, error = %e, "list_consumer_group_offsets failed; skipping group");
162                continue;
163            }
164        };
165
166        for ((topic, partition), committed) in offsets {
167            // `offsets` comes from the cross-crate admin client as raw
168            // `(i32, i64)`; wrap at this boundary so the translation call and
169            // the checkpoint fields are type-checked.
170            let partition = PartitionIndex(partition);
171            let committed = CommittedOffset(committed);
172            let Some(downstream) = store.translate(&topic, partition, committed) else {
173                continue;
174            };
175
176            let checkpoint = Checkpoint {
177                group: group.clone(),
178                topic: renamer.target_name(&topic),
179                partition,
180                // A group's committed source offset is the upstream side of the
181                // checkpoint; the translated target offset is the downstream.
182                upstream: committed.into(),
183                downstream,
184                metadata: String::new(),
185            };
186
187            let _rx = producer
188                .enqueue(ProducerRecord {
189                    topic: checkpoint_topic.clone(),
190                    partition: None,
191                    key: Some(Bytes::from(checkpoint.key_bytes())),
192                    value: Some(Bytes::from(checkpoint.value_bytes())),
193                    headers: Vec::new(),
194                    timestamp_ms: None,
195                })
196                .await
197                .map_err(|error| ReplicatorError::Client(format!("checkpoint produce: {error}")))?;
198        }
199    }
200
201    // 6. Flush to ensure all checkpoint records are durably written.
202    producer
203        .flush()
204        .await
205        .map_err(|e| ReplicatorError::Client(format!("producer flush: {e}")))?;
206
207    Ok(())
208}
209
210/// Background task that incrementally updates an [`OffsetSyncStore`] from the
211/// target cluster's offset-syncs topic and then calls [`run_once`].
212pub struct CheckpointTask {
213    handle: tokio::task::JoinHandle<()>,
214    shutdown: tokio::sync::watch::Sender<bool>,
215}
216
217impl CheckpointTask {
218    /// Start the checkpoint loop with the given interval.
219    ///
220    /// Each cycle:
221    /// 1. Captures the read-committed end of `mm2-offset-syncs.<source>.internal`.
222    /// 2. Streams only records since the preceding cycle into a retained store.
223    /// 3. Calls [`run_once`] to write translated checkpoints.
224    ///
225    /// The loop logs an error in any cycle as a warning and continues until
226    /// shutdown.
227    #[tracing::instrument(
228        level = "info",
229        skip_all,
230        fields(source_alias = %params.source_alias, interval = %interval.human()),
231    )]
232    pub fn start(params: CheckpointParams, interval: Time) -> Self {
233        Self::start_with_policy(params, interval, ClientResourcePolicy::default())
234    }
235
236    /// Start with the deployment's client resource policy.
237    #[must_use]
238    pub fn start_with_policy(
239        params: CheckpointParams,
240        interval: Time,
241        client_resource_policy: ClientResourcePolicy,
242    ) -> Self {
243        Self::start_with_runtime_policy(
244            params,
245            interval,
246            client_resource_policy,
247            ReplicatorRuntimePolicy::default(),
248        )
249    }
250
251    pub(crate) fn start_with_runtime_policy(
252        params: CheckpointParams,
253        interval: Time,
254        client_resource_policy: ClientResourcePolicy,
255        runtime_policy: ReplicatorRuntimePolicy,
256    ) -> Self {
257        let (shutdown_tx, mut shutdown_rx) = tokio::sync::watch::channel(false);
258
259        let handle = tokio::spawn(async move {
260            let offset_syncs_topic = OffsetSync::topic_name(&params.source_alias);
261            let mut reader: Option<OffsetSyncReader> = None;
262            // Retain every historical anchor for the lifetime of the task. An
263            // arbitrary cap could make translation unsafe for a lagging group.
264            let mut store = OffsetSyncStore::default();
265            loop {
266                let cycle = async {
267                    if reader.is_none() {
268                        reader = Some(
269                            OffsetSyncReader::connect(
270                                &params.target_bootstrap,
271                                offset_syncs_topic.clone(),
272                                params.security.clone(),
273                                client_resource_policy,
274                                &runtime_policy,
275                            )
276                            .await?,
277                        );
278                    }
279                    let active = reader.as_mut().expect("reader connected above");
280                    active.read_cycle(&mut store, &runtime_policy).await?;
281                    run_once_with_runtime_policy(
282                        &params,
283                        &store,
284                        client_resource_policy,
285                        &runtime_policy,
286                    )
287                    .await
288                    .map_err(|error| error.to_string())
289                };
290                let result = tokio::select! {
291                    result = cycle => Some(result),
292                    _ = shutdown_rx.changed() => None,
293                };
294                match result {
295                    None => break,
296                    Some(Err(error)) => {
297                        warn!(%error, "checkpoint cycle failed; retrying next interval");
298                    }
299                    Some(Ok(())) => {}
300                }
301
302                // Wait for the next interval or a shutdown signal.
303                tokio::select! {
304                    () = tokio::time::sleep(interval.to_std()) => {}
305                    _ = shutdown_rx.changed() => {
306                        if *shutdown_rx.borrow() {
307                            break;
308                        }
309                    }
310                }
311            }
312
313            if let Some(reader) = reader {
314                let _ = reader.close().await;
315            }
316        });
317
318        Self {
319            handle,
320            shutdown: shutdown_tx,
321        }
322    }
323
324    /// Signal the task to stop and await its completion.
325    // cargo-mutants: shutdown signal/join not asserted by unit tests
326    #[cfg_attr(test, mutants::skip)]
327    pub async fn shutdown(self) {
328        let _ = self.shutdown.send(true);
329        let _ = self.handle.await;
330    }
331
332    /// Abort the task without writing another checkpoint.
333    pub async fn crash(self) {
334        self.handle.abort();
335        let _ = self.handle.await;
336    }
337}
338
339/// A long-lived, manually assigned consumer and its next offsets.
340///
341/// Every cycle snapshots a finite read-committed end offset. Records appended
342/// after that snapshot remain for the next cycle, even when every poll is
343/// nonempty. Cursors are intentionally in memory: after a process restart the
344/// empty translation store is reconstructed from the earliest retained sync.
345struct OffsetSyncReader {
346    admin: AdminClient,
347    topic_id: Option<String>,
348    consumer: Consumer,
349    topic: String,
350    cursors: BTreeMap<(String, i32), i64>,
351}
352
353impl OffsetSyncReader {
354    async fn connect(
355        bootstrap: &str,
356        topic: String,
357        security: Option<krabka_client_core::security::ClientSecurity>,
358        policy: ClientResourcePolicy,
359        runtime_policy: &ReplicatorRuntimePolicy,
360    ) -> Result<Self, String> {
361        let admin = AdminClient::connect_with_options(
362            &[bootstrap.to_owned()],
363            krabka_client_core::ConnectionOptions {
364                dns_timeout: krabka_client_core::ClientDnsTimeout::new(
365                    runtime_policy.client_dns_timeout,
366                )?,
367                socket_connection_setup_timeout: runtime_policy.client_connect_timeout,
368                socket_connection_setup_timeout_max: runtime_policy.client_connect_timeout,
369                request_timeout: runtime_policy.client_request_timeout,
370                dispatch_queue_capacity: policy.dispatch_queue_capacity,
371                frame_max: policy.frame_max,
372                security: security.clone().map(Box::new),
373                ..Default::default()
374            },
375        )
376        .await
377        .map_err(|error| error.to_string())?;
378        let builder = Consumer::builder()
379            .bootstrap(bootstrap)
380            .client_id("krabka-replicator-offset-sync-reader")
381            .dispatch_queue_capacity(policy.dispatch_queue_capacity.get())
382            .frame_max(policy.frame_max.size())
383            .auto_offset_reset(AutoOffsetReset::Earliest)
384            .enable_auto_commit(false)
385            .allow_auto_create_topics(false)
386            .isolation_level(IsolationLevel::ReadCommitted);
387        let consumer = match security {
388            Some(security) => builder.security(security).build().await,
389            None => builder.build().await,
390        }
391        .map_err(|error| error.to_string())?;
392        Ok(Self {
393            admin,
394            topic_id: None,
395            consumer,
396            topic,
397            cursors: BTreeMap::new(),
398        })
399    }
400
401    async fn read_cycle(
402        &mut self,
403        store: &mut OffsetSyncStore,
404        runtime_policy: &ReplicatorRuntimePolicy,
405    ) -> Result<usize, String> {
406        // Offsets alone cannot identify a recreated topic whose new log has
407        // already grown past our old cursor. Invalidate both anchors and cursor
408        // when Kafka reports a different topic UUID.
409        let metadata = self
410            .admin
411            .metadata(&[&self.topic])
412            .await
413            .map_err(|error| error.to_string())?;
414        let entry = metadata
415            .topics
416            .into_iter()
417            .find(|entry| entry.name == self.topic);
418        if let Some(error) = entry.as_ref().and_then(|entry| entry.error.as_ref())
419            && error.code != 3
420        {
421            return Err(format!("{error:?}"));
422        }
423        let topic_id = entry
424            .as_ref()
425            .and_then(|entry| entry.topic_id)
426            .map(|id| id.to_string());
427        if topic_id != self.topic_id {
428            // Drop assignment ownership as well as cursors: seeking the same
429            // numeric offset must not retain an old topic's buffered fetches.
430            self.consumer
431                .unsubscribe()
432                .await
433                .map_err(|error| error.to_string())?;
434            store.clear();
435            self.cursors.clear();
436            self.topic_id = topic_id;
437        }
438        let partitions = match self.consumer.partitions_for(&self.topic).await {
439            Ok(partitions) => partitions
440                .into_iter()
441                .map(|partition| (self.topic.clone(), partition.partition))
442                .collect::<Vec<_>>(),
443            Err(error) if crate::admin_util::is_unknown_topic_error(&error.to_string()) => {
444                Vec::new()
445            }
446            Err(error) => return Err(error.to_string()),
447        };
448        self.consumer
449            .assign(&partitions)
450            .await
451            .map_err(|error| error.to_string())?;
452        if partitions.is_empty() {
453            store.clear();
454            self.cursors.clear();
455            self.topic_id = None;
456            return Ok(0);
457        }
458
459        let beginnings = self
460            .consumer
461            .beginning_offsets(&partitions)
462            .await
463            .map_err(|error| error.to_string())?;
464        // Under read-committed isolation these are last-stable offsets, not
465        // moving high watermarks, so open transactions cannot strand a cycle.
466        let targets = self
467            .consumer
468            .end_offsets(&partitions)
469            .await
470            .map_err(|error| error.to_string())?;
471
472        let must_rebuild = beginnings.iter().any(|(partition, beginning)| {
473            let target = targets.get(partition).copied().unwrap_or(*beginning);
474            self.cursors
475                .get(partition)
476                .is_some_and(|cursor| cursor < beginning || *cursor > target)
477        });
478        if must_rebuild {
479            store.clear();
480            self.cursors.clear();
481        }
482
483        for ((topic, partition), beginning) in &beginnings {
484            let cursor = self
485                .cursors
486                .get(&(topic.clone(), *partition))
487                .copied()
488                .unwrap_or(*beginning);
489            let target = targets
490                .get(&(topic.clone(), *partition))
491                .copied()
492                .unwrap_or(*beginning);
493            let next = if cursor < *beginning || cursor > target {
494                *beginning
495            } else {
496                cursor
497            };
498            self.consumer
499                .seek(topic, *partition, next)
500                .await
501                .map_err(|error| error.to_string())?;
502        }
503
504        let mut ingested = 0;
505        while !self.reached(&partitions, &targets).await? {
506            let records = self
507                .consumer
508                .poll(runtime_policy.internal_drain_poll_timeout)
509                .await
510                .map_err(|error| error.to_string())?;
511            for record in records {
512                let key = (record.topic.clone(), record.partition);
513                if record.offset >= targets.get(&key).copied().unwrap_or(0) {
514                    continue;
515                }
516                if let (Some(key), Some(value)) = (record.key, record.value) {
517                    match OffsetSync::from_bytes(&key, &value) {
518                        Ok(sync) => {
519                            store.ingest(sync);
520                            ingested += 1;
521                        }
522                        Err(error) => {
523                            warn!(%error, "failed to decode offset-sync record; skipping");
524                        }
525                    }
526                }
527            }
528        }
529
530        // A fetch can include records appended after the snapshot. Rewind to
531        // the captured boundary so those records are visible next cycle.
532        for ((topic, partition), target) in &targets {
533            self.consumer
534                .seek(topic, *partition, *target)
535                .await
536                .map_err(|error| error.to_string())?;
537            self.cursors.insert((topic.clone(), *partition), *target);
538        }
539        Ok(ingested)
540    }
541
542    async fn reached(
543        &self,
544        partitions: &[(String, i32)],
545        targets: &std::collections::HashMap<(String, i32), i64>,
546    ) -> Result<bool, String> {
547        for (topic, partition) in partitions {
548            let position = self
549                .consumer
550                .position(topic, *partition)
551                .await
552                .map_err(|error| error.to_string())?;
553            if position
554                < targets
555                    .get(&(topic.clone(), *partition))
556                    .copied()
557                    .unwrap_or(0)
558            {
559                return Ok(false);
560            }
561        }
562        Ok(true)
563    }
564
565    async fn close(self) -> Result<(), String> {
566        self.consumer
567            .close()
568            .await
569            .map_err(|error| error.to_string())
570    }
571}
572
573/// Build a non-idempotent producer with `acks=All` that targets the given
574/// bootstrap.
575async fn build_producer(
576    bootstrap: &str,
577    security: Option<krabka_client_core::security::ClientSecurity>,
578    client_resource_policy: ClientResourcePolicy,
579) -> Result<Producer, krabka_client_producer::ProducerError> {
580    let builder = Producer::builder()
581        .bootstrap(bootstrap)
582        .dispatch_queue_capacity(client_resource_policy.dispatch_queue_capacity.get())
583        .frame_max(client_resource_policy.frame_max.size())
584        .enable_idempotence(false)
585        .acks(Acks::All);
586    match security {
587        Some(s) => builder.security(s).build().await,
588        None => builder.build().await,
589    }
590}
591
592#[cfg(test)]
593mod tests {
594
595    use super::*;
596    use crate::{
597        ids::{DownstreamOffset, UpstreamOffset},
598        mm2::{Checkpoint, OffsetSync},
599        offset_sync_store::OffsetSyncStore,
600    };
601
602    async fn produce_sync(bootstrap: &str, topic: &str, offset: i64) {
603        let sync = OffsetSync {
604            topic: "orders".into(),
605            partition: PartitionIndex(0),
606            upstream: UpstreamOffset(offset),
607            downstream: DownstreamOffset(offset),
608        };
609        crate::test_util::produce(bootstrap, topic, &sync.key_bytes(), &sync.value_bytes()).await;
610    }
611
612    #[tokio::test]
613    async fn reader_initial_admin_request_obeys_configured_timeout() {
614        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
615        let bootstrap = listener.local_addr().unwrap().to_string();
616        // Accept TCP but never answer ApiVersions: the configured request
617        // timeout must also apply to the admin's initial protocol exchange.
618        let server = tokio::spawn(async move {
619            let (_socket, _) = listener.accept().await.unwrap();
620            std::future::pending::<()>().await;
621        });
622        let policy = ReplicatorRuntimePolicy {
623            client_request_timeout: krabka_units::millis(50),
624            ..ReplicatorRuntimePolicy::default()
625        };
626        let result = tokio::time::timeout(
627            std::time::Duration::from_secs(2),
628            OffsetSyncReader::connect(
629                &bootstrap,
630                "offset-syncs".into(),
631                None,
632                ClientResourcePolicy::default(),
633                &policy,
634            ),
635        )
636        .await;
637        server.abort();
638        assert2::assert!(matches!(result, Ok(Err(_))));
639    }
640
641    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
642    async fn reader_streams_only_offsets_appended_since_previous_cycle() {
643        let dir = tempfile::TempDir::new().unwrap();
644        let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
645            dir.path().to_path_buf(),
646        ))
647        .await
648        .unwrap();
649        let bootstrap = broker.listen_addr().to_string();
650        let topic = OffsetSync::topic_name("us-east");
651        crate::test_util::create_topic(&bootstrap, &topic, 1).await;
652        for offset in 0..3 {
653            produce_sync(&bootstrap, &topic, offset).await;
654        }
655
656        let mut reader = OffsetSyncReader::connect(
657            &bootstrap,
658            topic.clone(),
659            None,
660            ClientResourcePolicy::default(),
661            &ReplicatorRuntimePolicy::default(),
662        )
663        .await
664        .unwrap();
665        let mut store = OffsetSyncStore::default();
666        let policy = ReplicatorRuntimePolicy::default();
667        assert2::assert!(reader.read_cycle(&mut store, &policy).await.unwrap() == 3);
668
669        for offset in 3..5 {
670            produce_sync(&bootstrap, &topic, offset).await;
671        }
672        assert2::assert!(reader.read_cycle(&mut store, &policy).await.unwrap() == 2);
673        assert2::assert!(
674            store.translate("orders", PartitionIndex(0), CommittedOffset(4))
675                == Some(DownstreamOffset(4))
676        );
677
678        // A restart owns no persisted cursor or translation state and safely
679        // reconstructs both from all retained records.
680        reader.close().await.unwrap();
681        let mut restarted = OffsetSyncReader::connect(
682            &bootstrap,
683            topic,
684            None,
685            ClientResourcePolicy::default(),
686            &policy,
687        )
688        .await
689        .unwrap();
690        let mut rebuilt = OffsetSyncStore::default();
691        assert2::assert!(restarted.read_cycle(&mut rebuilt, &policy).await.unwrap() == 5);
692        restarted.close().await.unwrap();
693    }
694
695    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
696    async fn reader_rebuilds_after_topic_recreation_with_overlapping_offsets() {
697        let dir = tempfile::TempDir::new().unwrap();
698        let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
699            dir.path().to_path_buf(),
700        ))
701        .await
702        .unwrap();
703        let bootstrap = broker.listen_addr().to_string();
704        let topic = OffsetSync::topic_name("recreated");
705        crate::test_util::create_topic(&bootstrap, &topic, 1).await;
706        produce_sync(&bootstrap, &topic, 100).await;
707        let mut reader = OffsetSyncReader::connect(
708            &bootstrap,
709            topic.clone(),
710            None,
711            ClientResourcePolicy::default(),
712            &ReplicatorRuntimePolicy::default(),
713        )
714        .await
715        .unwrap();
716        let mut store = OffsetSyncStore::default();
717        let policy = ReplicatorRuntimePolicy::default();
718        assert2::assert!(reader.read_cycle(&mut store, &policy).await.unwrap() == 1);
719        store.ingest(OffsetSync {
720            topic: "removed".into(),
721            partition: PartitionIndex(0),
722            upstream: UpstreamOffset(0),
723            downstream: DownstreamOffset(999),
724        });
725        reader
726            .admin
727            .delete_topics(
728                &[&topic],
729                krabka_client_admin::topics::TopicMutationOptions::default(),
730            )
731            .await
732            .unwrap();
733        crate::test_util::create_topic(&bootstrap, &topic, 1).await;
734        for offset in 0..3 {
735            produce_sync(&bootstrap, &topic, offset).await;
736        }
737        assert2::assert!(reader.read_cycle(&mut store, &policy).await.unwrap() == 3);
738        assert2::assert!(
739            store
740                .translate("removed", PartitionIndex(0), CommittedOffset(0))
741                .is_none()
742        );
743        reader.close().await.unwrap();
744    }
745
746    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
747    async fn reader_treats_a_missing_topic_as_an_empty_cycle() {
748        let dir = tempfile::TempDir::new().unwrap();
749        let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
750            dir.path().to_path_buf(),
751        ))
752        .await
753        .unwrap();
754        let mut reader = OffsetSyncReader::connect(
755            &broker.listen_addr().to_string(),
756            "missing-offset-syncs".into(),
757            None,
758            ClientResourcePolicy::default(),
759            &ReplicatorRuntimePolicy::default(),
760        )
761        .await
762        .unwrap();
763        assert2::assert!(
764            reader
765                .read_cycle(
766                    &mut OffsetSyncStore::default(),
767                    &ReplicatorRuntimePolicy::default()
768                )
769                .await
770                .unwrap()
771                == 0
772        );
773        reader.close().await.unwrap();
774    }
775
776    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
777    async fn writes_translated_checkpoints() {
778        let s_dir = tempfile::TempDir::new().unwrap();
779        let t_dir = tempfile::TempDir::new().unwrap();
780        let source = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
781            s_dir.path().to_path_buf(),
782        ))
783        .await
784        .unwrap();
785        let target = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
786            t_dir.path().to_path_buf(),
787        ))
788        .await
789        .unwrap();
790        let sb = source.listen_addr().to_string();
791        let tb = target.listen_addr().to_string();
792
793        crate::test_util::create_topic(&sb, "orders", 1).await;
794        for _ in 0..200 {
795            crate::test_util::produce(&sb, "orders", b"k", b"v").await;
796        }
797        crate::test_util::commit_group(&sb, "g", "orders").await;
798
799        let mut syncs = OffsetSyncStore::default();
800        syncs.ingest(OffsetSync {
801            topic: "orders".into(),
802            partition: PartitionIndex(0),
803            upstream: UpstreamOffset(0),
804            downstream: DownstreamOffset(0),
805        });
806        syncs.ingest(OffsetSync {
807            topic: "orders".into(),
808            partition: PartitionIndex(0),
809            upstream: UpstreamOffset(200),
810            downstream: DownstreamOffset(165),
811        });
812
813        run_once(
814            &CheckpointParams {
815                source_bootstrap: sb,
816                target_bootstrap: tb.clone(),
817                source_alias: "us-east".into(),
818                naming: NamingPolicy::Default,
819                group_selector: Selector::compile(&["g".into()], &[]).unwrap(),
820                security: None,
821            },
822            &syncs,
823        )
824        .await
825        .unwrap();
826
827        let raw = crate::admin_util::read_last_value_for_key(
828            &tb,
829            &Checkpoint::topic_name("us-east"),
830            b"",
831            None,
832        )
833        .await
834        .unwrap()
835        .unwrap();
836        assert2::assert!(!raw.is_empty());
837    }
838}