Skip to main content

krabka_replicator/
worker.rs

1//! Per-flow replication worker. It drives one directional connect pipeline from
2//! `SourceConsumer` to `TargetSink`, plus the heartbeat and checkpoint
3//! background tasks, with build-retry resilience and clean shutdown.
4
5use 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
31/// Parameters to start a [`FlowWorker`]. The supervisor resolves selectors into
32/// a concrete topic list and passes the per-flow cluster addresses, aliases,
33/// naming policy, residency policies, and security here.
34pub struct FlowWorkerParams {
35    /// Unique flow name, for example `"us-east__eu-west"`. It seeds the consumer
36    /// group id and the checkpoint-store key.
37    pub flow_name: String,
38    /// Bootstrap address of the source cluster.
39    pub source_bootstrap: String,
40    /// Bootstrap address of the target cluster.
41    pub target_bootstrap: String,
42    /// Alias of the source cluster, stamped as provenance and used for MM2 names.
43    pub source_alias: String,
44    /// Alias of the target cluster (written into heartbeat records).
45    pub target_alias: String,
46    /// How source topics are renamed on the target.
47    pub naming: NamingPolicy,
48    /// Delivery guarantee for target writes and source checkpoints.
49    pub delivery: Delivery,
50    /// Already-resolved source topic list. The supervisor resolves the selectors.
51    pub topics: Vec<String>,
52    /// Source partition count by selected topic.
53    pub source_partition_counts: BTreeMap<String, i32>,
54    /// Compliance zones of the target cluster, used for residency checks.
55    pub target_zones: Vec<String>,
56    /// Residency policies to enforce on the sink.
57    pub policies: Vec<PolicyConfig>,
58    /// Selector for which consumer groups the checkpoint task translates.
59    pub group_selector: Selector,
60    /// Optional TLS/SASL security for the source cluster.
61    pub security_source: Option<krabka_client_core::security::ClientSecurity>,
62    /// Optional TLS/SASL security for the target cluster.
63    pub security_target: Option<krabka_client_core::security::ClientSecurity>,
64    /// Process-owned Kafka client resource policy.
65    pub client_resource_policy: ClientResourcePolicy,
66    /// Process-owned runtime and topic policy.
67    pub runtime_policy: ReplicatorRuntimePolicy,
68}
69
70/// One directional replication flow: the running connect runtime plus the
71/// heartbeat and checkpoint background tasks.
72pub struct FlowWorker {
73    runtime: krabka_connect::ConnectorHandle,
74    heartbeat: HeartbeatTask,
75    checkpoint: CheckpointTask,
76}
77
78/// Current wall-clock time in milliseconds since the Unix epoch.
79fn 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
89/// Compute the next build-retry backoff: double the current interval, capped at
90/// [`MAX_BACKOFF`].
91fn next_backoff(current: Time, maximum: Time) -> Time {
92    (current * 2.0).min(maximum)
93}
94
95impl FlowWorker {
96    /// Build and start the pipeline. The method retries a transient build
97    /// failure with bounded exponential backoff, because a cluster can be
98    /// briefly unreachable.
99    ///
100    /// Backoff starts at about 250ms and doubles up to a cap of about 8s. Once
101    /// the cumulative elapsed time exceeds about 30s, the method returns the
102    /// last build error.
103    ///
104    /// # Errors
105    ///
106    /// Returns the last build error if the pipeline cannot be constructed within
107    /// the retry budget.
108    // cargo-mutants: retry/backoff loop has no value-asserting unit coverage
109    #[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    /// A single build attempt: stand up the source, sink, checkpoint store,
142    /// connect runtime, and both background tasks.
143    #[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    /// Current connect-runtime state, for the supervisor to detect `Failed`
236    /// workers and restart them.
237    #[must_use]
238    pub fn state(&self) -> RuntimeState {
239        self.runtime.state()
240    }
241
242    /// Graceful stop: drain and shut down the connect runtime, then stop both
243    /// background tasks.
244    // cargo-mutants: shutdown ordering not asserted by unit tests
245    #[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    /// Abort every owned task without draining records or checkpoints.
254    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        // Epoch millis for 2023-11-14T22:13:20Z; any sane current time exceeds
273        // it. The `-> -1 / 0 / 1` mutants all fall below this floor.
274        assert2::assert!(super::now_ms() > 1_700_000_000_000);
275    }
276
277    #[test]
278    fn next_backoff_doubles_and_caps() {
279        // Doubling: 250ms -> 500ms (the `*2`→`/2` mutant gives 125ms).
280        assert2::assert!(super::next_backoff(millis(250), secs(8)) == millis(500));
281        // Cap: never exceeds MAX_BACKOFF even when already at the cap.
282        assert2::assert!(super::next_backoff(secs(8), secs(8)) == secs(8));
283        // The cap binds before the doubling would overshoot it: 8s doubled is
284        // 16s, so a 5s backoff must land on the 8s cap, not 10s.
285        assert2::assert!(super::next_backoff(secs(5), secs(8)) == secs(8));
286    }
287
288    #[test]
289    fn backoff_constants_convert_to_std_durations() {
290        // The retry loop sleeps on `Duration`; the seam must preserve the
291        // configured extents exactly.
292        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}