1use 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
18pub struct HeartbeatParams {
20 pub target_bootstrap: String,
22 pub source_alias: String,
24 pub target_alias: String,
26 pub interval: Time,
28 pub now_ms: fn() -> i64,
30 pub sleeper: std::sync::Arc<dyn qubit_clock::sleep::AsyncSleeper>,
35 pub security: Option<krabka_client_core::security::ClientSecurity>,
37}
38
39pub struct HeartbeatTask {
43 handle: tokio::task::JoinHandle<()>,
44 shutdown: watch::Sender<bool>,
45}
46
47impl HeartbeatTask {
48 #[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 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 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 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 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 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 #[cfg_attr(test, mutants::skip)]
177 pub async fn shutdown(self) {
178 let _ = self.shutdown.send(true);
180 let _ = self.handle.await;
181 }
182
183 pub async fn crash(self) {
185 self.handle.abort();
186 let _ = self.handle.await;
187 }
188}
189
190async 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 const TICKS: usize = 3;
219 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 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 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 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}