Skip to main content

manycastr/orchestrator/
service.rs

1use crate::custom_module::has_anycast_origin;
2use crate::custom_module::manycastr::WorkerStatus::{Disconnected, Idle, Listening, Probing};
3use crate::custom_module::manycastr::controller_server::Controller;
4use crate::custom_module::manycastr::reply::ReplyData;
5use crate::custom_module::manycastr::{
6    Ack, CliMessage, DiscoveryReply, Empty, Finished, Init, Instruction, LiveTarget,
7    MeasurementType, Probe, Reply, ReplyBatch, ScheduleMeasurement, Start, Task, TraceOptions,
8    TraceReply, Worker, WorkerStatus, cli_message, instruction, task,
9};
10use crate::orchestrator::cli::CLIReceiver;
11use crate::orchestrator::result_handler::{
12    SessionTracker, discovery_handler, trace_discovery_handler, trace_replies_handler,
13};
14use crate::orchestrator::task_distributor::{
15    DistributionStrategy, TaskDistributorConfig, distribute_live_tasks, distribute_tasks,
16};
17use crate::orchestrator::trace::check_trace_timeouts;
18use crate::orchestrator::worker::{WorkerReceiver, WorkerSender};
19use crate::orchestrator::{
20    ControllerService, LiveState, MeasurementHandle, MeasurementState, Participant,
21    TracerouteConfig, WorkerRegistry, WorkerSel, wire_nprobes,
22};
23use crate::{ALL_ORIGINS, ALL_WORKERS, custom_module};
24use log::{error, info, warn};
25
26use std::collections::{HashMap, HashSet};
27use std::sync::{Arc, Mutex};
28use std::time::{Duration, Instant};
29use tokio::sync::mpsc;
30use tokio::time::MissedTickBehavior;
31use tonic::{Request, Response, Status};
32
33/// Live feed buffer size, expressed in seconds of probing at the probing rate.
34/// When the buffer is full the CLI stream is no longer read (blocking the feed).
35const FEED_BUFFER_SECS: usize = 5;
36
37/// Workers classified by role for a measurement.
38struct ClassifiedWorkers {
39    participating_ids: Vec<u32>,
40    probing_ids: Vec<u32>,
41}
42
43/// Implementation of the Controller trait for the ControllerService
44/// Handles communication with the workers and the CLI
45#[tonic::async_trait]
46impl Controller for ControllerService {
47    /// Called by the worker when it has finished its current measurement.
48    /// When all connected workers have finished this measurement, it will notify the CLI that the measurement is finished.
49    ///
50    /// # Arguments
51    /// * `request` - a Finished message containing the measurement ID of the measurement that has finished
52    ///
53    /// # Errors
54    /// Returns an error if the measurement ID is unknown.
55    async fn measurement_finished(
56        &self,
57        request: Request<Finished>,
58    ) -> Result<Response<Ack>, Status> {
59        self.check_worker_access("measurement_finished")?;
60
61        let finished_measurement = request.into_inner();
62        let m_id: u32 = finished_measurement.m_id;
63        let finished_worker_id = finished_measurement.worker_id;
64
65        // Whether the measurement is finished
66        let mut should_notify = false;
67
68        {
69            let mut lock = self.measurement.write().unwrap();
70            if let Some(ref mut state) = *lock {
71                if state.m_id != m_id {
72                    // A stale signal must not release a claim on the current measurement
73                    warn!(
74                        "[Orchestrator] Worker {finished_worker_id} finished measurement {m_id}, but the active measurement is {}",
75                        state.m_id
76                    );
77                    return Err(Status::not_found("Measurement ID mismatch"));
78                }
79
80                // Remove worker as participant (disallowing reconnect for the current measurement)
81                if state.participants.remove(&finished_worker_id).is_none() {
82                    warn!(
83                        "[Orchestrator] Received finished signal from non-participant worker {finished_worker_id}"
84                    );
85                    return Ok(Response::new(Ack::ok()));
86                }
87                state.start_instructions.remove(&finished_worker_id);
88                state.probing_workers.retain(|&id| id != finished_worker_id);
89
90                // Set state to IDLE
91                {
92                    let workers = self.saved_workers.lock().unwrap();
93                    if let Some(w) = workers.iter().find(|w| w.worker_id == finished_worker_id) {
94                        w.finished();
95                    }
96                }
97
98                if state.active_workers() == 0 {
99                    // This was the last worker still holding a completion claim
100                    info!(
101                        "[Orchestrator] All workers finished for measurement {m_id}. Notifying CLI"
102                    );
103                    should_notify = true;
104
105                    // Drop all measurement state at once
106                    *lock = None;
107                }
108            } else {
109                // Worker finished whilst there is no measurement active
110                warn!(
111                    "[Orchestrator] Received measurement finished signal for worker {finished_worker_id}, but no measurement is active."
112                );
113                return Err(Status::not_found("No active measurement found"));
114            }
115        }
116
117        // Notify the CLI if this was the last worker
118        if should_notify {
119            let cli_tx = { self.cli_sender.lock().unwrap().clone() };
120            if let Some(tx) = cli_tx
121                && tx.send(Ok(ReplyBatch::default())).await.is_err()
122            {
123                warn!("[Orchestrator] CLI disconnected, cannot send measurement-finished signal.");
124            }
125        }
126
127        // Acknowledge the worker
128        Ok(Response::new(Ack::ok()))
129    }
130
131    type WorkerConnectStream = WorkerReceiver<Result<Instruction, Status>>;
132
133    /// Handles a worker connecting to this orchestrator formally.
134    /// Ensures the hostname is unique and returns a unique worker ID
135    /// Returns the receiver side of a stream to which the orchestrator will send tasks
136    ///
137    /// # Arguments
138    /// * `request` - a Metadata message containing the hostname of the worker
139    async fn worker_connect(
140        &self,
141        request: Request<Worker>,
142    ) -> Result<Response<Self::WorkerConnectStream>, Status> {
143        self.check_worker_access("worker_connect")?;
144
145        let worker = request.into_inner();
146        let hostname = worker.hostname;
147        let unicast_v4 = worker.unicast_v4;
148        let unicast_v6 = worker.unicast_v6;
149        let (tx, rx) = mpsc::channel::<Result<Instruction, Status>>(1000);
150        // Get the worker ID, and check if it is a reconnection
151        let (worker_id, is_reconnect) = self.get_worker_id(&hostname)?;
152
153        if is_reconnect {
154            info!("[Orchestrator] Reconnecting worker: {hostname}");
155        } else {
156            info!("[Orchestrator] New worker connected: {hostname}");
157        }
158
159        // Send worker ID
160        tx.send(Ok(Instruction {
161            instruction_type: Some(instruction::InstructionType::Init(Init { worker_id })),
162        }))
163        .await
164        .expect("Unable to send task");
165
166        let worker_status = Arc::new(Mutex::new(Idle));
167
168        let worker_tx = WorkerSender {
169            inner: tx.clone(),
170            worker_id,
171            hostname: hostname.clone(),
172            status: worker_status.clone(),
173            unicast_v4,
174            unicast_v6,
175        };
176
177        // Remove the disconnected worker if it existed
178        if is_reconnect {
179            let mut senders = self.saved_workers.lock().unwrap();
180            senders.retain(|sender| sender.worker_id != worker_id);
181        }
182
183        // Add the new worker sender to the list of workers
184        self.saved_workers.lock().unwrap().push(worker_tx);
185
186        // Check if this is a reconnecting worker
187        if is_reconnect {
188            self.try_rejoin(worker_id, &hostname, &tx, &worker_status);
189        }
190
191        // Create stream receiver for the worker
192        let worker_rx = WorkerReceiver {
193            inner: rx,
194            measurement: self.measurement.clone(),
195            cli_sender: self.cli_sender.clone(),
196            hostname,
197            status: worker_status,
198            worker_id,
199        };
200
201        // Send the stream receiver to the worker
202        Ok(Response::new(worker_rx))
203    }
204
205    type DoMeasurementStream = CLIReceiver<Result<ReplyBatch, Status>>;
206
207    /// Handles a measurement request from the CLI.
208    ///
209    /// Classifies workers, initializes measurement state, sends Start instructions to
210    /// all participating workers, optionally sets up traceroute, and launches the task
211    /// distributor. Returns a stream of results to the CLI.
212    ///
213    /// # Errors
214    /// Returns an error if there is already an active measurement, if there are no
215    /// connected workers, if the configuration references unknown worker IDs, if the
216    /// probing rate exceeds the configured `--max_rate`, or if an origin is not
217    /// allowed by this orchestrator (`--configs`).
218    async fn do_measurement(
219        &self,
220        request: Request<ScheduleMeasurement>,
221    ) -> Result<Response<Self::DoMeasurementStream>, Status> {
222        self.check_cli_access("do_measurement")?;
223
224        info!("[Orchestrator] Received CLI measurement request for measurement");
225        let mut m_def = request.into_inner();
226
227        // Refuse start messages exceeding the rate limit or using disallowed origins
228        self.validate_rate(m_def.probing_rate)?;
229        self.validate_origins(&m_def)?;
230        let worker_interval = m_def.worker_interval as u64;
231        let probe_interval = m_def.probe_interval as u64;
232        let nprobes = m_def.number_of_probes;
233        let probing_rate = m_def.probing_rate;
234        let m_type = m_def.m_type();
235
236        // Feed measurements stream their targets over the live measurement RPC
237        if m_type.is_feed() {
238            return Err(Status::invalid_argument(
239                "Feed measurements must use the live measurement stream",
240            ));
241        }
242
243        // Classify workers and validate configuration
244        let ClassifiedWorkers {
245            participating_ids,
246            probing_ids,
247        } = self.classify_workers(&m_def)?;
248        let probing_workers_count = probing_ids.len();
249        let m_id = self.next_m_id();
250
251        // The strategy determines task distribution, discovery usage, and pacing
252        let strategy = DistributionStrategy::select(&m_def);
253
254        self.init_measurement(
255            &m_def,
256            m_id,
257            &participating_ids,
258            &probing_ids,
259            strategy.is_gated_broadcast(),
260        )?;
261
262        info!(
263            "[Orchestrator] {} participating workers, {} will probe ({worker_interval} seconds between probing workers)",
264            participating_ids.len(),
265            probing_workers_count,
266        );
267
268        // Set up CLI result stream
269        let (cli_tx, cli_rx) = mpsc::channel::<Result<ReplyBatch, Status>>(1000);
270        let _ = self.cli_sender.lock().unwrap().insert(cli_tx);
271
272        // Send Start instructions to all participating workers
273        send_start_instructions(&self.saved_workers, &self.measurement, &m_def, m_id).await;
274        tokio::time::sleep(Duration::from_secs(1)).await;
275
276        // Initialize traceroute if applicable
277        if let Some(trace_options) = m_def.trace_options.take() {
278            // Tracemap seed probes must use a single origin TODO test traceroute/tracemap with multi-origins
279            let trace_origin_id = m_def
280                .configurations
281                .first()
282                .and_then(|c| c.origin)
283                .map(|o| o.origin_id)
284                .unwrap_or(ALL_ORIGINS);
285            self.setup_traceroute(trace_options, trace_origin_id);
286        }
287
288        // Round-robin strategies pace each worker at the full rate; Broadcast paces the batch
289        let mut probing_rate_interval = if matches!(strategy, DistributionStrategy::Broadcast) {
290            tokio::time::interval(Duration::from_secs(1))
291        } else {
292            tokio::time::interval(Duration::from_secs(1) / probing_workers_count as u32)
293        };
294        // Skip missed ticks instead of bursting to catch up after a stalled (backpressured) send
295        probing_rate_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
296
297        // Build config and launch task distribution
298        let task_config = TaskDistributorConfig {
299            m_id,
300            hitlist: std::mem::take(&mut m_def.hitlist),
301            measurement: self.measurement.clone(),
302            workers: Arc::clone(&self.saved_workers),
303            probing_rate,
304            probing_rate_interval,
305            number_of_probing_workers: probing_workers_count,
306            worker_interval,
307            nprobes,
308            probe_interval,
309            is_prefix_hitlist: m_def.is_prefix_hitlist,
310        };
311
312        distribute_tasks(task_config, strategy).await;
313
314        //  Return CLI result stream
315        Ok(Response::new(CLIReceiver {
316            inner: cli_rx,
317            measurement: self.measurement.clone(),
318            workers: Arc::clone(&self.saved_workers),
319            m_id,
320        }))
321    }
322
323    type LiveMeasurementStream = CLIReceiver<Result<ReplyBatch, Status>>;
324
325    /// Handles a live (feed-based) measurement request from the CLI.
326    ///
327    /// The first message on the stream must be the measurement definition;
328    /// subsequent messages carry targets to probe.
329    /// # Errors
330    /// Returns an error if the first message is not a measurement definition, if the
331    /// measurement type is not catchment, if the probing rate exceeds the configured
332    /// `--max_rate`, if an origin is not allowed by this orchestrator, if there is
333    /// already an active measurement, or if no workers can participate.
334    async fn live_measurement(
335        &self,
336        request: Request<tonic::Streaming<CliMessage>>,
337    ) -> Result<Response<Self::LiveMeasurementStream>, Status> {
338        self.check_cli_access("live_measurement")?;
339
340        let mut inbound = request.into_inner();
341
342        // The first message on the stream must be the measurement definition
343        let m_def = match inbound.message().await? {
344            Some(CliMessage {
345                message: Some(cli_message::Message::Start(m_def)),
346            }) => m_def,
347            _ => {
348                return Err(Status::invalid_argument(
349                    "First message on a live stream must be the measurement definition",
350                ));
351            }
352        };
353
354        if !m_def.m_type().is_feed() {
355            return Err(Status::invalid_argument(
356                "Live measurements require a feed measurement type (-m feed or -m feed-trace)",
357            ));
358        }
359
360        // Live mode requires every origin to be available on every probing worker
361        let mut origin_workers: HashMap<u32, HashSet<u32>> = HashMap::new();
362        for config in &m_def.configurations {
363            if let Some(origin) = &config.origin {
364                origin_workers
365                    .entry(origin.origin_id)
366                    .or_default()
367                    .insert(config.worker_id);
368            }
369        }
370        let all_assignments: HashSet<u32> =
371            m_def.configurations.iter().map(|c| c.worker_id).collect();
372        for (origin_id, assigned) in &origin_workers {
373            if !assigned.contains(&ALL_WORKERS) && *assigned != all_assignments {
374                return Err(Status::invalid_argument(format!(
375                    "Live measurements require origins shared among all probing workers (origin {origin_id} is not)"
376                )));
377            }
378        }
379
380        // Refuse start messages exceeding the rate limit or using disallowed origins
381        self.validate_rate(m_def.probing_rate)?;
382        self.validate_origins(&m_def)?;
383        let probing_rate = m_def.probing_rate;
384
385        info!(
386            "[Orchestrator] Received CLI live measurement request (rate {probing_rate} per worker)"
387        );
388
389        // Classify workers and validate configuration
390        let ClassifiedWorkers {
391            participating_ids,
392            probing_ids,
393        } = self.classify_workers(&m_def)?;
394        let probing_workers_count = probing_ids.len();
395        if probing_workers_count == 0 {
396            return Err(Status::new(
397                tonic::Code::Cancelled,
398                "No probing workers available",
399            ));
400        }
401
402        // Initialize measurement state (errors if already active)
403        let m_id = self.next_m_id();
404        // Feed follow-ups pick their workers per target (LiveTarget.worker_ids), not here
405        self.init_measurement(&m_def, m_id, &participating_ids, &probing_ids, false)?;
406
407        if let Some(state) = self.measurement.write().unwrap().as_mut() {
408            state.live = Some(LiveState {
409                pending: HashMap::new(),
410                set_stacks: HashMap::new(),
411                trace_targets: HashMap::new(),
412            });
413        }
414
415        // Sweep timed-out discovery targets
416        self.spawn_discovery_sweeper();
417
418        info!(
419            "[Orchestrator] {} participating workers, {} will probe",
420            participating_ids.len(),
421            probing_workers_count,
422        );
423
424        // Set up CLI result stream
425        let (cli_tx, cli_rx) = mpsc::channel::<Result<ReplyBatch, Status>>(1000);
426        let _ = self.cli_sender.lock().unwrap().insert(cli_tx);
427
428        // Send Start instructions to all participating workers
429        send_start_instructions(&self.saved_workers, &self.measurement, &m_def, m_id).await;
430        tokio::time::sleep(Duration::from_secs(1)).await;
431
432        // Rate-limiting: when full, the orchestrator stops reading the CLI stream
433        let capacity = (probing_rate as usize * FEED_BUFFER_SECS).max(1000);
434        let (feed_tx, feed_rx) = mpsc::channel::<LiveTarget>(capacity);
435
436        // Forward targets into the task distributor until the CLI closes its stream
437        tokio::spawn(async move {
438            loop {
439                match inbound.message().await {
440                    Ok(Some(CliMessage {
441                        message: Some(cli_message::Message::Targets(batch)),
442                    })) => {
443                        for target in batch.targets {
444                            if feed_tx.send(target).await.is_err() {
445                                return; // Distributor is gone (measurement ended)
446                            }
447                        }
448                    }
449                    Ok(Some(_)) => {
450                        warn!("[Orchestrator] Ignoring unexpected message on live stream");
451                    }
452                    Ok(None) => return, // CLI closed its stream
453                    Err(e) => {
454                        warn!("[Orchestrator] Live stream error: {e}");
455                        return;
456                    }
457                }
458            }
459        });
460
461        distribute_live_tasks(
462            feed_rx,
463            self.measurement.clone(),
464            Arc::clone(&self.saved_workers),
465            &m_def,
466            m_id,
467        );
468
469        // Return CLI result stream
470        Ok(Response::new(CLIReceiver {
471            inner: cli_rx,
472            measurement: self.measurement.clone(),
473            workers: Arc::clone(&self.saved_workers),
474            m_id,
475        }))
476    }
477
478    /// Handle the list_clients command from the CLI.
479    ///
480    /// Returns the connected clients.
481    async fn list_workers(
482        &self,
483        _request: Request<Empty>,
484    ) -> Result<Response<custom_module::manycastr::Status>, Status> {
485        self.check_cli_access("list_workers")?;
486
487        // Lock the workers list and clone it to return
488        let workers_list = self.saved_workers.lock().unwrap();
489        let mut workers = Vec::new();
490        for worker in workers_list.iter() {
491            workers.push(Worker {
492                worker_id: worker.worker_id,
493                hostname: worker.hostname.clone(),
494                status: worker.get_status() as i32,
495                unicast_v4: worker.unicast_v4,
496                unicast_v6: worker.unicast_v6,
497            });
498        }
499
500        let status = custom_module::manycastr::Status { workers };
501        Ok(Response::new(status))
502    }
503
504    /// Receive a batch of results from a worker and put it in the stream towards the CLI.
505    ///
506    /// # Arguments
507    /// * `request` - a ReplyBatch containing results from a worker
508    ///
509    /// # Errors
510    /// Returns an error if the CLI has disconnected.
511    async fn send_result(&self, request: Request<ReplyBatch>) -> Result<Response<Ack>, Status> {
512        self.check_worker_access("send_result")?;
513
514        // Send the result to the CLI through the established stream
515        let task_result = request.into_inner();
516        let catcher_id = task_result.rx_id;
517        let origin_id = task_result.origin_id;
518
519        // Split replies into buckets
520        let mut results_bucket: Vec<Reply> = Vec::new();
521        let mut trace_bucket: Vec<TraceReply> = Vec::new();
522        let mut discovery_bucket: Vec<DiscoveryReply> = Vec::new();
523
524        for result_wrapper in task_result.results {
525            match result_wrapper.reply_data {
526                Some(ReplyData::Trace(t)) => {
527                    trace_bucket.push(t);
528                }
529                Some(ReplyData::Discovery(d)) => {
530                    discovery_bucket.push(d);
531                }
532                Some(inner_data) => {
533                    results_bucket.push(Reply {
534                        reply_data: Some(inner_data),
535                    });
536                }
537                None => {}
538            }
539        }
540
541        // Process discovery and traceroute replies
542        if !discovery_bucket.is_empty() || !trace_bucket.is_empty() {
543            let mut lock = self.measurement.write().unwrap();
544            let Some(state) = lock.as_mut() else {
545                // Discard late results arrived after the measurement was torn down
546                warn!(
547                    "[Orchestrator] Dropping {} late replies from worker {catcher_id} (no active measurement)",
548                    discovery_bucket.len() + trace_bucket.len()
549                );
550                return Ok(Response::new(Ack::ok()));
551            };
552
553            // Create a follow-up task for a discovery reply
554            if let Some(live) = state.live.as_mut() {
555                for reply in discovery_bucket.drain(..) {
556                    let Some(src) = reply.src else { continue };
557                    let Some((session_id, pending)) = live.remove_pending(src, reply.session_id)
558                    else {
559                        continue; // Unknown target or duplicate reply
560                    };
561
562                    // Create the follow-up task
563                    let task = Task {
564                        task_type: Some(task::TaskType::Probe(Probe { dst: Some(src) })),
565                        origin_id,
566                        nprobes: wire_nprobes(pending.nprobes), // repeat nprobes times
567                        session_id,
568                    };
569                    match pending.worker_sel {
570                        // Any-worker follow-ups are performed by the discovery worker
571                        WorkerSel::Any => state
572                            .worker_stacks
573                            .entry(pending.discovery_worker)
574                            .or_default()
575                            .push_back(task),
576                        WorkerSel::All => state
577                            .worker_stacks
578                            .entry(ALL_WORKERS)
579                            .or_default()
580                            .push_back(task),
581                        // Worker-set follow-ups are staggered by the live distributor
582                        WorkerSel::Set(worker_ids) => live
583                            .set_stacks
584                            .entry(worker_ids)
585                            .or_default()
586                            .push_back(task),
587                    }
588                }
589            }
590
591            // Drop duplicate discovery replies (e.g., multi-reply targets)
592            discovery_bucket.retain(|reply| match reply.src {
593                Some(addr) if state.is_prefix_hitlist => {
594                    // For ISI hitlist probing, resolve targets at prefix granularity
595                    state.resolved_targets.insert(addr.prefix_base())
596                }
597                Some(addr) => state.resolved_targets.insert(addr),
598                None => false,
599            });
600
601            if !discovery_bucket.is_empty() {
602                match state.m_type {
603                    // Determine worker(s) for follow-up probes
604                    MeasurementType::Laces | MeasurementType::AnycastLatency => {
605                        // Determine whether follow-up probes are sent from all workers
606                        let follow_up_id = if state.is_gated_broadcast {
607                            ALL_WORKERS
608                        } else {
609                            catcher_id
610                        };
611                        discovery_handler(
612                            discovery_bucket,
613                            follow_up_id,
614                            &mut state.worker_stacks,
615                            origin_id,
616                            state.nprobes,
617                        );
618                    }
619
620                    // Special handling for Traceroute
621                    MeasurementType::AnycastTraceroute => {
622                        if let Some(config) = state.trace_config.as_mut() {
623                            trace_discovery_handler(
624                                discovery_bucket,
625                                catcher_id,
626                                &mut state.worker_stacks,
627                                config,
628                                origin_id,
629                            );
630                        }
631                    }
632
633                    MeasurementType::Catchment
634                    | MeasurementType::Tracemap
635                    | MeasurementType::Feed
636                    | MeasurementType::FeedTrace => warn!(
637                        "[Orchestrator] Received discovery results for Origin {origin_id}, from Worker {catcher_id}, for unsupported mode: {}",
638                        state.m_type
639                    ),
640                }
641            }
642
643            if !trace_bucket.is_empty() {
644                if let Some(config) = state.trace_config.as_mut() {
645                    // Only forward trace replies that matched an active trace session
646                    let matched_replies = trace_replies_handler(
647                        trace_bucket,
648                        &mut state.worker_stacks,
649                        config,
650                        origin_id,
651                    );
652
653                    for t in matched_replies {
654                        results_bucket.push(Reply {
655                            reply_data: Some(ReplyData::Trace(t)),
656                        });
657                    }
658                } else if state.m_type == MeasurementType::FeedTrace {
659                    // Check there is a matching trace target for the received reply
660                    let now = std::time::Instant::now();
661                    if let Some(live) = state.live.as_ref() {
662                        for t in trace_bucket {
663                            let is_probed_target = t.trace_dst.is_some_and(|dst| {
664                                live.trace_targets
665                                    .get(&dst)
666                                    .is_some_and(|deadline| *deadline > now)
667                            });
668                            if is_probed_target && state.participants.contains_key(&t.tx_id) {
669                                results_bucket.push(Reply {
670                                    reply_data: Some(ReplyData::Trace(t)),
671                                });
672                            }
673                        }
674                    }
675                }
676            }
677        }
678
679        if !results_bucket.is_empty() {
680            // Whether this is a --responsive catchment mapping for multi-prefix targets
681            let is_prefix_catchment = {
682                let lock = self.measurement.read().unwrap();
683                matches!(*lock, Some(ref state) if state.is_prefix_hitlist && state.m_type == MeasurementType::Catchment)
684            };
685            if is_prefix_catchment {
686                // Keep track of resolved prefixes
687                let mut lock = self.measurement.write().unwrap();
688                if let Some(state) = lock.as_mut() {
689                    for reply in &results_bucket {
690                        if let Some(ReplyData::Measurement(m)) = &reply.reply_data
691                            && let Some(src) = m.src
692                        {
693                            state.resolved_targets.insert(src.prefix_base());
694                        }
695                    }
696                }
697            }
698        }
699
700        if !results_bucket.is_empty() {
701            // Forward results to the CLI
702            let tx = self.cli_sender.lock().unwrap().clone();
703
704            if let Some(tx) = tx
705                && tx
706                    .send(Ok(ReplyBatch {
707                        rx_id: catcher_id,
708                        results: results_bucket,
709                        origin_id,
710                    }))
711                    .await
712                    .is_err()
713            {
714                warn!("[Orchestrator] CLI disconnected, dropping result batch.");
715            }
716        }
717
718        Ok(Response::new(Ack::ok()))
719    }
720}
721
722impl ControllerService {
723    /// Validate a requested probing rate against the orchestrator's configured
724    /// maximum (`--max_rate`).
725    ///
726    /// # Errors
727    /// Returns an error naming the requested and maximum rate.
728    fn validate_rate(&self, probing_rate: u32) -> Result<(), Status> {
729        let Some(max_rate) = self.max_rate else {
730            return Ok(()); // No rate limit configured
731        };
732        if probing_rate > max_rate {
733            warn!(
734                "[Orchestrator] Refusing measurement: probing rate {probing_rate} exceeds the configured maximum of {max_rate}"
735            );
736            return Err(Status::invalid_argument(format!(
737                "Probing rate {probing_rate} exceeds this orchestrator's maximum rate of {max_rate} (probes per second, per worker)"
738            )));
739        }
740        Ok(())
741    }
742
743    /// Validate the origins of a measurement definition against the orchestrator's
744    /// origin allow-list (`--origins`). Without an allow-list, every origin is allowed.
745    ///
746    /// An origin is allowed when a rule matches its source address and permits its protocol.
747    ///
748    /// # Errors
749    /// Returns an error naming the refused origin and listing the available origins.
750    fn validate_origins(&self, m_def: &ScheduleMeasurement) -> Result<(), Status> {
751        let Some(allowed_origins) = &self.allowed_origins else {
752            return Ok(()); // No allow-list configured
753        };
754
755        for origin in m_def
756            .configurations
757            .iter()
758            .filter_map(|c| c.origin.as_ref())
759        {
760            let Some(src) = &origin.src else { continue };
761            let p_type = origin.p_type();
762
763            if allowed_origins
764                .iter()
765                .any(|rule| rule.src == *src && rule.allows(p_type))
766            {
767                continue;
768            }
769
770            // Distinguish an unavailable address from a disallowed protocol
771            let reason = if allowed_origins.iter().any(|rule| rule.src == *src) {
772                format!(
773                    "protocol {} is not allowed for source address {src}",
774                    p_type.as_str()
775                )
776            } else {
777                format!("source address {src} is not available")
778            };
779            warn!("[Orchestrator] Refusing measurement: {reason}");
780
781            let available: Vec<String> = allowed_origins
782                .iter()
783                .map(|rule| rule.to_string())
784                .collect();
785            return Err(Status::permission_denied(format!(
786                "Origin not allowed by this orchestrator: {reason}. Available origins: {}",
787                available.join(", ")
788            )));
789        }
790
791        Ok(())
792    }
793
794    /// Classify connected workers as probing, listening, or idle based on the measurement
795    /// configuration. Validates that at least one worker can participate and that all
796    /// configured worker IDs correspond to connected workers.
797    ///
798    /// Returns the worker senders plus the participating and probing worker ID lists.
799    fn classify_workers(&self, m_def: &ScheduleMeasurement) -> Result<ClassifiedWorkers, Status> {
800        let mut participating_worker_ids = Vec::new();
801        let mut probing_worker_ids = Vec::new();
802
803        // Whether non-probing workers should listen (true when any configuration probes with anycast).
804        let is_anycast = has_anycast_origin(&m_def.configurations);
805
806        let workers = self.saved_workers.lock().unwrap();
807
808        for worker in workers.iter() {
809            let mut status_lock = worker.status.lock().unwrap();
810
811            // Skip disconnected workers
812            if *status_lock == Disconnected {
813                warn!("[Orchestrator] Worker {} unavailable.", worker.hostname);
814                continue;
815            }
816
817            // Probing if any configuration is assigned to this worker
818            let is_probing = m_def.configurations.iter().any(|config| {
819                config.worker_id == worker.worker_id || config.worker_id == ALL_WORKERS
820            });
821
822            if is_probing {
823                *status_lock = Probing;
824                probing_worker_ids.push(worker.worker_id);
825                participating_worker_ids.push(worker.worker_id);
826            } else if is_anycast {
827                *status_lock = Listening;
828                participating_worker_ids.push(worker.worker_id);
829            } else {
830                *status_lock = Idle;
831            };
832        }
833
834        // Validate: at least one participating worker
835        if participating_worker_ids.is_empty() {
836            error!("[Orchestrator] No connected workers available for this configuration.");
837            return Err(Status::new(tonic::Code::Cancelled, "No connected workers"));
838        }
839
840        // Validate: no unknown worker IDs in configuration
841        if m_def.configurations.iter().any(|conf| {
842            conf.worker_id != ALL_WORKERS && !workers.iter().any(|w| w.worker_id == conf.worker_id)
843        }) {
844            error!("[Orchestrator] Configuration contains unknown worker IDs.");
845            return Err(Status::new(
846                tonic::Code::Cancelled,
847                "Unknown worker in configuration",
848            ));
849        }
850
851        Ok(ClassifiedWorkers {
852            participating_ids: participating_worker_ids,
853            probing_ids: probing_worker_ids,
854        })
855    }
856
857    /// Re-admit a reconnecting worker into the active measurement, if it was participating.
858    ///
859    /// Sends the Start instruction for the worker, and restores it for the task distributor.
860    ///
861    /// Rejoin is refused if the measurement is finalizing.
862    fn try_rejoin(
863        &self,
864        worker_id: u32,
865        hostname: &str,
866        tx: &mpsc::Sender<Result<Instruction, Status>>,
867        status: &Arc<Mutex<WorkerStatus>>,
868    ) {
869        let mut lock = self.measurement.write().unwrap();
870        let Some(state) = lock.as_mut() else {
871            return; // No active measurement
872        };
873        if !state.participants.contains_key(&worker_id) {
874            return; // Not a participant of the measurement
875        }
876        if state.is_finalizing {
877            return; // Measurement being finished
878        }
879        let Some(start) = state.start_instructions.get(&worker_id) else {
880            return; // Should not happen
881        };
882
883        // Send the Start instruction to the reconnecting worker
884        let start_instruction = Instruction {
885            instruction_type: Some(instruction::InstructionType::Start(start.clone())),
886        };
887        if tx.try_send(Ok(start_instruction)).is_err() {
888            // TODO implement try_send function with warn printing
889            warn!(
890                "[Orchestrator] Could not queue Start instruction for rejoining worker {hostname}"
891            );
892            return;
893        }
894
895        // Restore the worker's role, probing slot for the distributor, and completion claim
896        let role = state.participants[&worker_id].role;
897        *status.lock().unwrap() = role;
898        if role == Probing && !state.probing_workers.contains(&worker_id) {
899            state.probing_workers.push(worker_id);
900        }
901        // Ensure the measurement waits for this worker when finalizing
902        if let Some(participant) = state.participants.get_mut(&worker_id) {
903            participant.is_counted = true;
904        }
905
906        info!(
907            "[Orchestrator] Worker {hostname} rejoined measurement {} ({})",
908            state.m_id,
909            role.as_str_name()
910        );
911    }
912
913    /// Initialize the shared measurement state. Errors if a measurement is already active.
914    fn init_measurement(
915        &self,
916        m_def: &ScheduleMeasurement,
917        m_id: u32,
918        participating_ids: &[u32],
919        probing_ids: &[u32],
920        is_gated_broadcast: bool,
921    ) -> Result<(), Status> {
922        let mut lock = self.measurement.write().unwrap();
923        if lock.is_some() {
924            error!("[Orchestrator] There is already an active measurement, returning");
925            return Err(Status::new(
926                tonic::Code::Cancelled,
927                "There is already an active measurement",
928            ));
929        }
930
931        // Each participant starts with a claim on measurement completion
932        let participants = participating_ids
933            .iter()
934            .map(|&id| {
935                let role = if probing_ids.contains(&id) {
936                    Probing
937                } else {
938                    Listening
939                };
940                (
941                    id,
942                    Participant {
943                        role,
944                        is_counted: true,
945                    },
946                )
947            })
948            .collect();
949
950        *lock = Some(MeasurementState {
951            m_id,
952            probing_workers: probing_ids.to_vec(),
953            participants,
954            start_instructions: HashMap::new(),
955            is_finalizing: false,
956            m_type: m_def.m_type(),
957            is_gated_broadcast,
958            is_prefix_hitlist: m_def.is_prefix_hitlist,
959            nprobes: m_def.number_of_probes,
960            worker_stacks: HashMap::new(),
961            trace_config: None,
962            resolved_targets: HashSet::new(),
963            live: None,
964        });
965
966        Ok(())
967    }
968
969    /// Spawn the discovery-timeout sweeper for a live measurement.
970    ///
971    /// Every second, expired pending discovery targets are dropped as unresponsive.
972    /// The sweeper exits when the measurement ends.
973    fn spawn_discovery_sweeper(&self) {
974        let measurement = self.measurement.clone();
975        tokio::spawn(async move {
976            let mut interval = tokio::time::interval(Duration::from_secs(1));
977            loop {
978                interval.tick().await;
979
980                let mut lock = measurement.write().unwrap();
981                let Some(state) = lock.as_mut() else {
982                    break; // Measurement ended
983                };
984                let Some(live) = state.live.as_mut() else {
985                    break;
986                };
987
988                let now = Instant::now();
989                live.pending.retain(|_, pending| pending.deadline > now);
990            }
991        });
992    }
993
994    /// Initialize traceroute configuration within the measurement state and spawn the
995    /// timeout handler thread that monitors active trace sessions.
996    fn setup_traceroute(&self, trace_options: TraceOptions, origin_id: u32) {
997        {
998            let mut lock = self.measurement.write().unwrap();
999            if let Some(ref mut state) = *lock {
1000                state.trace_config = Some(TracerouteConfig {
1001                    session_tracker: SessionTracker::new(),
1002                    origin_id,
1003                    timeout: trace_options.timeout as u64,
1004                    max_hops: trace_options.max_hops,
1005                    initial_hop: trace_options.initial_hop,
1006                    max_failures: trace_options.max_failures,
1007                    star_unresponsive: trace_options.star_unresponsive,
1008                });
1009            }
1010        }
1011
1012        let measurement_clone = self.measurement.clone();
1013        let cli_sender_clone = self.cli_sender.clone();
1014        std::thread::spawn(move || {
1015            check_trace_timeouts(measurement_clone, cli_sender_clone);
1016        });
1017    }
1018}
1019
1020/// Builds Start instruction for all participating workers.
1021/// Sends them for measurement init, and persists them for re-joining workers.
1022async fn send_start_instructions(
1023    workers: &WorkerRegistry,
1024    measurement: &MeasurementHandle,
1025    m_def: &ScheduleMeasurement,
1026    m_id: u32,
1027) {
1028    // Collect unique anycast RX origins across all configurations
1029    let mut seen_origins = HashSet::new();
1030    let mut anycast_rx_origins = vec![];
1031    for configuration in m_def.configurations.iter() {
1032        if let Some(origin) = &configuration.origin
1033            && !origin.is_unicast()
1034            && seen_origins.insert(origin.origin_id)
1035        {
1036            anycast_rx_origins.push(*origin);
1037        }
1038    }
1039
1040    // Get current workers connected at measurement start
1041    let participants: Vec<_> = workers
1042        .lock()
1043        .unwrap()
1044        .iter()
1045        .filter(|w| w.is_participating())
1046        .cloned()
1047        .collect();
1048
1049    for worker in participants {
1050        let worker_id = worker.worker_id;
1051
1052        // Collect TX origins assigned to this specific worker
1053        let mut tx_origins = vec![];
1054        for configuration in &m_def.configurations {
1055            if (configuration.worker_id == worker_id || configuration.worker_id == ALL_WORKERS)
1056                && let Some(origin) = &configuration.origin
1057            {
1058                tx_origins.push(*origin);
1059            }
1060        }
1061
1062        // This worker listens on all anycast origins plus its own unicast TX origins
1063        let mut rx_origins = anycast_rx_origins.clone();
1064        rx_origins.extend(tx_origins.iter().filter(|o| o.is_unicast()).copied());
1065
1066        let start = Start {
1067            rate: m_def.probing_rate,
1068            m_id,
1069            tx_origins,
1070            rx_origins,
1071            record: m_def.record.clone(),
1072            url: m_def.url.clone(),
1073            m_type: m_def.m_type,
1074            probe_interval: m_def.probe_interval,
1075        };
1076
1077        // Persist the Start instruction for re-sending on rejoin
1078        if let Some(state) = measurement.write().unwrap().as_mut() {
1079            state.start_instructions.insert(worker_id, start.clone());
1080        }
1081
1082        let start_instruction = Instruction {
1083            instruction_type: Some(instruction::InstructionType::Start(start)),
1084        };
1085
1086        worker
1087            .send(Ok(start_instruction))
1088            .await
1089            .expect("Failed to send Start instruction to worker");
1090    }
1091}