Skip to main content

consortium/worker/
tree.rs

1//! TreeWorker implementation for tree-based command propagation.
2//!
3//! Provides TreeWorker, which distributes command execution across a cluster
4//! topology using gateway nodes and propagation channels. Mirrors
5//! ClusterShell's TreeWorker class.
6//!
7//! The TreeWorker uses:
8//! - [`PropagationTreeRouter`] to determine next-hop gateways
9//! - [`PropagationChannel`] to communicate with gateway processes
10//! - Direct `SshWorker`/`ExecWorker` for leaf nodes reachable without gateways
11
12use std::collections::{HashMap, HashSet};
13use std::time::Duration;
14
15use crate::node_set::NodeSet;
16use crate::propagation::{PropagationChannel, PropagationResult, PropagationTreeRouter};
17use crate::topology::TopologyTree;
18use crate::worker::{WorkerError, WorkerState};
19
20/// Build a NodeSet from a collection of node name strings.
21fn nodeset_from_strings(names: &[String]) -> String {
22    names.join(",")
23}
24
25/// Configuration for tree-based execution.
26#[derive(Debug, Clone)]
27pub struct TreeWorkerConfig {
28    /// Maximum concurrent operations per gateway.
29    pub fanout: usize,
30    /// Command timeout per node.
31    pub timeout: Option<Duration>,
32    /// Whether to capture stderr.
33    pub stderr: bool,
34    /// Whether targets are remote (SSH) or local.
35    pub remote: bool,
36    /// Whether this is a copy operation.
37    pub source: Option<String>,
38    /// Destination for copy operations.
39    pub dest: Option<String>,
40    /// Whether this is a reverse copy (remote -> local).
41    pub reverse: bool,
42    /// Whether to preserve file attributes in copy.
43    pub preserve: bool,
44}
45
46impl Default for TreeWorkerConfig {
47    fn default() -> Self {
48        Self {
49            fanout: 64,
50            timeout: None,
51            stderr: false,
52            remote: true,
53            source: None,
54            dest: None,
55            reverse: false,
56            preserve: false,
57        }
58    }
59}
60
61/// Tracks the state of a gateway connection.
62#[derive(Debug)]
63struct GatewayState {
64    /// The propagation channel for this gateway.
65    channel: PropagationChannel,
66    /// Active target nodes being processed through this gateway.
67    active_targets: HashSet<String>,
68    /// Whether the gateway channel has been started.
69    started: bool,
70}
71
72/// Result from a remote node received through the tree.
73#[derive(Debug, Clone)]
74pub enum TreeWorkerEvent {
75    /// Node produced stdout output.
76    StdOut {
77        node: String,
78        gateway: String,
79        data: Vec<u8>,
80    },
81    /// Node produced stderr output.
82    StdErr {
83        node: String,
84        gateway: String,
85        data: Vec<u8>,
86    },
87    /// Node completed with a return code.
88    Close {
89        node: String,
90        gateway: String,
91        rc: i32,
92    },
93    /// Node timed out.
94    Timeout { node: String, gateway: String },
95    /// Routing event (reroute, unreachable, etc.)
96    Routing {
97        event: String,
98        gateway: String,
99        targets: String,
100    },
101}
102
103/// Worker that distributes command execution across a cluster topology.
104///
105/// TreeWorker uses [`PropagationTreeRouter`] to determine routing through
106/// gateway nodes, and [`PropagationChannel`] to communicate with gateways.
107/// Nodes directly reachable (same hop as root) are executed via direct
108/// workers (SSH or local).
109///
110/// Mirrors ClusterShell's `TreeWorker` class.
111pub struct TreeWorker {
112    /// Target nodes for this worker.
113    nodes: NodeSet,
114    /// Command to execute (mutually exclusive with source).
115    command: Option<String>,
116    /// Worker configuration.
117    config: TreeWorkerConfig,
118    /// Current worker state.
119    state: WorkerState,
120    /// Router for determining next-hop gateways.
121    router: Option<PropagationTreeRouter>,
122    /// Gateway states indexed by gateway name.
123    gateways: HashMap<String, GatewayState>,
124    /// Return codes collected per node.
125    retcodes: HashMap<String, i32>,
126    /// Events collected from remote nodes.
127    events: Vec<TreeWorkerEvent>,
128    /// Count of closed/completed targets.
129    close_count: usize,
130    /// Total target count (for completion tracking).
131    target_count: usize,
132    /// Whether any node has timed out.
133    has_timeout: bool,
134    /// Unique worker ID for channel communication.
135    worker_id: u64,
136    /// Nodes that timed out.
137    timeout_nodes: HashSet<String>,
138}
139
140impl TreeWorker {
141    /// Create a new TreeWorker for command execution.
142    ///
143    /// # Arguments
144    /// * `nodes` - Target nodes to execute on.
145    /// * `command` - Command to execute on each node.
146    /// * `config` - Tree worker configuration.
147    pub fn new(nodes: NodeSet, command: String, config: TreeWorkerConfig) -> Self {
148        static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
149
150        Self {
151            nodes,
152            command: Some(command),
153            config,
154            state: WorkerState::Pending,
155            router: None,
156            gateways: HashMap::new(),
157            retcodes: HashMap::new(),
158            events: Vec::new(),
159            close_count: 0,
160            target_count: 0,
161            has_timeout: false,
162            worker_id: NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst),
163            timeout_nodes: HashSet::new(),
164        }
165    }
166
167    /// Set the topology router for this worker.
168    ///
169    /// Must be called before `start()`. The router determines how commands
170    /// are distributed across gateway nodes.
171    pub fn set_router(&mut self, router: PropagationTreeRouter) {
172        self.router = Some(router);
173    }
174
175    /// Create a router from a topology tree.
176    ///
177    /// # Arguments
178    /// * `root` - The root node name (typically the local hostname).
179    /// * `topology` - The cluster topology tree.
180    ///
181    /// # Errors
182    /// Returns `WorkerError` if the root node is not found in the topology.
183    pub fn set_topology(&mut self, root: &str, topology: &TopologyTree) -> Result<(), WorkerError> {
184        let router = PropagationTreeRouter::new(root, topology, self.config.fanout)
185            .map_err(|e| WorkerError::General(format!("topology error: {}", e)))?;
186        self.router = Some(router);
187        Ok(())
188    }
189
190    /// Get the target nodes.
191    pub fn nodes(&self) -> &NodeSet {
192        &self.nodes
193    }
194
195    /// Get the command being executed.
196    pub fn command(&self) -> Option<&str> {
197        self.command.as_deref()
198    }
199
200    /// Get the current state.
201    pub fn state(&self) -> WorkerState {
202        self.state
203    }
204
205    /// Get collected return codes.
206    pub fn retcodes(&self) -> &HashMap<String, i32> {
207        &self.retcodes
208    }
209
210    /// Get collected events from remote nodes.
211    pub fn events(&self) -> &[TreeWorkerEvent] {
212        &self.events
213    }
214
215    /// Drain collected events.
216    pub fn drain_events(&mut self) -> Vec<TreeWorkerEvent> {
217        std::mem::take(&mut self.events)
218    }
219
220    /// Get the number of timed out nodes.
221    pub fn num_timeout(&self) -> usize {
222        self.timeout_nodes.len()
223    }
224
225    /// Get timed out nodes.
226    pub fn timeout_nodes(&self) -> &HashSet<String> {
227        &self.timeout_nodes
228    }
229
230    /// Whether any node has timed out.
231    pub fn has_timeout(&self) -> bool {
232        self.has_timeout
233    }
234
235    /// Check if the worker is done (all targets completed or timed out).
236    pub fn is_done(&self) -> bool {
237        self.state == WorkerState::Done || self.state == WorkerState::Aborted
238    }
239
240    /// Distribute target nodes across next-hop gateways.
241    ///
242    /// Returns a list of (gateway, targets) pairs. When gateway == targets,
243    /// the nodes are directly reachable (no gateway needed).
244    fn distribute(&self) -> Result<Vec<(NodeSet, NodeSet)>, WorkerError> {
245        let router = self.router.as_ref().ok_or_else(|| {
246            WorkerError::General("router not set; call set_router() or set_topology()".to_string())
247        })?;
248
249        let mut distribution: HashMap<String, NodeSet> = HashMap::new();
250
251        for (gw, dstset) in router.dispatch(&self.nodes) {
252            let gw_str = gw.to_string();
253            distribution
254                .entry(gw_str)
255                .and_modify(|ns| ns.update(&dstset))
256                .or_insert(dstset);
257        }
258
259        Ok(distribution
260            .into_iter()
261            .map(|(k, v)| {
262                let gw = NodeSet::parse(&k).unwrap_or_default();
263                (gw, v)
264            })
265            .collect())
266    }
267
268    /// Start the tree worker — distribute work across gateways.
269    ///
270    /// This sets up propagation channels for gateway nodes and prepares
271    /// direct execution for leaf nodes.
272    pub fn start(&mut self) -> Result<Vec<(NodeSet, NodeSet)>, WorkerError> {
273        if self.state != WorkerState::Pending {
274            return Err(WorkerError::General(
275                "worker already started or aborted".to_string(),
276            ));
277        }
278
279        self.state = WorkerState::Running;
280
281        let next_hops = self.distribute()?;
282
283        for (gw, targets) in &next_hops {
284            let target_count = targets.len();
285            self.target_count += target_count;
286
287            let gw_str = gw.to_string();
288            let targets_str = targets.to_string();
289
290            // Check if gateway == targets (direct execution, no gateway needed)
291            if gw_str == targets_str {
292                // Direct targets — caller should spawn SSH/local workers for these
293                continue;
294            }
295
296            // Gateway-mediated execution: set up propagation channel
297            let channel = PropagationChannel::new(&gw_str);
298            let gw_state = GatewayState {
299                channel,
300                active_targets: targets.iter().collect(),
301                started: false,
302            };
303            self.gateways.insert(gw_str, gw_state);
304        }
305
306        Ok(next_hops)
307    }
308
309    /// Start a gateway's propagation channel with topology data.
310    ///
311    /// # Arguments
312    /// * `gateway` - The gateway node name.
313    /// * `topology_bytes` - Serialized topology data to send to gateway.
314    pub fn start_gateway(
315        &mut self,
316        gateway: &str,
317        topology_bytes: &[u8],
318    ) -> Result<(), WorkerError> {
319        let gw_state = self
320            .gateways
321            .get_mut(gateway)
322            .ok_or_else(|| WorkerError::General(format!("unknown gateway: {}", gateway)))?;
323
324        if gw_state.started {
325            return Err(WorkerError::General(format!(
326                "gateway {} already started",
327                gateway
328            )));
329        }
330
331        gw_state.channel.start(topology_bytes);
332        gw_state.started = true;
333
334        // Send the shell command if this is a command execution
335        if let Some(ref cmd) = self.command {
336            let targets: Vec<String> = gw_state.active_targets.iter().cloned().collect();
337            let targets_str = nodeset_from_strings(&targets);
338            gw_state
339                .channel
340                .shell(&targets_str, cmd.as_bytes(), self.worker_id);
341        }
342
343        Ok(())
344    }
345
346    /// Process a propagation result from a gateway.
347    ///
348    /// Call this when the gateway channel receives results.
349    /// Returns true if the worker is now complete.
350    pub fn process_result(
351        &mut self,
352        gateway: &str,
353        result: PropagationResult,
354    ) -> Result<bool, WorkerError> {
355        match result {
356            PropagationResult::StdOut { ref node, ref data } => {
357                self.events.push(TreeWorkerEvent::StdOut {
358                    node: node.clone(),
359                    gateway: gateway.to_string(),
360                    data: data.clone(),
361                });
362            }
363            PropagationResult::StdErr { ref node, ref data } => {
364                self.events.push(TreeWorkerEvent::StdErr {
365                    node: node.clone(),
366                    gateway: gateway.to_string(),
367                    data: data.clone(),
368                });
369            }
370            PropagationResult::Retcode { ref node, rc } => {
371                self.retcodes.insert(node.clone(), rc);
372                self.close_count += 1;
373
374                // Remove from active targets
375                if let Some(gw_state) = self.gateways.get_mut(gateway) {
376                    gw_state.active_targets.remove(node);
377                }
378
379                self.events.push(TreeWorkerEvent::Close {
380                    node: node.clone(),
381                    gateway: gateway.to_string(),
382                    rc,
383                });
384            }
385            PropagationResult::Timeout { ref node } => {
386                self.timeout_nodes.insert(node.clone());
387                self.has_timeout = true;
388                self.close_count += 1;
389
390                if let Some(gw_state) = self.gateways.get_mut(gateway) {
391                    gw_state.active_targets.remove(node);
392                }
393
394                self.events.push(TreeWorkerEvent::Timeout {
395                    node: node.clone(),
396                    gateway: gateway.to_string(),
397                });
398            }
399            PropagationResult::Routing {
400                ref event,
401                ref gateway,
402                ref targets,
403            } => {
404                self.events.push(TreeWorkerEvent::Routing {
405                    event: event.clone(),
406                    gateway: gateway.clone(),
407                    targets: targets.clone(),
408                });
409            }
410        }
411
412        // Check if we're done
413        let done = self.close_count >= self.target_count && self.target_count > 0;
414        if done {
415            self.state = WorkerState::Done;
416        }
417
418        // Check if gateway has no more active targets
419        self.check_gateway_fini(gateway);
420
421        Ok(done)
422    }
423
424    /// Record completion of a direct (non-gateway) node.
425    ///
426    /// Called when a direct SSH/local worker reports node completion.
427    pub fn record_direct_close(&mut self, node: &str, rc: i32) {
428        self.retcodes.insert(node.to_string(), rc);
429        self.close_count += 1;
430
431        if self.close_count >= self.target_count && self.target_count > 0 {
432            self.state = WorkerState::Done;
433        }
434    }
435
436    /// Record a direct node timeout.
437    pub fn record_direct_timeout(&mut self, node: &str) {
438        self.timeout_nodes.insert(node.to_string());
439        self.has_timeout = true;
440        self.close_count += 1;
441
442        if self.close_count >= self.target_count && self.target_count > 0 {
443            self.state = WorkerState::Done;
444        }
445    }
446
447    /// Check if a gateway has finished (no more active targets).
448    fn check_gateway_fini(&mut self, gateway: &str) {
449        if let Some(gw_state) = self.gateways.get(gateway) {
450            if gw_state.active_targets.is_empty() {
451                // Gateway is done — could trigger cleanup
452                // The actual channel close is handled by the caller
453            }
454        }
455    }
456
457    /// Write data to all active gateway channels.
458    pub fn write_remote(&mut self, buf: &[u8]) {
459        let worker_id = self.worker_id;
460        for (_, gw_state) in self.gateways.iter_mut() {
461            if !gw_state.active_targets.is_empty() {
462                let targets: Vec<String> = gw_state.active_targets.iter().cloned().collect();
463                let targets_str = nodeset_from_strings(&targets);
464                gw_state.channel.write(&targets_str, buf, worker_id);
465            }
466        }
467    }
468
469    /// Send write EOF to all active gateway channels.
470    pub fn set_write_eof_remote(&mut self) {
471        let worker_id = self.worker_id;
472        for (_, gw_state) in self.gateways.iter_mut() {
473            if !gw_state.active_targets.is_empty() {
474                let targets: Vec<String> = gw_state.active_targets.iter().cloned().collect();
475                let targets_str = nodeset_from_strings(&targets);
476                gw_state.channel.set_write_eof(&targets_str, worker_id);
477            }
478        }
479    }
480
481    /// Abort the tree worker.
482    ///
483    /// Marks all remaining gateway targets as failed with EX_PROTOCOL (76).
484    pub fn abort(&mut self) {
485        if self.is_done() {
486            return;
487        }
488
489        // Close all gateway channels
490        for (gateway, gw_state) in self.gateways.iter_mut() {
491            let remaining: Vec<String> = gw_state.active_targets.drain().collect();
492            for node in remaining {
493                self.retcodes.insert(node.clone(), 76); // EX_PROTOCOL
494                self.close_count += 1;
495                self.events.push(TreeWorkerEvent::Close {
496                    node,
497                    gateway: gateway.clone(),
498                    rc: 76,
499                });
500            }
501        }
502
503        self.state = WorkerState::Aborted;
504    }
505
506    /// Get the number of active gateways.
507    pub fn active_gateway_count(&self) -> usize {
508        self.gateways
509            .values()
510            .filter(|gs| !gs.active_targets.is_empty())
511            .count()
512    }
513
514    /// Get active gateway names.
515    pub fn active_gateways(&self) -> Vec<&str> {
516        self.gateways
517            .iter()
518            .filter(|(_, gs)| !gs.active_targets.is_empty())
519            .map(|(name, _)| name.as_str())
520            .collect()
521    }
522
523    /// Get the total number of target nodes.
524    pub fn num_nodes(&self) -> usize {
525        self.nodes.len()
526    }
527
528    /// Get the worker ID.
529    pub fn worker_id(&self) -> u64 {
530        self.worker_id
531    }
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    fn make_nodeset(s: &str) -> NodeSet {
539        NodeSet::parse(s).unwrap()
540    }
541
542    #[test]
543    fn test_tree_worker_config_default() {
544        let config = TreeWorkerConfig::default();
545        assert_eq!(config.fanout, 64);
546        assert!(config.timeout.is_none());
547        assert!(!config.stderr);
548        assert!(config.remote);
549        assert!(config.source.is_none());
550        assert!(config.dest.is_none());
551        assert!(!config.reverse);
552        assert!(!config.preserve);
553    }
554
555    #[test]
556    fn test_tree_worker_creation() {
557        let nodes = make_nodeset("node[1-10]");
558        let config = TreeWorkerConfig::default();
559        let worker = TreeWorker::new(nodes.clone(), "hostname".to_string(), config);
560
561        assert_eq!(worker.state(), WorkerState::Pending);
562        assert_eq!(worker.num_nodes(), 10);
563        assert_eq!(worker.command(), Some("hostname"));
564        assert!(worker.retcodes().is_empty());
565        assert!(worker.events().is_empty());
566        assert!(!worker.has_timeout());
567        assert_eq!(worker.num_timeout(), 0);
568    }
569
570    #[test]
571    fn test_tree_worker_start_without_router() {
572        let nodes = make_nodeset("node[1-3]");
573        let config = TreeWorkerConfig::default();
574        let mut worker = TreeWorker::new(nodes, "echo hi".to_string(), config);
575
576        let result = worker.start();
577        assert!(result.is_err());
578        assert!(result.unwrap_err().to_string().contains("router not set"));
579    }
580
581    #[test]
582    fn test_tree_worker_abort() {
583        let nodes = make_nodeset("node[1-5]");
584        let config = TreeWorkerConfig::default();
585        let mut worker = TreeWorker::new(nodes, "echo hi".to_string(), config);
586
587        worker.abort();
588        assert_eq!(worker.state(), WorkerState::Aborted);
589        assert!(worker.is_done());
590    }
591
592    #[test]
593    fn test_tree_worker_record_direct_close() {
594        let nodes = make_nodeset("node[1-3]");
595        let config = TreeWorkerConfig::default();
596        let mut worker = TreeWorker::new(nodes, "echo hi".to_string(), config);
597
598        // Simulate that 3 direct targets were started
599        worker.state = WorkerState::Running;
600        worker.target_count = 3;
601
602        worker.record_direct_close("node1", 0);
603        assert_eq!(worker.retcodes().get("node1"), Some(&0));
604        assert!(!worker.is_done());
605
606        worker.record_direct_close("node2", 0);
607        assert!(!worker.is_done());
608
609        worker.record_direct_close("node3", 1);
610        assert!(worker.is_done());
611        assert_eq!(worker.retcodes().get("node3"), Some(&1));
612    }
613
614    #[test]
615    fn test_tree_worker_record_direct_timeout() {
616        let nodes = make_nodeset("node[1-2]");
617        let config = TreeWorkerConfig::default();
618        let mut worker = TreeWorker::new(nodes, "sleep 100".to_string(), config);
619
620        worker.state = WorkerState::Running;
621        worker.target_count = 2;
622
623        worker.record_direct_timeout("node1");
624        assert!(worker.has_timeout());
625        assert_eq!(worker.num_timeout(), 1);
626        assert!(worker.timeout_nodes().contains("node1"));
627        assert!(!worker.is_done());
628
629        worker.record_direct_close("node2", 0);
630        assert!(worker.is_done());
631    }
632
633    #[test]
634    fn test_tree_worker_process_result_stdout() {
635        let nodes = make_nodeset("node[1-2]");
636        let config = TreeWorkerConfig::default();
637        let mut worker = TreeWorker::new(nodes, "echo hi".to_string(), config);
638
639        worker.state = WorkerState::Running;
640        worker.target_count = 2;
641
642        let result = PropagationResult::StdOut {
643            node: "node1".to_string(),
644            data: b"hello world".to_vec(),
645        };
646
647        let done = worker.process_result("gw1", result).unwrap();
648        assert!(!done);
649        assert_eq!(worker.events().len(), 1);
650
651        match &worker.events()[0] {
652            TreeWorkerEvent::StdOut { node, data, .. } => {
653                assert_eq!(node, "node1");
654                assert_eq!(data, b"hello world");
655            }
656            _ => panic!("expected StdOut event"),
657        }
658    }
659
660    #[test]
661    fn test_tree_worker_process_result_retcode() {
662        let nodes = make_nodeset("node[1-2]");
663        let config = TreeWorkerConfig::default();
664        let mut worker = TreeWorker::new(nodes, "echo hi".to_string(), config);
665
666        worker.state = WorkerState::Running;
667        worker.target_count = 2;
668
669        // First node completes
670        let result1 = PropagationResult::Retcode {
671            node: "node1".to_string(),
672            rc: 0,
673        };
674        let done = worker.process_result("gw1", result1).unwrap();
675        assert!(!done);
676
677        // Second node completes
678        let result2 = PropagationResult::Retcode {
679            node: "node2".to_string(),
680            rc: 42,
681        };
682        let done = worker.process_result("gw1", result2).unwrap();
683        assert!(done);
684        assert_eq!(worker.state(), WorkerState::Done);
685        assert_eq!(worker.retcodes().get("node2"), Some(&42));
686    }
687
688    #[test]
689    fn test_tree_worker_process_result_timeout() {
690        let nodes = make_nodeset("node1");
691        let config = TreeWorkerConfig::default();
692        let mut worker = TreeWorker::new(nodes, "sleep 999".to_string(), config);
693
694        worker.state = WorkerState::Running;
695        worker.target_count = 1;
696
697        let result = PropagationResult::Timeout {
698            node: "node1".to_string(),
699        };
700        let done = worker.process_result("gw1", result).unwrap();
701        assert!(done);
702        assert!(worker.has_timeout());
703        assert!(worker.timeout_nodes().contains("node1"));
704    }
705
706    #[test]
707    fn test_tree_worker_drain_events() {
708        let nodes = make_nodeset("node1");
709        let config = TreeWorkerConfig::default();
710        let mut worker = TreeWorker::new(nodes, "echo hi".to_string(), config);
711
712        worker.state = WorkerState::Running;
713        worker.target_count = 1;
714
715        let result = PropagationResult::StdOut {
716            node: "node1".to_string(),
717            data: b"data".to_vec(),
718        };
719        worker.process_result("gw1", result).unwrap();
720
721        assert_eq!(worker.events().len(), 1);
722        let drained = worker.drain_events();
723        assert_eq!(drained.len(), 1);
724        assert!(worker.events().is_empty());
725    }
726
727    #[test]
728    fn test_tree_worker_unique_ids() {
729        let nodes = make_nodeset("node1");
730        let config1 = TreeWorkerConfig::default();
731        let config2 = TreeWorkerConfig::default();
732        let w1 = TreeWorker::new(nodes.clone(), "a".to_string(), config1);
733        let w2 = TreeWorker::new(nodes, "b".to_string(), config2);
734        assert_ne!(w1.worker_id(), w2.worker_id());
735    }
736
737    #[test]
738    fn test_tree_worker_active_gateways_empty() {
739        let nodes = make_nodeset("node[1-3]");
740        let config = TreeWorkerConfig::default();
741        let worker = TreeWorker::new(nodes, "echo hi".to_string(), config);
742
743        assert_eq!(worker.active_gateway_count(), 0);
744        assert!(worker.active_gateways().is_empty());
745    }
746
747    #[test]
748    fn test_tree_worker_routing_event() {
749        let nodes = make_nodeset("node1");
750        let config = TreeWorkerConfig::default();
751        let mut worker = TreeWorker::new(nodes, "echo".to_string(), config);
752
753        worker.state = WorkerState::Running;
754        worker.target_count = 1;
755
756        let result = PropagationResult::Routing {
757            event: "reroute".to_string(),
758            gateway: "gw1".to_string(),
759            targets: "node1".to_string(),
760        };
761        worker.process_result("gw1", result).unwrap();
762
763        assert_eq!(worker.events().len(), 1);
764        match &worker.events()[0] {
765            TreeWorkerEvent::Routing {
766                event,
767                gateway,
768                targets,
769            } => {
770                assert_eq!(event, "reroute");
771                assert_eq!(gateway, "gw1");
772                assert_eq!(targets, "node1");
773            }
774            _ => panic!("expected Routing event"),
775        }
776    }
777}