Skip to main content

manycastr/orchestrator/
task_distributor.rs

1use crate::custom_module::has_anycast_origin;
2use crate::custom_module::manycastr::WorkerStatus;
3use crate::custom_module::manycastr::WorkerStatus::Probing;
4use crate::custom_module::manycastr::{
5    Address, End, Instruction, LiveTarget, MeasurementType, Probe, ScheduleMeasurement, Task,
6    Tasks, Trace, instruction, task,
7};
8use crate::orchestrator::trace::seed_tracemap_sessions;
9use crate::orchestrator::{
10    LIVE_DISCOVERY_TIMEOUT_SECS, MeasurementHandle, PendingTarget, WorkerRegistry, WorkerSel,
11    wire_nprobes,
12};
13use crate::{ALL_ORIGINS, ALL_WORKERS};
14use log::{debug, info, warn};
15use std::collections::HashMap;
16use std::time::Duration;
17use tokio::spawn;
18use tokio::sync::mpsc;
19use tokio::time::{Instant, Interval, MissedTickBehavior};
20
21/// Grace period (seconds) after the hitlist is exhausted
22const REPLY_GRACE_SECS: u64 = 5;
23
24/// Pause discovery when the deepest follow-up stack exceeds this many seconds of drain (at the probing rate)
25const STACK_HIGH_WATERMARK_SECS: usize = 5;
26/// Resume discovery once the deepest follow-up stack drops below this many seconds of drain (at the probing rate)
27const STACK_LOW_WATERMARK_SECS: usize = 1;
28
29/// How tasks should be distributed to workers
30pub enum DistributionStrategy {
31    /// Broadcast tasks to all probing workers simultaneously (LACeS, and unicast-only latency)
32    Broadcast,
33    /// Send tasks to probing workers in round-robin fashion (catchment mode)
34    RoundRobin,
35    /// Send discovery tasks round-robin, with follow-up task interleaving (latency, traceroute, responsive modes)
36    Discovery {
37        /// --responsive sends follow-ups to ALL workers; otherwise to the catching worker
38        is_responsive: bool,
39    },
40    /// Seed binary-search trace sessions round-robin, with follow-up task interleaving (tracemap mode)
41    Tracemap,
42}
43
44impl DistributionStrategy {
45    /// Select the distribution strategy for a measurement definition.
46    ///
47    /// * **catchment** → RoundRobin: one probe per target
48    /// * **tracemap** → Tracemap
49    /// * **anycast-traceroute** → Discovery: find the catching worker first
50    /// * **latency** with an anycast origin → Discovery: measure from the catching worker
51    /// * **latency** with only unicast origins → Broadcast: every worker measures
52    ///   from its own unicast address (no discovery needed)
53    /// * **laces** → Broadcast
54    /// * `--responsive` gates either broadcast probes or multi-target hitlist probing.
55    pub fn select(m_def: &ScheduleMeasurement) -> Self {
56        match m_def.m_type() {
57            MeasurementType::Catchment => Self::RoundRobin,
58            MeasurementType::Tracemap => Self::Tracemap,
59            // --responsive only used for multi-target hitlist probing
60            MeasurementType::AnycastTraceroute => Self::Discovery {
61                is_responsive: false,
62            },
63            // --responsive only used for multi-target hitlist probing
64            MeasurementType::AnycastLatency if has_anycast_origin(&m_def.configurations) => {
65                Self::Discovery {
66                    is_responsive: false,
67                }
68            }
69            // Feed measurements are rejected by do_measurement and use the live distributor
70            MeasurementType::Feed | MeasurementType::FeedTrace => {
71                unreachable!("feed measurements use the live task distributor")
72            }
73            // --responsive gates the broadcast behind a single-worker discovery probe
74            MeasurementType::AnycastLatency | MeasurementType::Laces => {
75                if m_def.is_responsive {
76                    Self::Discovery {
77                        is_responsive: true,
78                    }
79                } else {
80                    Self::Broadcast
81                }
82            }
83        }
84    }
85
86    /// Whether follow-up probes are broadcast to all workers, rather than sent by the
87    /// worker that caught the discovery probe.
88    pub fn is_gated_broadcast(&self) -> bool {
89        matches!(
90            self,
91            Self::Discovery {
92                is_responsive: true
93            }
94        )
95    }
96}
97
98pub struct TaskDistributorConfig {
99    /// ID of the measurement this distributor belongs to
100    pub m_id: u32,
101    /// Target addresses to probe
102    pub hitlist: Vec<Address>,
103    /// All per-measurement state. `None` when idle.
104    pub measurement: MeasurementHandle,
105    /// Shared list of worker senders, updated on reconnect.
106    pub workers: WorkerRegistry,
107    /// Number of tasks to send per interval (equal to probing rate)
108    pub probing_rate: u32,
109    /// Interval at which to send tasks
110    pub probing_rate_interval: Interval,
111    /// Number of probing workers
112    pub number_of_probing_workers: usize,
113    /// Inter-worker interval in seconds between workers
114    pub worker_interval: u64,
115    /// Number of times to repeat each measurement probe (discovery probes are always sent once)
116    pub nprobes: u32,
117    /// Inter-probe interval in seconds between repeated probes
118    pub probe_interval: u64,
119    /// Measure multiple targets pre prefix, skip targets of already-resolved prefixes
120    pub is_prefix_hitlist: bool,
121}
122
123/// Build a `Task` from a raw address and the current distribution metadata.
124/// The worker sends the probe `nprobes` times (spaced by the measurement's probe interval).
125#[inline]
126fn make_task(
127    addr: Address,
128    is_discovery: bool,
129    origin_id: u32,
130    nprobes: u32,
131    session_id: u32,
132) -> Task {
133    Task {
134        task_type: Some(if is_discovery {
135            task::TaskType::Discovery(Probe { dst: Some(addr) })
136        } else {
137            task::TaskType::Probe(Probe { dst: Some(addr) })
138        }),
139        origin_id,
140        nprobes: wire_nprobes(nprobes),
141        session_id,
142    }
143}
144
145/// Send an instruction to workers according to the specified parameters.
146///
147/// # Arguments
148/// * `workers` - registry of worker senders
149/// * `worker_id` - target: `ALL_WORKERS` for broadcast, or a specific worker ID
150/// * `instruction` - the instruction to send
151/// * `inter_worker_interval` - seconds between workers for broadcast sends
152async fn send_to_workers(
153    workers: &WorkerRegistry,
154    worker_id: u32,
155    instruction: Instruction,
156    inter_worker_interval: u64,
157) {
158    if worker_id == ALL_WORKERS {
159        // Broadcast to all probing workers with inter-worker delay
160        let probing_ids: Vec<u32> = workers
161            .lock()
162            .unwrap()
163            .iter()
164            .filter(|sender| *sender.status == Probing)
165            .map(|sender| sender.worker_id)
166            .collect();
167
168        send_staggered(workers, &probing_ids, instruction, inter_worker_interval);
169    } else {
170        // Send to a specific worker
171        let sender = {
172            let workers = workers.lock().unwrap();
173            workers.iter().find(|s| s.worker_id == worker_id).cloned()
174        };
175        if let Some(sender) = sender {
176            if sender.get_status() != WorkerStatus::Disconnected {
177                let _ = sender.send(Ok(instruction)).await;
178            }
179        } else {
180            warn!("[Orchestrator] No sender found for worker ID {worker_id}");
181        }
182    }
183}
184
185/// Send an instruction to each listed worker, spaced by the inter-worker interval.
186fn send_staggered(
187    workers: &WorkerRegistry,
188    worker_ids: &[u32],
189    instruction: Instruction,
190    inter_worker_interval: u64,
191) {
192    let senders: Vec<_> = {
193        let registry = workers.lock().unwrap();
194        worker_ids
195            .iter()
196            .filter_map(|id| {
197                let sender = registry.iter().find(|s| s.worker_id == *id).cloned();
198                if sender.is_none() {
199                    warn!("[Orchestrator] No sender found for worker ID {id}");
200                }
201                sender
202            })
203            .collect()
204    };
205
206    for (probing_index, sender) in (0_u64..).zip(senders) {
207        let task_c = instruction.clone();
208        spawn(async move {
209            // Wait inter-worker probing interval
210            tokio::time::sleep(Duration::from_secs(probing_index * inter_worker_interval)).await;
211
212            let _ = sender.send(Ok(task_c)).await;
213        });
214    }
215}
216
217/// Resolve a live target's `worker_ids` into a worker selection:
218/// empty selects any worker (round-robin), `[ALL_WORKERS]` selects all probing
219/// workers, and anything else is an explicit set of worker IDs (sorted,
220/// deduplicated, and filtered to probing workers).
221///
222/// Returns `None` (drop the target) when none of the requested workers is probing.
223fn resolve_worker_sel(
224    mut worker_ids: Vec<u32>,
225    probing_workers: &[u32],
226    dst: Address,
227) -> Option<WorkerSel> {
228    if worker_ids.is_empty() {
229        return Some(WorkerSel::Any);
230    }
231    if worker_ids.contains(&ALL_WORKERS) {
232        return Some(WorkerSel::All);
233    }
234
235    worker_ids.sort_unstable();
236    worker_ids.dedup();
237    let requested = worker_ids.len();
238    worker_ids.retain(|id| probing_workers.contains(id));
239    match worker_ids.len() {
240        0 => {
241            warn!(
242                "[Orchestrator] Dropping target {dst}: none of its workers are probing in this measurement"
243            );
244            None
245        }
246        probing => {
247            if probing < requested {
248                warn!(
249                    "[Orchestrator] Target {dst}: ignoring {} worker(s) not probing in this measurement",
250                    requested - probing
251                );
252            }
253            Some(WorkerSel::Set(worker_ids))
254        }
255    }
256}
257
258/// Finalize a measurement once task distribution is done: send the end-of-measurement
259/// instruction to all workers (marking them finished), then wait for every worker to
260/// report back before the distributor task exits.
261///
262/// Does nothing when measurement `m_id` is no longer the active one (the CLI dropped
263/// and tore it down, possibly replacing it with a new measurement in the meantime).
264async fn finalize_measurement(
265    workers: &WorkerRegistry,
266    measurement: &MeasurementHandle,
267    m_id: u32,
268) {
269    // Start the finalizing, disallowing reconnects
270    {
271        let mut lock = measurement.write().unwrap();
272        match lock.as_mut() {
273            Some(state) if state.m_id == m_id => state.is_finalizing = true,
274            _ => {
275                warn!("[Orchestrator] Measurement {m_id} already ended, skipping finalization.");
276                return;
277            }
278        }
279    }
280    info!("[Orchestrator] Task distribution finished.");
281
282    // Notify all workers that the measurement is over
283    let end = Instruction {
284        instruction_type: Some(instruction::InstructionType::End(End { code: 0 })),
285    };
286    let senders: Vec<_> = workers.lock().unwrap().clone();
287    for sender in &senders {
288        let _ = sender.send(Ok(end.clone())).await;
289        sender.finished();
290    }
291
292    // Wait for all workers to finish this measurement
293    while matches!(*measurement.read().unwrap(), Some(ref state) if state.m_id == m_id) {
294        tokio::time::sleep(Duration::from_secs(1)).await;
295    }
296}
297
298/// Task distributor. Spawns a background task that distributes tasks to workers
299/// according to the chosen strategy, handles cooldowns, and sends end/break signals.
300///
301/// # Arguments
302/// * `config` - TaskDistributorConfig with all necessary parameters
303/// * `strategy` - How tasks should be distributed (Broadcast, RoundRobin, or Discovery)
304pub async fn distribute_tasks(config: TaskDistributorConfig, strategy: DistributionStrategy) {
305    let strategy_name = match &strategy {
306        DistributionStrategy::Broadcast => "Broadcast",
307        DistributionStrategy::RoundRobin => "Round-Robin",
308        DistributionStrategy::Discovery { .. } => "Round-Robin Discovery",
309        DistributionStrategy::Tracemap => "Round-Robin Tracemap",
310    };
311    info!("[Orchestrator] Starting {strategy_name} Task Distributor.");
312
313    let is_broadcast = matches!(&strategy, DistributionStrategy::Broadcast);
314    let is_tracemap = matches!(&strategy, DistributionStrategy::Tracemap);
315    // Discovery mode wraps hitlist addresses in Discovery tasks (Probe tasks otherwise)
316    let is_discovery = matches!(&strategy, DistributionStrategy::Discovery { .. });
317    // Tracemap interleaves follow-up trace probes with session seeding, like discovery modes
318    let has_follow_ups = is_discovery || is_tracemap;
319    let is_responsive = strategy.is_gated_broadcast();
320
321    // Wait for the last tasks being sent (accounting for repeated probes)
322    let repeat_secs = (config.nprobes.saturating_sub(1)) as u64 * config.probe_interval;
323    let cooldown_secs = if is_broadcast || is_responsive {
324        // Also wait for the inter-worker staggering of the last broadcast batch
325        (config.number_of_probing_workers as u64 * config.worker_interval) + repeat_secs + 1
326    } else {
327        repeat_secs + 1
328    };
329
330    let mut probing_rate_interval = config.probing_rate_interval;
331
332    let mut hitlist_iter = config.hitlist.into_iter();
333    let mut hitlist_exhausted = false;
334    // When the hitlist was first exhausted (used for the reply grace period)
335    let mut hitlist_exhausted_at: Option<Instant> = None;
336    let mut cooldown_timer: Option<Instant> = None;
337    // Whether the idle cooldown has been announced
338    let mut cooldown_announced = false;
339
340    // nprobes: measurement probes are repeated (by the worker), discovery probes are not
341    let task_nprobes = if has_follow_ups { 1 } else { config.nprobes };
342    let inter_worker_interval = config.worker_interval;
343
344    // Follow-up backlog watermarks, expressed in seconds of drain at the probing rate
345    let high_watermark = STACK_HIGH_WATERMARK_SECS * config.probing_rate as usize;
346    let low_watermark = STACK_LOW_WATERMARK_SECS * config.probing_rate as usize;
347
348    spawn(async move {
349        let mut current_index: usize = 0;
350        let mut discovery_paused = false;
351
352        loop {
353            // Get next worker ID (also verifies our measurement is still the active one)
354            let (worker_id, n_probing) = {
355                let lock = config.measurement.read().unwrap();
356                let state = match *lock {
357                    Some(ref s) if s.m_id == config.m_id => s,
358                    _ => {
359                        warn!("[Orchestrator] Measurement no longer active");
360                        break;
361                    }
362                };
363
364                let workers = &state.probing_workers;
365                if workers.is_empty() {
366                    warn!("[Orchestrator] No more probing workers available, ending measurement.");
367                    break;
368                }
369
370                // Determine which worker(s) perform the current batch
371                if is_broadcast {
372                    (ALL_WORKERS, workers.len())
373                } else {
374                    current_index %= workers.len();
375                    let id = workers[current_index];
376                    current_index = (current_index + 1) % workers.len();
377                    (id, workers.len())
378                }
379            };
380
381            // Add follow-up tasks from worker stacks (discovery mode only) to this batch
382            let follow_up_count = if has_follow_ups {
383                // Responsive probes are broadcasted and incur a follow-up cost for each probers
384                let (f_worker_id, f_budget) = if is_responsive {
385                    (
386                        ALL_WORKERS,
387                        std::cmp::max(1, config.probing_rate as usize / n_probing),
388                    )
389                } else {
390                    (worker_id, config.probing_rate as usize)
391                };
392
393                let (follow_up_tasks, max_stack_depth): (Vec<Task>, usize) = {
394                    let mut lock = config.measurement.write().unwrap();
395                    match lock.as_mut() {
396                        Some(state) if state.m_id == config.m_id => {
397                            let tasks =
398                                if let Some(queue) = state.worker_stacks.get_mut(&f_worker_id) {
399                                    let n = std::cmp::min(f_budget, queue.len());
400                                    queue.drain(..n).collect()
401                                } else {
402                                    Vec::new()
403                                };
404                            let depth = state
405                                .worker_stacks
406                                .values()
407                                .map(|q| q.len())
408                                .max()
409                                .unwrap_or(0);
410                            (tasks, depth)
411                        }
412                        _ => (Vec::new(), 0),
413                    }
414                };
415
416                // Hysteresis: pause/resume discovery based on the watermark thresholds
417                if discovery_paused {
418                    if max_stack_depth <= low_watermark {
419                        info!("[Orchestrator] Follow-up backlog drained, resuming discovery.");
420                        discovery_paused = false;
421                    }
422                } else if max_stack_depth >= high_watermark {
423                    info!(
424                        "[Orchestrator] Follow-up backlog too large ({max_stack_depth} tasks), pausing discovery."
425                    );
426                    discovery_paused = true;
427                }
428
429                let count = follow_up_tasks.len();
430                if !follow_up_tasks.is_empty() {
431                    send_to_workers(
432                        &config.workers,
433                        f_worker_id,
434                        Instruction {
435                            instruction_type: Some(instruction::InstructionType::Tasks(Tasks {
436                                tasks: follow_up_tasks,
437                            })),
438                        },
439                        inter_worker_interval,
440                    )
441                    .await;
442                }
443                count
444            } else {
445                0
446            };
447
448            // Fill remainder of the batch with hitlist tasks
449            let follow_up_cost = if is_responsive {
450                // Account for responsive follow-ups being sent by all workers
451                follow_up_count.saturating_mul(n_probing)
452            } else {
453                follow_up_count
454            };
455            let remainder = (config.probing_rate as usize).saturating_sub(follow_up_cost);
456
457            if remainder > 0 && !hitlist_exhausted && !discovery_paused {
458                // Wrap target addresses into tasks
459                let tasks: Vec<Task> = if is_tracemap {
460                    // Register a binary-search session per target, assigned to this round's worker
461                    let addrs: Vec<Address> = hitlist_iter.by_ref().take(remainder).collect();
462                    let mut lock = config.measurement.write().unwrap();
463                    match lock
464                        .as_mut()
465                        .filter(|state| state.m_id == config.m_id)
466                        .and_then(|state| state.trace_config.as_mut())
467                    {
468                        Some(trace_config) => {
469                            seed_tracemap_sessions(addrs, worker_id, trace_config)
470                        }
471                        None => {
472                            warn!(
473                                "[Orchestrator] No traceroute configuration for tracemap, ending measurement."
474                            );
475                            break;
476                        }
477                    }
478                } else if config.is_prefix_hitlist {
479                    // Add with targets inside unresolved prefixes
480                    let lock = config.measurement.read().unwrap();
481                    match *lock {
482                        Some(ref state) if state.m_id == config.m_id => hitlist_iter
483                            .by_ref()
484                            .filter(|addr| !state.resolved_targets.contains(&addr.prefix_base()))
485                            .take(remainder)
486                            .map(|addr| make_task(addr, is_discovery, ALL_ORIGINS, task_nprobes, 0))
487                            .collect(),
488                        _ => break, // Measurement canceled
489                    }
490                } else {
491                    // Simply add hitlist targets
492                    hitlist_iter
493                        .by_ref()
494                        .take(remainder)
495                        .map(|addr| make_task(addr, is_discovery, ALL_ORIGINS, task_nprobes, 0))
496                        .collect()
497                };
498
499                if tasks.len() < remainder {
500                    hitlist_exhausted = true;
501                    hitlist_exhausted_at = Some(Instant::now());
502                    if has_follow_ups {
503                        info!(
504                            "[Orchestrator] All discovery probes sent, awaiting follow-up probes."
505                        );
506                    }
507                }
508
509                if !tasks.is_empty() {
510                    send_to_workers(
511                        &config.workers,
512                        worker_id,
513                        Instruction {
514                            instruction_type: Some(instruction::InstructionType::Tasks(Tasks {
515                                tasks,
516                            })),
517                        },
518                        inter_worker_interval,
519                    )
520                    .await;
521                }
522            }
523
524            // Check if the measurement is finished
525            if hitlist_exhausted {
526                if has_follow_ups {
527                    // Discovery: wait for stacks + trace sessions to drain before cooldown
528                    let (stacks_empty, traces_active) = {
529                        let lock = config.measurement.read().unwrap();
530                        match *lock {
531                            Some(ref state) if state.m_id == config.m_id => (
532                                state.worker_stacks.values().all(|q| q.is_empty()),
533                                state
534                                    .trace_config
535                                    .as_ref()
536                                    .is_some_and(|c| !c.session_tracker.sessions.is_empty()),
537                            ),
538                            _ => break, // Measurement canceled
539                        }
540                    };
541
542                    if stacks_empty && !traces_active {
543                        if let Some(start_time) = cooldown_timer {
544                            if start_time.elapsed() >= Duration::from_secs(cooldown_secs) {
545                                break;
546                            }
547                        } else if hitlist_exhausted_at
548                            .is_some_and(|t| t.elapsed() >= Duration::from_secs(REPLY_GRACE_SECS))
549                        {
550                            // Grace period elapsed — start the idle cooldown
551                            if cooldown_announced {
552                                debug!(
553                                    "[Orchestrator] Idle again after late follow-ups. Restarting the {cooldown_secs}-second cooldown."
554                                );
555                            } else {
556                                info!(
557                                    "[Orchestrator] No more tasks. Awaiting a {cooldown_secs}-second cooldown."
558                                );
559                                cooldown_announced = true;
560                            }
561                            cooldown_timer = Some(Instant::now());
562                        }
563                    } else {
564                        // Activity resumed -> cancel any pending cooldown.
565                        cooldown_timer = None;
566                    }
567                } else {
568                    // Broadcast/RoundRobin: hitlist exhausted → cooldown and done
569                    break;
570                }
571            }
572
573            probing_rate_interval.tick().await;
574        }
575
576        // Discovery handles the cooldown inside the loop; other modes sleep here
577        if !has_follow_ups {
578            info!("[Orchestrator] All tasks sent. Awaiting a {cooldown_secs}-second cooldown.");
579            tokio::time::sleep(Duration::from_secs(cooldown_secs)).await;
580        }
581
582        finalize_measurement(&config.workers, &config.measurement, config.m_id).await;
583    });
584}
585
586/// Live task distributor for feed-based measurements.
587///
588/// Prioritizes follow-up tasks for workers (--responsive).
589/// Drains targets up to the probing rate (optinally enforced by the orchestrator).
590///
591/// Measurement probes are optionally repeated by the worker (nprobes > 1).
592///
593/// In feed-trace mode (`is_trace`), targets are probed with TTL-limited trace
594/// probes (per-target `ttl`, default 255)
595///
596/// The measurement ends when the feed has closed (the CLI ended its stream or
597/// disconnected) and all follow-ups and pending discoveries have resolved,
598/// or when no probing workers remain.
599pub fn distribute_live_tasks(
600    mut feed: mpsc::Receiver<LiveTarget>,
601    measurement: MeasurementHandle,
602    workers: WorkerRegistry,
603    m_def: &ScheduleMeasurement,
604    m_id: u32,
605) {
606    info!("[Orchestrator] Starting Live Task Distributor.");
607
608    let probing_rate = m_def.probing_rate;
609    let worker_interval = m_def.worker_interval as u64;
610    let probe_interval = m_def.probe_interval as u64;
611    let is_responsive = m_def.is_responsive;
612    let is_trace = m_def.m_type() == MeasurementType::FeedTrace;
613
614    spawn(async move {
615        let mut tick_interval = tokio::time::interval(Duration::from_secs(1));
616        tick_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
617        let mut current_index: usize = 0;
618        let batch_capacity = probing_rate as usize;
619        let mut feed_closed = false;
620
621        loop {
622            tick_interval.tick().await;
623
624            // Drain follow-up tasks from the worker and worker-set stacks
625            let (follow_ups, set_follow_ups, probing_workers, pending_count) = {
626                let mut lock = measurement.write().unwrap();
627                let Some(state) = lock.as_mut().filter(|s| s.m_id == m_id) else {
628                    warn!("[Orchestrator] Measurement no longer active");
629                    break;
630                };
631
632                if state.probing_workers.is_empty() {
633                    warn!("[Orchestrator] No more probing workers available, ending measurement.");
634                    break;
635                }
636
637                let mut follow_ups: Vec<(u32, Vec<Task>)> = Vec::new();
638                for (worker_id, stack) in state.worker_stacks.iter_mut() {
639                    if !stack.is_empty() {
640                        let n = stack.len().min(batch_capacity);
641                        follow_ups.push((*worker_id, stack.drain(..n).collect()));
642                    }
643                }
644
645                let mut set_follow_ups: Vec<(Vec<u32>, Vec<Task>)> = Vec::new();
646                if let Some(live) = state.live.as_mut() {
647                    for (worker_ids, stack) in live.set_stacks.iter_mut() {
648                        if !stack.is_empty() {
649                            let n = stack.len().min(batch_capacity);
650                            set_follow_ups.push((worker_ids.clone(), stack.drain(..n).collect()));
651                        }
652                    }
653
654                    // Drop trace targets whose reply window has passed (stray filtering)
655                    if is_trace {
656                        let now = std::time::Instant::now();
657                        live.trace_targets.retain(|_, deadline| *deadline > now);
658                    }
659                }
660
661                let pending_count = state.live.as_ref().map_or(0, |live| live.pending.len());
662                (
663                    follow_ups,
664                    set_follow_ups,
665                    state.probing_workers.clone(),
666                    pending_count,
667                )
668            };
669
670            let follow_up_count: usize = follow_ups
671                .iter()
672                .map(|(_, tasks)| tasks.len())
673                .sum::<usize>()
674                + set_follow_ups
675                    .iter()
676                    .map(|(_, tasks)| tasks.len())
677                    .sum::<usize>();
678            for (worker_id, tasks) in follow_ups {
679                // The worker interval only applies to ALL_WORKERS (broadcast) stacks
680                send_to_workers(
681                    &workers,
682                    worker_id,
683                    Instruction {
684                        instruction_type: Some(instruction::InstructionType::Tasks(Tasks {
685                            tasks,
686                        })),
687                    },
688                    worker_interval,
689                )
690                .await;
691            }
692            // Worker-set follow-ups are staggered by the worker interval
693            for (worker_ids, tasks) in set_follow_ups {
694                send_staggered(
695                    &workers,
696                    &worker_ids,
697                    Instruction {
698                        instruction_type: Some(instruction::InstructionType::Tasks(Tasks {
699                            tasks,
700                        })),
701                    },
702                    worker_interval,
703                );
704            }
705
706            // Drain the feed (non-blocking) up to the remaining rate budget
707            let remainder = batch_capacity.saturating_sub(follow_up_count);
708            let mut batch: Vec<LiveTarget> = Vec::new();
709            while batch.len() < remainder {
710                match feed.try_recv() {
711                    Ok(target) => batch.push(target),
712                    Err(mpsc::error::TryRecvError::Empty) => break,
713                    Err(mpsc::error::TryRecvError::Disconnected) => {
714                        if !feed_closed {
715                            info!("[Orchestrator] Live feed closed, finishing outstanding tasks.");
716                            feed_closed = true;
717                        }
718                        break;
719                    }
720                }
721            }
722            let dispatched = batch.len();
723
724            // Partition the batch by worker-set assignment, registering discovery targets
725            let mut per_set: HashMap<Vec<u32>, Vec<Task>> = HashMap::new();
726            let mut broadcast: Vec<Task> = Vec::new();
727            {
728                let mut lock = measurement.write().unwrap();
729                let Some(state) = lock.as_mut().filter(|s| s.m_id == m_id) else {
730                    warn!("[Orchestrator] Measurement no longer active");
731                    break;
732                };
733
734                for mut target in batch {
735                    let Some(dst) = target.dst else { continue };
736
737                    // Resolve the target's worker selection (drops targets with no probing worker)
738                    let Some(sel) = resolve_worker_sel(
739                        std::mem::take(&mut target.worker_ids),
740                        &probing_workers,
741                        dst,
742                    ) else {
743                        continue;
744                    };
745
746                    // Feed-trace: each target is a TTL-limited trace probe
747                    if is_trace {
748                        // Track the trace packet sent for filtering
749                        if let Some(live) = state.live.as_mut() {
750                            let window = probing_workers.len() as u64 * worker_interval
751                                + target.nprobes.max(1).saturating_sub(1) as u64 * probe_interval
752                                + REPLY_GRACE_SECS;
753                            let deadline = std::time::Instant::now() + Duration::from_secs(window);
754                            live.trace_targets.insert(dst, deadline);
755                        }
756
757                        // Default to a high TTL that reaches the target itself
758                        let ttl = if target.ttl == 0 { 255 } else { target.ttl };
759                        let task = Task {
760                            task_type: Some(task::TaskType::Trace(Trace {
761                                dst: Some(dst),
762                                ttl,
763                            })),
764                            origin_id: target.origin_id,
765                            nprobes: wire_nprobes(target.nprobes),
766                            session_id: 0,
767                        };
768                        match sel {
769                            WorkerSel::Any => {
770                                // Round-robin across probing workers
771                                current_index %= probing_workers.len();
772                                per_set
773                                    .entry(vec![probing_workers[current_index]])
774                                    .or_default()
775                                    .push(task);
776                                current_index += 1;
777                            }
778                            WorkerSel::All => broadcast.push(task),
779                            WorkerSel::Set(ids) => {
780                                per_set.entry(ids).or_default().push(task);
781                            }
782                        }
783                        continue;
784                    }
785
786                    // --responsive gates multi-worker probing behind a single discovery probe
787                    if is_responsive && sel.is_multi() {
788                        // The discovery probe is sent by a single worker (round-robin)
789                        let probe_worker = match &sel {
790                            WorkerSel::Any | WorkerSel::All => {
791                                current_index %= probing_workers.len();
792                                let id = probing_workers[current_index];
793                                current_index += 1;
794                                id
795                            }
796                            WorkerSel::Set(ids) => {
797                                let id = ids[current_index % ids.len()];
798                                current_index += 1;
799                                id
800                            }
801                        };
802
803                        let Some(live) = state.live.as_mut() else {
804                            continue;
805                        };
806
807                        live.pending.insert(
808                            (dst, target.session_id),
809                            PendingTarget {
810                                worker_sel: sel,
811                                discovery_worker: probe_worker,
812                                nprobes: target.nprobes,
813                                deadline: std::time::Instant::now()
814                                    + Duration::from_secs(LIVE_DISCOVERY_TIMEOUT_SECS),
815                            },
816                        );
817
818                        // The discovery probe is sent once; measurement probes follow on a reply
819                        per_set
820                            .entry(vec![probe_worker])
821                            .or_default()
822                            .push(make_task(dst, true, target.origin_id, 1, target.session_id));
823                        continue;
824                    }
825
826                    // Regular probe task
827                    let task = make_task(
828                        dst,
829                        false,
830                        target.origin_id,
831                        target.nprobes,
832                        target.session_id,
833                    );
834                    match sel {
835                        WorkerSel::Any => {
836                            // Round-robin across probing workers
837                            current_index %= probing_workers.len();
838                            per_set
839                                .entry(vec![probing_workers[current_index]])
840                                .or_default()
841                                .push(task);
842                            current_index += 1;
843                        }
844                        WorkerSel::All => broadcast.push(task),
845                        WorkerSel::Set(ids) => {
846                            per_set.entry(ids).or_default().push(task);
847                        }
848                    }
849                }
850            }
851
852            // Send the per-worker-set tasks, staggered by the worker interval
853            for (worker_ids, tasks) in per_set {
854                send_staggered(
855                    &workers,
856                    &worker_ids,
857                    Instruction {
858                        instruction_type: Some(instruction::InstructionType::Tasks(Tasks {
859                            tasks,
860                        })),
861                    },
862                    worker_interval,
863                );
864            }
865
866            // Broadcast tasks to all probing workers, staggered by the worker interval
867            if !broadcast.is_empty() {
868                send_to_workers(
869                    &workers,
870                    ALL_WORKERS,
871                    Instruction {
872                        instruction_type: Some(instruction::InstructionType::Tasks(Tasks {
873                            tasks: broadcast,
874                        })),
875                    },
876                    worker_interval,
877                )
878                .await;
879            }
880
881            // Done once the feed is closed and all outstanding work has resolved
882            if feed_closed && dispatched == 0 && follow_up_count == 0 && pending_count == 0 {
883                break;
884            }
885        }
886
887        // Exit the measurement
888        finalize_measurement(&workers, &measurement, m_id).await;
889    });
890}