1use std::{
6 collections::BTreeMap,
7 sync::Arc,
8 time::{SystemTime, UNIX_EPOCH},
9};
10
11use krabka_connect::{ConnectorRuntime, RuntimeState};
12use krabka_units::{
13 fmt::Human as _,
14 prelude::{Time, TimeExt as _},
15};
16use tracing::warn;
17
18use crate::{
19 checkpoint_store::InternalTopicCheckpointStore,
20 config::{ClientResourcePolicy, Delivery, NamingPolicy, PolicyConfig, ReplicatorRuntimePolicy},
21 record::ReplicatedRecord,
22 selector::Selector,
23 sink::{SinkParams, TargetSink},
24 source::SourceConsumer,
25 tasks::{
26 checkpoint::{CheckpointParams, CheckpointTask},
27 heartbeat::{HeartbeatParams, HeartbeatTask},
28 },
29};
30
31pub struct FlowWorkerParams {
35 pub flow_name: String,
38 pub source_bootstrap: String,
40 pub target_bootstrap: String,
42 pub source_alias: String,
44 pub target_alias: String,
46 pub naming: NamingPolicy,
48 pub delivery: Delivery,
50 pub topics: Vec<String>,
52 pub source_partition_counts: BTreeMap<String, i32>,
54 pub target_zones: Vec<String>,
56 pub policies: Vec<PolicyConfig>,
58 pub group_selector: Selector,
60 pub security_source: Option<krabka_client_core::security::ClientSecurity>,
62 pub security_target: Option<krabka_client_core::security::ClientSecurity>,
64 pub client_resource_policy: ClientResourcePolicy,
66 pub runtime_policy: ReplicatorRuntimePolicy,
68}
69
70pub struct FlowWorker {
73 runtime: krabka_connect::ConnectorHandle,
74 heartbeat: HeartbeatTask,
75 checkpoint: CheckpointTask,
76}
77
78fn now_ms() -> i64 {
80 i64::try_from(
81 SystemTime::now()
82 .duration_since(UNIX_EPOCH)
83 .unwrap_or_default()
84 .as_millis(),
85 )
86 .unwrap_or(i64::MAX)
87}
88
89fn next_backoff(current: Time, maximum: Time) -> Time {
92 (current * 2.0).min(maximum)
93}
94
95impl FlowWorker {
96 #[cfg_attr(test, mutants::skip)]
110 #[tracing::instrument(
111 level = "info",
112 skip_all,
113 fields(flow = %p.flow_name, source = %p.source_alias, target = %p.target_alias, topics = p.topics.len()),
114 err,
115 )]
116 pub async fn start(p: FlowWorkerParams) -> crate::Result<Self> {
117 let mut backoff = p.runtime_policy.worker_build_initial_backoff;
118 let mut elapsed = Time::ZERO;
119
120 loop {
121 match Self::build(&p).await {
122 Ok(worker) => return Ok(worker),
123 Err(e) => {
124 if elapsed >= p.runtime_policy.worker_build_retry_budget {
125 return Err(e);
126 }
127 warn!(
128 flow = %p.flow_name,
129 error = %e,
130 backoff = %backoff.human(),
131 "flow worker build failed; retrying after backoff"
132 );
133 tokio::time::sleep(backoff.to_std()).await;
134 elapsed += backoff;
135 backoff = next_backoff(backoff, p.runtime_policy.worker_build_max_backoff);
136 }
137 }
138 }
139 }
140
141 #[tracing::instrument(
144 level = "info",
145 skip_all,
146 fields(flow = %p.flow_name, group_id = tracing::field::Empty),
147 err,
148 )]
149 async fn build(p: &FlowWorkerParams) -> crate::Result<Self> {
150 let group_id = format!("krabka-replicator-{}", p.flow_name);
151 tracing::Span::current().record("group_id", group_id.as_str());
152
153 let source = SourceConsumer::start_with_runtime_policy(
154 &p.source_bootstrap,
155 &group_id,
156 &p.topics,
157 p.security_source.clone(),
158 p.client_resource_policy,
159 &p.runtime_policy,
160 p.delivery,
161 )
162 .await?;
163
164 let sink = TargetSink::start_with_runtime_policy(
165 SinkParams {
166 target_bootstrap: p.target_bootstrap.clone(),
167 source_alias: p.source_alias.clone(),
168 flow_name: p.flow_name.clone(),
169 naming: p.naming,
170 delivery: p.delivery,
171 target_zones: p.target_zones.clone(),
172 policies: p.policies.clone(),
173 security: p.security_target.clone(),
174 source_partition_counts: p.source_partition_counts.clone(),
175 },
176 p.client_resource_policy,
177 p.runtime_policy.clone(),
178 )
179 .await?;
180
181 let store = InternalTopicCheckpointStore::start_with_runtime_policy(
182 &p.target_bootstrap,
183 &p.flow_name,
184 p.security_target.clone(),
185 p.client_resource_policy,
186 p.runtime_policy.clone(),
187 )
188 .await?
189 .with_sink_transaction(p.delivery == Delivery::ExactlyOnce);
190
191 let runtime = ConnectorRuntime::<(), ReplicatedRecord>::new()
192 .add_source(source)
193 .add_sink(sink)
194 .checkpoint_store(Arc::new(store))
195 .commit_interval(p.runtime_policy.connect_commit_interval.to_std())
196 .max_batch(p.runtime_policy.connect_max_batch_records.get())
197 .run();
198
199 let heartbeat = HeartbeatTask::start_with_runtime_policy(
200 HeartbeatParams {
201 target_bootstrap: p.target_bootstrap.clone(),
202 source_alias: p.source_alias.clone(),
203 target_alias: p.target_alias.clone(),
204 interval: p.runtime_policy.heartbeat_interval,
205 now_ms,
206 sleeper: Arc::new(qubit_clock::sleep::SystemSleeper::new()),
207 security: p.security_target.clone(),
208 },
209 p.client_resource_policy,
210 &p.runtime_policy,
211 )
212 .await?;
213
214 let checkpoint = CheckpointTask::start_with_runtime_policy(
215 CheckpointParams {
216 source_bootstrap: p.source_bootstrap.clone(),
217 target_bootstrap: p.target_bootstrap.clone(),
218 source_alias: p.source_alias.clone(),
219 naming: p.naming,
220 group_selector: p.group_selector.clone(),
221 security: p.security_target.clone(),
222 },
223 p.runtime_policy.checkpoint_interval,
224 p.client_resource_policy,
225 p.runtime_policy.clone(),
226 );
227
228 Ok(Self {
229 runtime,
230 heartbeat,
231 checkpoint,
232 })
233 }
234
235 #[must_use]
238 pub fn state(&self) -> RuntimeState {
239 self.runtime.state()
240 }
241
242 #[cfg_attr(test, mutants::skip)]
246 #[tracing::instrument(level = "info", skip_all)]
247 pub async fn shutdown(self) {
248 let _ = self.runtime.shutdown().await;
249 self.heartbeat.shutdown().await;
250 self.checkpoint.shutdown().await;
251 }
252
253 pub async fn crash(self) {
255 tokio::join!(
256 self.runtime.crash(),
257 self.heartbeat.crash(),
258 self.checkpoint.crash(),
259 );
260 }
261}
262
263#[cfg(test)]
264mod tests {
265
266 use krabka_units::{millis, secs};
267
268 use super::*;
269
270 #[test]
271 fn now_ms_is_a_recent_epoch_millis() {
272 assert2::assert!(super::now_ms() > 1_700_000_000_000);
275 }
276
277 #[test]
278 fn next_backoff_doubles_and_caps() {
279 assert2::assert!(super::next_backoff(millis(250), secs(8)) == millis(500));
281 assert2::assert!(super::next_backoff(secs(8), secs(8)) == secs(8));
283 assert2::assert!(super::next_backoff(secs(5), secs(8)) == secs(8));
286 }
287
288 #[test]
289 fn backoff_constants_convert_to_std_durations() {
290 let policy = crate::config::ReplicatorRuntimePolicy::default();
293 assert2::check!(
294 policy.worker_build_initial_backoff.to_std() == std::time::Duration::from_millis(250)
295 );
296 assert2::check!(
297 policy.worker_build_max_backoff.to_std() == std::time::Duration::from_secs(8)
298 );
299 assert2::check!(
300 policy.worker_build_retry_budget.to_std() == std::time::Duration::from_secs(30)
301 );
302 assert2::check!(
303 policy.connect_commit_interval.to_std() == std::time::Duration::from_millis(500)
304 );
305 }
306
307 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
308 async fn worker_replicates_one_flow() {
309 let s_dir = tempfile::TempDir::new().unwrap();
310 let t_dir = tempfile::TempDir::new().unwrap();
311 let source = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
312 s_dir.path().to_path_buf(),
313 ))
314 .await
315 .unwrap();
316 let target = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
317 t_dir.path().to_path_buf(),
318 ))
319 .await
320 .unwrap();
321 let sb = source.listen_addr().to_string();
322 let tb = target.listen_addr().to_string();
323
324 crate::test_util::create_topic(&sb, "orders", 3).await;
325 crate::test_util::produce(&sb, "orders", b"k", b"v").await;
326
327 let worker = Box::pin(FlowWorker::start(FlowWorkerParams {
328 flow_name: "us-east__eu-west".into(),
329 source_bootstrap: sb,
330 target_bootstrap: tb.clone(),
331 source_alias: "us-east".into(),
332 target_alias: "eu-west".into(),
333 naming: crate::config::NamingPolicy::Default,
334 delivery: crate::config::Delivery::AtLeastOnce,
335 topics: vec!["orders".to_string()],
336 source_partition_counts: [("orders".to_string(), 3)].into(),
337 target_zones: vec!["us".into()],
338 policies: vec![],
339 group_selector: crate::selector::Selector::compile(&[], &[]).unwrap(),
340 security_source: None,
341 security_target: None,
342 client_resource_policy: ClientResourcePolicy::default(),
343 runtime_policy: crate::config::ReplicatorRuntimePolicy::default(),
344 }))
345 .await
346 .unwrap();
347
348 crate::test_util::await_topic_count(&tb, "us-east.orders", 1, secs(15)).await;
349
350 let mut admin = krabka_client_admin::AdminClient::connect(std::slice::from_ref(&tb))
351 .await
352 .unwrap();
353 let metadata = admin.metadata(&["us-east.orders"]).await.unwrap();
354 assert2::assert!(metadata.topics[0].partition_count == 3);
355
356 worker.shutdown().await;
357 assert2::assert!(crate::test_util::topic_record_count(&tb, "us-east.orders").await >= 1);
358 }
359}