Skip to main content

crabka_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 std::time::Duration;
9
10use crabka_client_admin::AdminClient;
11use crabka_connect::RuntimeState;
12use tokio::sync::watch;
13use tokio::task::JoinHandle;
14
15use crate::config::{NamingPolicy, PolicyConfig, ReplicatorConfig};
16use crate::error::ReplicatorError;
17use crate::naming::Renamer;
18use crate::selector::Selector;
19use crate::worker::{FlowWorker, FlowWorkerParams};
20
21/// How often the supervision loop polls each worker's runtime state.
22const SUPERVISE_INTERVAL: Duration = Duration::from_secs(3);
23
24/// Owned, `Clone`-able inputs needed to rebuild a [`FlowWorkerParams`] (and thus
25/// a fresh [`FlowWorker`]) when the supervision loop restarts a failed worker.
26///
27/// `FlowWorkerParams` itself lives in another module and is not `Clone`, so we
28/// keep the resolved inputs here and reconstruct the params via [`make_params`].
29#[derive(Clone)]
30struct RebuildSpec {
31    flow_name: String,
32    source_bootstrap: String,
33    target_bootstrap: String,
34    source_alias: String,
35    target_alias: String,
36    naming: NamingPolicy,
37    topics: Vec<String>,
38    target_zones: Vec<String>,
39    policies: Vec<PolicyConfig>,
40    group_selector: Selector,
41}
42
43/// Build a fresh [`FlowWorkerParams`] from a [`RebuildSpec`]. All security is
44/// `None` in Slice 1 (plaintext).
45fn make_params(spec: &RebuildSpec) -> FlowWorkerParams {
46    FlowWorkerParams {
47        flow_name: spec.flow_name.clone(),
48        source_bootstrap: spec.source_bootstrap.clone(),
49        target_bootstrap: spec.target_bootstrap.clone(),
50        source_alias: spec.source_alias.clone(),
51        target_alias: spec.target_alias.clone(),
52        naming: spec.naming,
53        topics: spec.topics.clone(),
54        target_zones: spec.target_zones.clone(),
55        policies: spec.policies.clone(),
56        group_selector: spec.group_selector.clone(),
57        security_source: None,
58        security_target: None,
59    }
60}
61
62/// Returns `true` if `name` is a Kafka or replicator internal topic that must
63/// never be selected as a replication source.
64///
65/// Excludes Kafka internals (anything starting with `__`) and the replicator's
66/// own state topics: heartbeats, the consolidated offsets topic, per-flow
67/// checkpoint topics (`*.checkpoints.internal`), and MM2 offset-sync topics
68/// (`mm2-offset-syncs.*`).
69fn is_internal(name: &str) -> bool {
70    name.starts_with("__")
71        || name == "heartbeats"
72        || name == "crabka-replicator-offsets"
73        || name.ends_with(".checkpoints.internal")
74        || name.starts_with("mm2-offset-syncs.")
75}
76
77/// Control plane that owns and supervises the per-flow replication workers.
78pub struct FlowSupervisor {
79    shutdown: watch::Sender<bool>,
80    handle: JoinHandle<()>,
81}
82
83impl FlowSupervisor {
84    /// Validate `config`, resolve and start one [`FlowWorker`] per flow, then
85    /// spawn a supervision loop that owns the workers and restarts any that
86    /// enter [`RuntimeState::Failed`].
87    ///
88    /// For each flow the source cluster's topics are listed and filtered by the
89    /// flow's topic selector, excluding already-remote topics (default-naming
90    /// loop guard) and Kafka/replicator internal topics.
91    ///
92    /// # Errors
93    ///
94    /// Returns [`ReplicatorError`] if the config is invalid, a source cluster
95    /// cannot be reached for topic discovery, a selector fails to compile, or an
96    /// initial worker fails to build within its retry budget.
97    pub async fn run(config: ReplicatorConfig) -> crate::Result<Self> {
98        config.validate()?;
99
100        let mut entries: Vec<(RebuildSpec, FlowWorker)> = Vec::with_capacity(config.flows.len());
101
102        for flow in &config.flows {
103            // validate() guarantees both aliases resolve.
104            let from = &config.clusters[&flow.from];
105            let to = &config.clusters[&flow.to];
106
107            let flow_name = format!("{}__{}", flow.from, flow.to);
108
109            // Resolve the concrete topic list from the source cluster's metadata.
110            let mut admin = AdminClient::connect(std::slice::from_ref(&from.bootstrap))
111                .await
112                .map_err(|e| ReplicatorError::Client(e.to_string()))?;
113            let md = admin
114                .metadata(&[])
115                .await
116                .map_err(|e| ReplicatorError::Client(e.to_string()))?;
117
118            let topic_sel = Selector::compile(&flow.topics.include, &flow.topics.exclude)?;
119            let renamer = Renamer::new(flow.naming, &flow.from);
120
121            let mut topics: Vec<String> = md
122                .topics
123                .into_iter()
124                .map(|t| t.name)
125                .filter(|name| {
126                    topic_sel.matches(name) && !renamer.is_remote(name) && !is_internal(name)
127                })
128                .collect();
129            topics.sort();
130            topics.dedup();
131
132            let group_selector = Selector::compile(&flow.groups.include, &flow.groups.exclude)?;
133
134            let spec = RebuildSpec {
135                flow_name: flow_name.clone(),
136                source_bootstrap: from.bootstrap.clone(),
137                target_bootstrap: to.bootstrap.clone(),
138                source_alias: flow.from.clone(),
139                target_alias: flow.to.clone(),
140                naming: flow.naming,
141                topics,
142                target_zones: to.zones.clone(),
143                policies: config.policies.clone(),
144                group_selector,
145            };
146
147            let worker = FlowWorker::start(make_params(&spec)).await?;
148            tracing::info!(
149                flow = %flow_name,
150                source = %flow.from,
151                target = %flow.to,
152                topics = spec.topics.len(),
153                "started flow worker",
154            );
155            entries.push((spec, worker));
156        }
157
158        let (shutdown, mut rx) = watch::channel(false);
159        let handle = tokio::spawn(async move {
160            let mut ticker = tokio::time::interval(SUPERVISE_INTERVAL);
161            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
162            loop {
163                tokio::select! {
164                    res = rx.changed() => {
165                        // Sender dropped or shutdown signalled — stop supervising.
166                        if res.is_err() || *rx.borrow() {
167                            break;
168                        }
169                    }
170                    _ = ticker.tick() => {
171                        for (spec, worker) in &mut entries {
172                            if worker.state() == RuntimeState::Failed {
173                                tracing::warn!(
174                                    flow = %spec.flow_name,
175                                    "flow worker failed; rebuilding",
176                                );
177                                // Take the failed worker out, stop it, and
178                                // replace it with a freshly built one.
179                                let old = std::mem::replace(
180                                    worker,
181                                    match FlowWorker::start(make_params(spec)).await {
182                                        Ok(w) => w,
183                                        Err(e) => {
184                                            tracing::error!(
185                                                flow = %spec.flow_name,
186                                                error = %e,
187                                                "failed to rebuild flow worker; \
188                                                 will retry next tick",
189                                            );
190                                            continue;
191                                        }
192                                    },
193                                );
194                                old.shutdown().await;
195                            }
196                        }
197                    }
198                }
199            }
200
201            for (_spec, worker) in entries {
202                worker.shutdown().await;
203            }
204        });
205
206        Ok(Self { shutdown, handle })
207    }
208
209    /// Signal the supervision loop to stop and gracefully shut down all workers.
210    // cargo-mutants: shutdown signal/join not asserted by unit tests
211    #[cfg_attr(test, mutants::skip)]
212    pub async fn shutdown(self) {
213        let _ = self.shutdown.send(true);
214        let _ = self.handle.await;
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use std::collections::BTreeMap;
221
222    use assert2::assert;
223
224    use super::*;
225    use crate::config::{
226        ClusterConfig, Delivery, FlowConfig, NamingPolicy, ReplicatorConfig, Selectors,
227    };
228
229    #[test]
230    fn is_internal_excludes_kafka_and_replicator_topics() {
231        assert!(is_internal("__consumer_offsets"));
232        assert!(is_internal("heartbeats"));
233        assert!(is_internal("crabka-replicator-offsets"));
234        assert!(is_internal("us-east.eu-west.checkpoints.internal"));
235        assert!(is_internal("mm2-offset-syncs.us-east.internal"));
236        assert!(!is_internal("orders"));
237        assert!(!is_internal("telemetry.cpu"));
238    }
239
240    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
241    async fn spawns_a_worker_per_flow_and_replicates() {
242        let s_dir = tempfile::TempDir::new().unwrap();
243        let t_dir = tempfile::TempDir::new().unwrap();
244        let source = crabka_broker::Broker::start(crabka_broker::BrokerConfig::for_tests(
245            s_dir.path().to_path_buf(),
246        ))
247        .await
248        .unwrap();
249        let target = crabka_broker::Broker::start(crabka_broker::BrokerConfig::for_tests(
250            t_dir.path().to_path_buf(),
251        ))
252        .await
253        .unwrap();
254        let sb = source.listen_addr().to_string();
255        let tb = target.listen_addr().to_string();
256
257        crate::test_util::create_topic(&sb, "orders", 1).await;
258        crate::test_util::produce(&sb, "orders", b"k", b"v").await;
259
260        // A second LOCAL topic that is non-remote and non-internal but NOT in the
261        // flow's `topics.include`. It pins the topic filter's `selector.matches`
262        // conjunct: with `&&`→`||` the filter would replicate `noise` (since it
263        // is !remote && !internal), so `us-east.noise` must stay absent.
264        crate::test_util::create_topic(&sb, "noise", 1).await;
265        crate::test_util::produce(&sb, "noise", b"k", b"v").await;
266
267        let mut clusters = BTreeMap::new();
268        clusters.insert(
269            "us-east".to_string(),
270            ClusterConfig {
271                bootstrap: sb.clone(),
272                region: "us".into(),
273                zones: vec!["us".into()],
274            },
275        );
276        clusters.insert(
277            "eu-west".to_string(),
278            ClusterConfig {
279                bootstrap: tb.clone(),
280                region: "eu".into(),
281                zones: vec!["eu".into(), "gdpr".into()],
282            },
283        );
284        let config = ReplicatorConfig {
285            clusters,
286            flows: vec![FlowConfig {
287                from: "us-east".into(),
288                to: "eu-west".into(),
289                topics: Selectors {
290                    include: vec!["orders".into()],
291                    exclude: vec![],
292                },
293                groups: Selectors::default(),
294                naming: NamingPolicy::Default,
295                delivery: Delivery::AtLeastOnce,
296            }],
297            policies: vec![],
298        };
299
300        let sup = FlowSupervisor::run(config).await.unwrap();
301        crate::test_util::await_topic_count(
302            &tb,
303            "us-east.orders",
304            1,
305            std::time::Duration::from_secs(20),
306        )
307        .await;
308        sup.shutdown().await;
309        assert!(crate::test_util::topic_record_count(&tb, "us-east.orders").await >= 1);
310        // `noise` was excluded by the selector, so it must never have been
311        // replicated to the target.
312        assert!(crate::test_util::topic_record_count(&tb, "us-east.noise").await == 0);
313    }
314}