Skip to main content

krabka_replicator/
supervisor.rs

1//! Supervisor: top-level task tree that owns and restarts replication workers.
2//!
3//! The [`FlowSupervisor`] turns a validated [`ReplicatorConfig`] into one
4//! running [`FlowWorker`] per flow, then runs a background supervision loop that
5//! restarts any worker whose connect runtime has entered
6//! [`RuntimeState::Failed`].
7
8use krabka_client_admin::AdminClient;
9use krabka_connect::RuntimeState;
10use krabka_units::prelude::TimeExt as _;
11use tokio::{
12    sync::{oneshot, watch},
13    task::JoinHandle,
14};
15
16use crate::{
17    config::{
18        ClientResourcePolicy, Delivery, NamingPolicy, PolicyConfig, ReplicatorConfig,
19        ReplicatorRuntimePolicy,
20    },
21    error::ReplicatorError,
22    naming::Renamer,
23    selector::Selector,
24    worker::{FlowWorker, FlowWorkerParams},
25};
26
27/// Owned, `Clone`-able inputs that rebuild a [`FlowWorkerParams`], and with it a
28/// fresh [`FlowWorker`], when the supervision loop restarts a failed worker.
29///
30/// `FlowWorkerParams` itself lives in another module and is not `Clone`. This
31/// type therefore keeps the resolved inputs, and [`make_params`] reconstructs
32/// the params from them.
33#[derive(Clone)]
34struct RebuildSpec {
35    flow_name: String,
36    source_bootstrap: String,
37    target_bootstrap: String,
38    source_alias: String,
39    target_alias: String,
40    naming: NamingPolicy,
41    delivery: Delivery,
42    topics: Vec<String>,
43    source_partition_counts: std::collections::BTreeMap<String, i32>,
44    target_zones: Vec<String>,
45    policies: Vec<PolicyConfig>,
46    group_selector: Selector,
47    client_resource_policy: ClientResourcePolicy,
48    runtime_policy: ReplicatorRuntimePolicy,
49}
50
51/// Build a fresh [`FlowWorkerParams`] from a [`RebuildSpec`]. All security is
52/// `None` in Slice 1, which is plaintext.
53fn make_params(spec: &RebuildSpec) -> FlowWorkerParams {
54    FlowWorkerParams {
55        flow_name: spec.flow_name.clone(),
56        source_bootstrap: spec.source_bootstrap.clone(),
57        target_bootstrap: spec.target_bootstrap.clone(),
58        source_alias: spec.source_alias.clone(),
59        target_alias: spec.target_alias.clone(),
60        naming: spec.naming,
61        delivery: spec.delivery,
62        topics: spec.topics.clone(),
63        source_partition_counts: spec.source_partition_counts.clone(),
64        target_zones: spec.target_zones.clone(),
65        policies: spec.policies.clone(),
66        group_selector: spec.group_selector.clone(),
67        security_source: None,
68        security_target: None,
69        client_resource_policy: spec.client_resource_policy,
70        runtime_policy: spec.runtime_policy.clone(),
71    }
72}
73
74/// Returns `true` if `name` is a Kafka or replicator internal topic that must
75/// never be selected as a replication source.
76///
77/// The function excludes Kafka internals, that is, any name that starts with
78/// `__`. It also excludes the replicator's own state topics: heartbeats, the
79/// consolidated offsets topic, the per-flow checkpoint topics
80/// `*.checkpoints.internal`, and the MM2 offset-sync topics
81/// `mm2-offset-syncs.*`.
82fn is_internal(name: &str) -> bool {
83    name.starts_with("__")
84        || name == "heartbeats"
85        || name == "krabka-replicator-offsets"
86        || name.ends_with(".checkpoints.internal")
87        || name.starts_with("mm2-offset-syncs.")
88}
89
90/// Control plane that owns and supervises the per-flow replication workers.
91pub struct FlowSupervisor {
92    shutdown: watch::Sender<bool>,
93    crash: Option<oneshot::Sender<()>>,
94    handle: JoinHandle<()>,
95}
96
97impl FlowSupervisor {
98    /// Start with the default Kafka client resource policy.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error when configuration validation, metadata discovery, or
103    /// worker startup fails.
104    pub async fn run(config: ReplicatorConfig) -> crate::Result<Self> {
105        Self::run_with_policy(config, ClientResourcePolicy::default()).await
106    }
107
108    /// Validate `config`, resolve and start one [`FlowWorker`] per flow, then
109    /// spawn a supervision loop that owns the workers and restarts any that
110    /// enter [`RuntimeState::Failed`].
111    ///
112    /// For each flow this method lists the topics of the source cluster and
113    /// filters them by the topic selector of the flow. It excludes
114    /// already-remote topics, which is the default-naming loop guard, and Kafka
115    /// and replicator internal topics.
116    ///
117    /// # Errors
118    ///
119    /// Returns [`ReplicatorError`] if the config is invalid, a source cluster
120    /// cannot be reached for topic discovery, a selector fails to compile, or an
121    /// initial worker fails to build within its retry budget.
122    #[tracing::instrument(
123        level = "info",
124        skip_all,
125        fields(flows = config.flows.len(), clusters = config.clusters.len()),
126        err,
127    )]
128    pub async fn run_with_policy(
129        config: ReplicatorConfig,
130        client_resource_policy: ClientResourcePolicy,
131    ) -> crate::Result<Self> {
132        Self::run_with_runtime_policy(
133            config,
134            client_resource_policy,
135            ReplicatorRuntimePolicy::default(),
136        )
137        .await
138    }
139
140    /// Start using explicit process runtime and topic policy.
141    ///
142    /// # Errors
143    /// Returns an error when config validation, discovery, or worker startup fails.
144    pub async fn run_with_runtime_policy(
145        config: ReplicatorConfig,
146        client_resource_policy: ClientResourcePolicy,
147        runtime_policy: ReplicatorRuntimePolicy,
148    ) -> crate::Result<Self> {
149        config.validate()?;
150
151        let mut entries: Vec<(RebuildSpec, FlowWorker)> = Vec::with_capacity(config.flows.len());
152
153        for flow in &config.flows {
154            // validate() guarantees both aliases resolve.
155            let from = &config.clusters[&flow.from];
156            let to = &config.clusters[&flow.to];
157
158            let flow_name = format!("{}__{}", flow.from, flow.to);
159
160            // Resolve the concrete topic list from the source cluster's metadata.
161            let mut admin = AdminClient::connect_with_options(
162                std::slice::from_ref(&from.bootstrap),
163                krabka_client_core::ConnectionOptions {
164                    dns_timeout: krabka_client_core::ClientDnsTimeout::new(
165                        runtime_policy.client_dns_timeout,
166                    )
167                    .map_err(ReplicatorError::Client)?,
168                    socket_connection_setup_timeout: runtime_policy.client_connect_timeout,
169                    socket_connection_setup_timeout_max: runtime_policy.client_connect_timeout,
170                    request_timeout: runtime_policy.client_request_timeout,
171                    client_id: "krabka-operator".to_owned(),
172                    dispatch_queue_capacity: client_resource_policy.dispatch_queue_capacity,
173                    frame_max: client_resource_policy.frame_max,
174                    security: None,
175                    ..Default::default()
176                },
177            )
178            .await
179            .map_err(|e| ReplicatorError::Client(e.to_string()))?;
180            let md = admin
181                .metadata(&[])
182                .await
183                .map_err(|e| ReplicatorError::Client(e.to_string()))?;
184
185            let topic_sel = Selector::compile(&flow.topics.include, &flow.topics.exclude)?;
186            let renamer = Renamer::new(flow.naming, &flow.from);
187
188            let source_partition_counts: std::collections::BTreeMap<String, i32> = md
189                .topics
190                .into_iter()
191                .filter(|topic| {
192                    topic_sel.matches(&topic.name)
193                        && !renamer.is_remote(&topic.name)
194                        && !is_internal(&topic.name)
195                })
196                .map(|topic| (topic.name, topic.partition_count))
197                .collect();
198            let mut topics: Vec<String> = source_partition_counts.keys().cloned().collect();
199            topics.sort();
200            topics.dedup();
201
202            let group_selector = Selector::compile(&flow.groups.include, &flow.groups.exclude)?;
203
204            let spec = RebuildSpec {
205                flow_name: flow_name.clone(),
206                source_bootstrap: from.bootstrap.clone(),
207                target_bootstrap: to.bootstrap.clone(),
208                source_alias: flow.from.clone(),
209                target_alias: flow.to.clone(),
210                naming: flow.naming,
211                delivery: flow.delivery,
212                topics,
213                source_partition_counts,
214                target_zones: to.zones.clone(),
215                policies: config.policies.clone(),
216                group_selector,
217                client_resource_policy,
218                runtime_policy: runtime_policy.clone(),
219            };
220
221            let worker = Box::pin(FlowWorker::start(make_params(&spec))).await?;
222            tracing::info!(
223                flow = %flow_name,
224                source = %flow.from,
225                target = %flow.to,
226                topics = spec.topics.len(),
227                "started flow worker",
228            );
229            entries.push((spec, worker));
230        }
231
232        let (shutdown, mut rx) = watch::channel(false);
233        let (crash, mut crash_rx) = oneshot::channel();
234        let handle = tokio::spawn(async move {
235            let mut ticker = tokio::time::interval(runtime_policy.supervisor_interval.to_std());
236            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
237            loop {
238                tokio::select! {
239                    Ok(()) = &mut crash_rx => {
240                        for (_spec, worker) in entries {
241                            worker.crash().await;
242                        }
243                        return;
244                    }
245                    res = rx.changed() => {
246                        // Sender dropped or shutdown signalled — stop supervising.
247                        if res.is_err() || *rx.borrow() {
248                            break;
249                        }
250                    }
251                    _ = ticker.tick() => {
252                        for (spec, worker) in &mut entries {
253                            if worker.state() == RuntimeState::Failed {
254                                tracing::warn!(
255                                    flow = %spec.flow_name,
256                                    "flow worker failed; rebuilding",
257                                );
258                                // Take the failed worker out, stop it, and
259                                // replace it with a freshly built one.
260                                let old = std::mem::replace(
261                                    worker,
262                            match Box::pin(FlowWorker::start(make_params(spec))).await {
263                                        Ok(w) => w,
264                                        Err(e) => {
265                                            tracing::error!(
266                                                flow = %spec.flow_name,
267                                                error = %e,
268                                                "failed to rebuild flow worker; \
269                                                 will retry next tick",
270                                            );
271                                            continue;
272                                        }
273                                    },
274                                );
275                                old.shutdown().await;
276                            }
277                        }
278                    }
279                }
280            }
281
282            for (_spec, worker) in entries {
283                worker.shutdown().await;
284            }
285        });
286
287        Ok(Self {
288            shutdown,
289            crash: Some(crash),
290            handle,
291        })
292    }
293
294    /// Signal the supervision loop to stop and gracefully shut down all workers.
295    // cargo-mutants: shutdown signal/join not asserted by unit tests
296    #[cfg_attr(test, mutants::skip)]
297    pub async fn shutdown(self) {
298        let _ = self.shutdown.send(true);
299        let _ = self.handle.await;
300    }
301
302    /// Abort the supervisor and its owned workers without draining them.
303    pub async fn crash(mut self) {
304        if let Some(crash) = self.crash.take() {
305            let _ = crash.send(());
306        }
307        let _ = self.handle.await;
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use std::collections::BTreeMap;
314
315    use krabka_units::secs;
316
317    use super::*;
318    use crate::config::{
319        ClusterConfig, Delivery, FlowConfig, NamingPolicy, ReplicatorConfig, Selectors,
320    };
321
322    #[test]
323    fn is_internal_excludes_kafka_and_replicator_topics() {
324        for (topic, want) in [
325            ("__consumer_offsets", true),
326            ("heartbeats", true),
327            ("krabka-replicator-offsets", true),
328            ("us-east.eu-west.checkpoints.internal", true),
329            ("mm2-offset-syncs.us-east.internal", true),
330            ("orders", false),
331            ("telemetry.cpu", false),
332        ] {
333            assert2::assert!(is_internal(topic) == want);
334        }
335    }
336
337    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
338    async fn spawns_a_worker_per_flow_and_replicates() {
339        let s_dir = tempfile::TempDir::new().unwrap();
340        let t_dir = tempfile::TempDir::new().unwrap();
341        let source = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
342            s_dir.path().to_path_buf(),
343        ))
344        .await
345        .unwrap();
346        let target = krabka_broker::Broker::start(krabka_broker::BrokerConfig::for_tests(
347            t_dir.path().to_path_buf(),
348        ))
349        .await
350        .unwrap();
351        let sb = source.listen_addr().to_string();
352        let tb = target.listen_addr().to_string();
353
354        crate::test_util::create_topic(&sb, "orders", 1).await;
355        crate::test_util::produce(&sb, "orders", b"k", b"v").await;
356
357        // A second LOCAL topic that is non-remote and non-internal but NOT in the
358        // flow's `topics.include`. It pins the topic filter's `selector.matches`
359        // conjunct: with `&&`→`||` the filter would replicate `noise` (since it
360        // is !remote && !internal), so `us-east.noise` must stay absent.
361        crate::test_util::create_topic(&sb, "noise", 1).await;
362        crate::test_util::produce(&sb, "noise", b"k", b"v").await;
363
364        let mut clusters = BTreeMap::new();
365        clusters.insert(
366            "us-east".to_string(),
367            ClusterConfig {
368                bootstrap: sb.clone(),
369                region: "us".into(),
370                zones: vec!["us".into()],
371            },
372        );
373        clusters.insert(
374            "eu-west".to_string(),
375            ClusterConfig {
376                bootstrap: tb.clone(),
377                region: "eu".into(),
378                zones: vec!["eu".into(), "gdpr".into()],
379            },
380        );
381        let config = ReplicatorConfig {
382            clusters,
383            flows: vec![FlowConfig {
384                from: "us-east".into(),
385                to: "eu-west".into(),
386                topics: Selectors {
387                    include: vec!["orders".into()],
388                    exclude: vec![],
389                },
390                groups: Selectors::default(),
391                naming: NamingPolicy::Default,
392                delivery: Delivery::AtLeastOnce,
393            }],
394            policies: vec![],
395        };
396
397        let sup = FlowSupervisor::run(config).await.unwrap();
398        crate::test_util::await_topic_count(&tb, "us-east.orders", 1, secs(20)).await;
399        sup.shutdown().await;
400        assert2::assert!(crate::test_util::topic_record_count(&tb, "us-east.orders").await >= 1);
401        // `noise` was excluded by the selector, so it must never have been
402        // replicated to the target.
403        assert2::assert!(crate::test_util::topic_record_count(&tb, "us-east.noise").await == 0);
404    }
405}