Skip to main content

krabka_replicator/tasks/
heartbeat.rs

1//! Heartbeat task: periodically writes MM2 Heartbeat records to the target.
2
3use bytes::Bytes;
4use krabka_client_producer::{Acks, Producer, ProducerRecord};
5use krabka_units::{
6    fmt::Human as _,
7    prelude::{Time, TimeExt as _},
8};
9use tokio::sync::watch;
10use tracing::warn;
11
12use crate::{
13    config::{ClientResourcePolicy, ReplicatorRuntimePolicy},
14    error::ReplicatorError,
15    mm2::Heartbeat,
16};
17
18/// Parameters for the [`HeartbeatTask`].
19pub struct HeartbeatParams {
20    /// Bootstrap address of the target cluster.
21    pub target_bootstrap: String,
22    /// Alias of the source cluster written into each heartbeat record.
23    pub source_alias: String,
24    /// Alias of the target cluster written into each heartbeat record.
25    pub target_alias: String,
26    /// How often to emit a heartbeat record.
27    pub interval: Time,
28    /// Injectable clock: returns the current time in milliseconds since epoch.
29    pub now_ms: fn() -> i64,
30    /// Injectable async sleeper that paces the heartbeat interval. Production
31    /// uses [`qubit_clock::sleep::SystemSleeper`], which is real tokio time.
32    /// Tests inject a [`qubit_clock::sleep::MockSleeper`], so the interval fires
33    /// on a mock timeline and not on wall-clock time.
34    pub sleeper: std::sync::Arc<dyn qubit_clock::sleep::AsyncSleeper>,
35    /// Optional TLS/SASL security for the target cluster.
36    pub security: Option<krabka_client_core::security::ClientSecurity>,
37}
38
39/// A handle to the background heartbeat task.
40///
41/// Drop or call [`shutdown`](HeartbeatTask::shutdown) to stop it.
42pub struct HeartbeatTask {
43    handle: tokio::task::JoinHandle<()>,
44    shutdown: watch::Sender<bool>,
45}
46
47impl HeartbeatTask {
48    /// Ensure the `heartbeats` topic exists, build a producer, and spawn the
49    /// background loop.
50    ///
51    /// # Errors
52    ///
53    /// Returns [`ReplicatorError::Client`] if topic creation or producer
54    /// construction fails.
55    #[tracing::instrument(
56        level = "info",
57        skip_all,
58        fields(source = %p.source_alias, target = %p.target_alias, interval = %p.interval.human()),
59        err,
60    )]
61    pub async fn start(p: HeartbeatParams) -> Result<Self, ReplicatorError> {
62        Self::start_with_policy(p, ClientResourcePolicy::default()).await
63    }
64
65    /// Start with the deployment's client resource policy.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`ReplicatorError::Client`] if topic creation or producer
70    /// construction fails.
71    pub async fn start_with_policy(
72        p: HeartbeatParams,
73        client_resource_policy: ClientResourcePolicy,
74    ) -> Result<Self, ReplicatorError> {
75        Self::start_with_runtime_policy(
76            p,
77            client_resource_policy,
78            &ReplicatorRuntimePolicy::default(),
79        )
80        .await
81    }
82
83    pub(crate) async fn start_with_runtime_policy(
84        p: HeartbeatParams,
85        client_resource_policy: ClientResourcePolicy,
86        runtime_policy: &ReplicatorRuntimePolicy,
87    ) -> Result<Self, ReplicatorError> {
88        // Ensure the heartbeats topic exists before we start producing.
89        crate::admin_util::ensure_topic_with_runtime_policy(
90            &p.target_bootstrap,
91            Heartbeat::TOPIC,
92            1,
93            p.security.clone(),
94            client_resource_policy,
95            runtime_policy,
96            runtime_policy.internal_topic_replication_factor,
97        )
98        .await
99        .map_err(ReplicatorError::Client)?;
100
101        // Build the producer once — reused for every heartbeat.
102        let producer = build_producer(&p.target_bootstrap, p.security, client_resource_policy)
103            .await
104            .map_err(|e| ReplicatorError::Client(e.to_string()))?;
105
106        let (shutdown_tx, mut shutdown_rx) = watch::channel(false);
107
108        let source = p.source_alias;
109        let target = p.target_alias;
110        let interval = p.interval;
111        let now_ms = p.now_ms;
112        let sleeper = p.sleeper;
113
114        let handle = tokio::spawn(async move {
115            // Pace the heartbeat cadence through the injected `AsyncSleeper`
116            // (production: real tokio time via `SystemSleeper`; tests: a
117            // `MockSleeper` on a mock timeline). The interval is a single sleep
118            // future re-armed only after it fires — equivalent to
119            // `tokio::time::interval` with `MissedTickBehavior::Delay`. Unlike
120            // `interval` it has no immediate zeroth tick, so the first heartbeat
121            // lands one interval in (which is what we want anyway).
122            let mut tick = sleeper.sleep_for_async(interval.to_std());
123
124            loop {
125                tokio::select! {
126                    () = &mut tick => {
127                        let hb = Heartbeat {
128                            source: source.clone(),
129                            target: target.clone(),
130                            timestamp_ms: now_ms(),
131                        };
132
133                        let key = Bytes::from(hb.key_bytes());
134                        let value = Bytes::from(hb.value_bytes());
135
136                        let rx = producer
137                            .send(ProducerRecord {
138                                topic: Heartbeat::TOPIC.to_string(),
139                                partition: None,
140                                key: Some(key),
141                                value: Some(value),
142                                headers: vec![],
143                                timestamp_ms: None,
144                            })
145                            .await;
146
147                        match rx {
148                            Ok(_) => {}
149                            Err(e) => warn!("heartbeat produce error: {e}"),
150                        }
151
152                        if let Err(e) = producer.flush().await {
153                            warn!("heartbeat flush error: {e}");
154                        }
155
156                        // Re-arm the interval for the next tick.
157                        tick = sleeper.sleep_for_async(interval.to_std());
158                    }
159                    Ok(()) = shutdown_rx.changed() => {
160                        if *shutdown_rx.borrow() {
161                            break;
162                        }
163                    }
164                }
165            }
166        });
167
168        Ok(Self {
169            handle,
170            shutdown: shutdown_tx,
171        })
172    }
173
174    /// Signal the background task to stop and wait for it to finish.
175    // cargo-mutants: shutdown signal/join not asserted by unit tests
176    #[cfg_attr(test, mutants::skip)]
177    pub async fn shutdown(self) {
178        // Ignore send error: if the receiver is already gone the task is done.
179        let _ = self.shutdown.send(true);
180        let _ = self.handle.await;
181    }
182
183    /// Abort the task without a final heartbeat.
184    pub async fn crash(self) {
185        self.handle.abort();
186        let _ = self.handle.await;
187    }
188}
189
190/// Build a non-idempotent producer with `acks=All` for heartbeat emission.
191async fn build_producer(
192    bootstrap: &str,
193    security: Option<krabka_client_core::security::ClientSecurity>,
194    client_resource_policy: ClientResourcePolicy,
195) -> Result<Producer, krabka_client_producer::ProducerError> {
196    let builder = Producer::builder()
197        .bootstrap(bootstrap)
198        .dispatch_queue_capacity(client_resource_policy.dispatch_queue_capacity.get())
199        .frame_max(client_resource_policy.frame_max.size())
200        .enable_idempotence(false)
201        .acks(Acks::All);
202    match security {
203        Some(s) => builder.security(s).build().await,
204        None => builder.build().await,
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use std::sync::Arc;
211
212    use krabka_units::prelude::{millis, secs};
213    use qubit_clock::{MockTime, MockWaiterKind};
214
215    use super::*;
216
217    /// Number of heartbeat intervals to fire on the mock timeline.
218    const TICKS: usize = 3;
219    /// Heartbeat cadence that the test advances the mock timeline by, one tick
220    /// at a time.
221    const INTERVAL: Time = millis(100);
222
223    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
224    async fn emits_heartbeat() {
225        let dir = tempfile::TempDir::new().unwrap();
226        let broker = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
227            dir.path().to_path_buf(),
228        ))
229        .await
230        .unwrap();
231        let target = broker.listen_addr().to_string();
232
233        // Pace the heartbeat interval off a mock timeline instead of wall-clock
234        // time: advancing the timeline — not a real `sleep` — is what fires each
235        // tick, so the test is deterministic and doesn't depend on real time.
236        let mock = MockTime::unix_epoch();
237
238        let h = HeartbeatTask::start(HeartbeatParams {
239            target_bootstrap: target.clone(),
240            source_alias: "us-east".into(),
241            target_alias: "eu-west".into(),
242            interval: INTERVAL,
243            now_ms: || 123,
244            sleeper: Arc::new(mock.sleeper()),
245            security: None,
246        })
247        .await
248        .unwrap();
249
250        // Fire the interval `TICKS` times. Each pass:
251        //   1. wait until the task has really parked on its interval sleep
252        //      (registered a mock `Sleep` waiter) so the advance can't race
253        //      ahead of the sleep's creation and skip a tick;
254        //   2. advance the mock timeline by one interval to wake it;
255        //   3. wait until that heartbeat is durably written before firing the
256        //      next tick.
257        // A completed sleep future drops its waiter registration *before* the
258        // tick body writes the record, so once the Nth heartbeat is visible the
259        // only registered waiter is the freshly re-armed (N+1)th — there is no
260        // stale count to trip the next `wait_for_blocked_waiters`.
261        for n in 1..=TICKS {
262            let timeline = mock.timeline();
263            let parked = tokio::task::spawn_blocking(move || {
264                timeline.wait_for_blocked_waiters(MockWaiterKind::Sleep, 1, secs(5).to_std())
265            })
266            .await
267            .unwrap();
268            assert2::assert!(parked);
269
270            mock.advance(INTERVAL.to_std());
271
272            crate::test_util::await_topic_count(&target, Heartbeat::TOPIC, n, secs(10)).await;
273        }
274
275        h.shutdown().await;
276
277        // Exactly one heartbeat per advance — no more (no advance fired after
278        // the loop) and no fewer (each advance was gated on its write landing).
279        let count = crate::test_util::topic_record_count(&target, Heartbeat::TOPIC).await;
280        assert2::assert!(count == TICKS);
281
282        let raw = crate::admin_util::read_last_value_for_key(
283            &target,
284            crate::mm2::Heartbeat::TOPIC,
285            b"",
286            None,
287        )
288        .await
289        .unwrap();
290        assert2::assert!(raw.is_some());
291    }
292}