Skip to main content

cortex/dispatcher/
server.rs

1use std::collections::HashMap;
2use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
3use std::sync::mpsc::SyncSender;
4use std::thread;
5use std::time::{Duration, Instant};
6
7use dashmap::DashMap;
8use tracing::{debug, error, warn};
9
10use crate::backend::Backend;
11use crate::helpers::{NewTaskMessage, TaskProgress, TaskReport, TaskStatus};
12use crate::models::Service;
13
14/// Probe interval (seconds) between TCP keepalive probes once the idle threshold is crossed, and
15/// the number of unanswered probes before the OS declares the peer dead — fixed sane values so only
16/// the idle threshold needs to be a config knob (see [`crate::config::DispatcherConfig`]). With the
17/// defaults a dead worker is detected in roughly `idle + 4×30 s` ≈ a few minutes — far faster than
18/// the 1 h lease, while keeping NAT mappings warm.
19const KEEPALIVE_PROBE_INTERVAL_SECS: i32 = 30;
20/// See [`KEEPALIVE_PROBE_INTERVAL_SECS`].
21const KEEPALIVE_PROBE_COUNT: i32 = 4;
22
23/// Applies TCP keepalive to a worker-facing ZMQ socket (the ventilator's ROUTER, the sink's PULL),
24/// the "stable" half of a remote worker interface: keepalive keeps idle worker connections alive
25/// across NAT/firewall idle-timeouts (an idle mapping is otherwise silently dropped, dropping the
26/// worker from the fleet until it reconnects) and lets the OS reap a dead peer's route. It does
27/// **not** affect task-recovery correctness — the lease reaper is that net — so `idle_seconds <= 0`
28/// safely disables it (OS default). **Set before `bind`** so accepted connections inherit it.
29pub fn apply_tcp_keepalive(socket: &zmq::Socket, idle_seconds: i32) -> zmq::Result<()> {
30  if idle_seconds <= 0 {
31    return Ok(());
32  }
33  socket.set_tcp_keepalive(1)?;
34  socket.set_tcp_keepalive_idle(idle_seconds)?;
35  socket.set_tcp_keepalive_intvl(KEEPALIVE_PROBE_INTERVAL_SECS)?;
36  socket.set_tcp_keepalive_cnt(KEEPALIVE_PROBE_COUNT)?;
37  Ok(())
38}
39
40/// **Graceful-shutdown request flag** (O-1, orchestration). Set by the SIGTERM/SIGINT handler the
41/// dispatcher binary installs; the ventilator checks it each loop iteration and, when set, signals
42/// completion (`dispatch_complete`) and returns — so the manager drains the in-flight set and
43/// flushes the finalize batch before exiting, rather than the supervisor hard-killing in-flight
44/// work. ONLY a **planned** stop uses this; unexpected failures still fail-fast (panic → abort).
45/// Production-only — bounded test runs never install the handler, so this stays `false` and the
46/// dispatch path is byte-for-byte unchanged.
47pub static SHUTDOWN_REQUESTED: AtomicBool = AtomicBool::new(false);
48
49/// SIGTERM/SIGINT handler. The only thing it does is an async-signal-safe atomic store — nothing
50/// allocating or locking, as `signal(2)` requires.
51extern "C" fn handle_shutdown_signal(_sig: libc::c_int) {
52  SHUTDOWN_REQUESTED.store(true, Ordering::SeqCst);
53}
54
55/// Installs SIGTERM + SIGINT handlers that request a graceful drain-and-stop. Call once from the
56/// dispatcher binary's `main` (never in bounded test runs).
57pub fn install_shutdown_handlers() {
58  // SAFETY: `handle_shutdown_signal` is async-signal-safe (a lone atomic store).
59  unsafe {
60    libc::signal(
61      libc::SIGTERM,
62      handle_shutdown_signal as *const () as libc::sighandler_t,
63    );
64    libc::signal(
65      libc::SIGINT,
66      handle_shutdown_signal as *const () as libc::sighandler_t,
67    );
68  }
69}
70
71/// Whether a graceful shutdown has been requested (set by [`install_shutdown_handlers`]'s handler).
72#[must_use]
73pub fn shutdown_requested() -> bool { SHUTDOWN_REQUESTED.load(Ordering::SeqCst) }
74
75/// The dispatcher's **service cache**: a `service_name → Option<Service>` memo shared by the
76/// ventilator (populated on a cache miss) and the sink (read on every result), so a `Service` row
77/// is looked up from the DB at most once per name. Phase 4 of the dispatcher rationalization
78/// replaces the old `Arc<Mutex<HashMap>>` with a sharded `DashMap`, so this near-static,
79/// read-mostly cache is no longer behind a single global lock contended on every dispatch.
80pub type ServiceCache = DashMap<String, Option<Service>>;
81
82/// The dispatcher's **sandbox cache**: a `corpus_id → Option<sandbox_id>` memo, the corpus-keyed
83/// twin of [`ServiceCache`]. `Some(id)` means the corpus is a sandbox (Arm 5) whose result archives
84/// are name-scoped by its own id; `None` means an ordinary corpus. Sandbox-ness is immutable per
85/// corpus (a corpus is born sandbox-or-not and never flips), so a one-time DB lookup per
86/// `corpus_id` is correct forever — no invalidation. The ventilator memoises it on dispatch (it
87/// holds a `Backend`), so the sink can scope a result's output path with a lock-free read — no
88/// per-result DB hit — keeping a sandbox rerun from overwriting the parent's archives (KNOWN_ISSUES
89/// F-6).
90pub type SandboxCache = DashMap<i32, Option<i32>>;
91
92/// Rate-limited logging for high-frequency, low-value events — the dispatcher's *discarded*
93/// messages (malformed replies/requests, unknown service names, unknown task ids). The dispatcher's
94/// request and sink loops would otherwise `warn!` one line per skipped message; under a **sustained
95/// flood** (a hostile or buggy peer spamming bad frames) even leveled logging serialises a
96/// synchronous write per event and *slows the real pipeline* — a self-inflicted throughput-DoS
97/// (KNOWN_ISSUES D-11). This aggregates instead: it counts events and signals an
98/// emit at most once per `interval` (plus the very first event, so a problem is visible
99/// immediately), carrying the suppressed count. So a flood of any size costs **O(1) log I/O per
100/// interval**, not O(flood) — *counted, not narrated* — while a genuine trickle is still surfaced.
101/// Per-thread (no sharing/locking); the clock read per event is a cheap monotonic `Instant::now()`.
102pub struct RateLimitedLog {
103  events_since_emit: u64,
104  last_emit: Option<Instant>,
105  interval: Duration,
106}
107
108impl RateLimitedLog {
109  /// A new aggregator that emits at most once per `interval` (after an immediate first emit).
110  pub fn new(interval: Duration) -> Self {
111    RateLimitedLog {
112      events_since_emit: 0,
113      last_emit: None,
114      interval,
115    }
116  }
117
118  /// Records one event. Returns `Some(count)` — the number of events since the last emit, inclusive
119  /// — when a summary line is due (the first event always, then at most once per `interval`),
120  /// resetting the counter; otherwise `None` (suppress; just counted).
121  pub fn record(&mut self) -> Option<u64> {
122    self.events_since_emit += 1;
123    let due = match self.last_emit {
124      None => true,
125      Some(at) => at.elapsed() >= self.interval,
126    };
127    if due {
128      let count = self.events_since_emit;
129      self.events_since_emit = 0;
130      self.last_emit = Some(Instant::now());
131      Some(count)
132    } else {
133      None
134    }
135  }
136}
137
138/// Hard ceiling on the in-flight (progress) set. Reaching it means backpressure
139/// ([`crate::config::DispatcherConfig::max_in_flight`]) failed to hold the line, so we fail fast
140/// (panic → process abort → external restart) rather than exhaust memory — the dispatcher's
141/// intentional fail-fast design. Backpressure must engage *below* this (asserted in tests).
142pub const PROGRESS_QUEUE_HARD_LIMIT: usize = 10_000;
143/// Capacity of the bounded done (results-pending-persist) channel between the producers (sink +
144/// ventilator reaper) and the finalize thread. A **full** channel *blocks* the producer — that *is*
145/// the backpressure (a slow DB makes the sink wait, which backs up the ZMQ PULL, which backs up the
146/// workers), replacing the old `DONE_QUEUE_HARD_LIMIT` panic-then-OOM backstop with a graceful,
147/// loss-free hand-off (a bounded channel blocks rather than drops). Phase 1 of the dispatcher
148/// rationalization (`docs/archive/DISPATCHER_RATIONALIZATION.md`).
149pub const DONE_QUEUE_CAPACITY: usize = 10_000;
150
151/// Whether the in-flight set is saturated and the ventilator should apply backpressure (stop
152/// leasing new work and mock-reply). Saturation is inclusive: at the threshold we already hold
153/// back, keeping the set bounded *below* [`PROGRESS_QUEUE_HARD_LIMIT`].
154pub fn in_flight_saturated(in_flight: usize, max_in_flight: usize) -> bool {
155  in_flight >= max_in_flight
156}
157
158/// The dispatcher's **in-flight (progress) set**: the dispatched-but-unfinished tasks, keyed by
159/// task id. Phase 4 of the dispatcher rationalization replaces the contended
160/// `Arc<Mutex<HashMap<i64, TaskProgress>>>` with a sharded, lock-free [`DashMap`] — so the
161/// ventilator's lease ([`Self::insert`]), the sink's return ([`Self::remove`]), and the reaper
162/// sweep ([`Self::take_expired`]) no longer serialise on one global lock — plus an [`AtomicUsize`]
163/// size counter so the per-request backpressure check ([`in_flight_saturated`]) reads the size in
164/// **O(1)** without locking or scanning the map.
165///
166/// The counter is maintained in lock-step with the map *here* (the only place either is mutated),
167/// so it converges to exactly the map size; a momentary ±1 skew between a map mutation and its
168/// atomic update is harmless for backpressure (self-correcting, never a leak). Shared as
169/// `Arc<InFlightSet>` across the ventilator / sink / reaper threads.
170#[derive(Default)]
171pub struct InFlightSet {
172  map: DashMap<i64, TaskProgress>,
173  len: AtomicUsize,
174}
175
176impl InFlightSet {
177  /// A new, empty in-flight set.
178  pub fn new() -> Self { Self::default() }
179
180  /// Current number of in-flight tasks — an **O(1)** atomic load (the backpressure hot read), not a
181  /// map scan.
182  pub fn len(&self) -> usize { self.len.load(Ordering::Acquire) }
183
184  /// Whether the in-flight set is empty.
185  pub fn is_empty(&self) -> bool { self.len() == 0 }
186
187  /// Record a dispatched task as in-flight (keyed by `task.id`). Re-inserting the same id (a
188  /// re-lease) overwrites without double-counting. Preserves the fail-fast hard-limit backstop: if
189  /// the set ever exceeds [`PROGRESS_QUEUE_HARD_LIMIT`] (backpressure failed to hold the line) we
190  /// panic → process abort → external restart, rather than grow unbounded.
191  pub fn insert(&self, progress_task: TaskProgress) {
192    let id = progress_task.task.id;
193    if self.map.insert(id, progress_task).is_none() {
194      self.len.fetch_add(1, Ordering::AcqRel);
195    }
196    if self.len() > PROGRESS_QUEUE_HARD_LIMIT {
197      panic!(
198        "Progress queue is too large: {:?} tasks. Stop the ventilator!",
199        self.len()
200      );
201    }
202  }
203
204  /// Remove and return the in-flight task with `taskid` (the sink draining a returned result).
205  /// Negative (mock-reply) ids are never tracked, so they short-circuit to `None`; removing an
206  /// already-gone task is a no-op that never underflows the counter.
207  pub fn remove(&self, taskid: i64) -> Option<TaskProgress> {
208    if taskid < 0 {
209      // Mock ids are to be skipped
210      return None;
211    }
212    let removed = self.map.remove(&taskid).map(|(_, v)| v);
213    if removed.is_some() {
214      self.len.fetch_sub(1, Ordering::AcqRel);
215    }
216    removed
217  }
218
219  /// Remove and return every in-flight task past its visibility-timeout deadline (the reaper
220  /// sweep).
221  pub fn take_expired(&self) -> Vec<TaskProgress> {
222    let now = chrono::Utc::now().timestamp();
223    let expired_keys: Vec<i64> = self
224      .map
225      .iter()
226      .filter(|entry| entry.value().expected_at() < now)
227      .map(|entry| *entry.key())
228      .collect();
229    let mut expired_tasks = Vec::with_capacity(expired_keys.len());
230    for key in expired_keys {
231      if let Some((_, task_progress)) = self.map.remove(&key) {
232        self.len.fetch_sub(1, Ordering::AcqRel);
233        expired_tasks.push(task_progress);
234      }
235    }
236    expired_tasks
237  }
238
239  /// Snapshot of the currently in-flight task ids — used on a ventilator restart to **exclude**
240  /// these from the `Queued → TODO` crash-recovery reset (`clear_limbo_tasks_except`), so tasks the
241  /// sink is still processing are not re-leased mid-flight (KNOWN_ISSUES D-4).
242  pub fn ids(&self) -> Vec<i64> { self.map.iter().map(|entry| *entry.key()).collect() }
243}
244
245/// Persists a **batch** of finished reports to the Task store, with bounded retry on a transient DB
246/// failure. The finalize thread drains the batch off the bounded done channel and hands it here in
247/// one `mark_done` call (amortizing the round-trip). On exhausted retries it returns `Err`, which
248/// the finalize thread propagates into a panic → the manager aborts the whole dispatcher — the
249/// intended fail-fast on a DB runaway (the channel design replaces the old mutex-poisoning that
250/// achieved the same propagation). A crash here loses nothing: the tasks remain `Queued` and
251/// recover on restart.
252pub fn mark_done_batch(backend: &mut Backend, reports: &[TaskReport]) -> Result<(), String> {
253  if reports.is_empty() {
254    return Ok(());
255  }
256  let request_time = chrono::Utc::now();
257  let mut success = false;
258  match backend.mark_done(reports) {
259    Err(e) => {
260      warn!(error = ?e, batch = reports.len(), "mark_done attempt failed");
261      // DB persist failed, retry
262      let mut retries = 0;
263      while retries < 3 {
264        thread::sleep(Duration::new(2, 0)); // wait 2 seconds before retrying, in case this is latency related
265        retries += 1;
266        match backend.mark_done(reports) {
267          Ok(_) => {
268            success = true;
269            break;
270          },
271          Err(e) => warn!(error = ?e, attempt = retries, "mark_done retry failed"),
272        };
273      }
274    },
275    _ => {
276      success = true;
277    },
278  }
279  if !success {
280    return Err(String::from(
281      "Database ran away during mark_done persisting.",
282    ));
283  }
284  let request_duration = (chrono::Utc::now() - request_time).num_milliseconds();
285  debug!(
286    count = reports.len(),
287    took_ms = request_duration,
288    "mark_done: persisted batch to DB"
289  );
290  Ok(())
291}
292
293/// Hands a finished report off to the finalize thread over the bounded done channel. A full channel
294/// **blocks** the producer (backpressure — see [`DONE_QUEUE_CAPACITY`]). An `Err` means the
295/// finalize receiver is gone (the thread died) — the report can't be persisted, but its task stays
296/// `Queued` and is recovered on restart, and the manager's supervision aborts the dispatcher, so we
297/// only log.
298pub fn send_done(done_tx: &SyncSender<TaskReport>, report: TaskReport) {
299  if done_tx.send(report).is_err() {
300    error!("done channel closed (finalize thread gone); the manager will abort for a restart");
301  }
302}
303
304/// The maximum number of dispatch retries before a perpetually-incomplete task is given up on.
305/// A task re-dispatched this many times that still never returns a result is treated as a hard
306/// failure (`Fatal`) rather than retried forever.
307///
308/// **1** with the short `lease_timeout_seconds` (~240 s, just above the worker's 180 s per-document
309/// timeout): a task whose worker keeps dying is almost always an unprocessable paper (a fresh
310/// recycle-clean worker dies on it too), so 2 retries (3 attempts total) catch the rare
311/// transient/worker-induced death and then converge to `Fatal` within a single run — the
312/// `(retries+1)×180` backoff cumulates to (1+2+3)×180 ≈ 1080 s, well inside a corpus pass —
313/// instead of the old 4 retries × 3600 s that stranded the task for hours.
314pub const MAX_DISPATCH_RETRIES: i64 = 1;
315
316/// The fate of a timed-out in-flight task, decided by [`classify_expired`].
317pub enum ExpiredOutcome {
318  /// Re-dispatch the task — retry budget remains; its retry count is incremented.
319  Requeue(TaskProgress),
320  /// Give up — retry budget exhausted; report the task `Fatal`.
321  Fatal(TaskReport),
322}
323
324/// Decides what to do with an in-flight task that timed out: retry it (until
325/// [`MAX_DISPATCH_RETRIES`] dispatches) or give up and report it `Fatal`.
326pub fn classify_expired(expired: TaskProgress) -> ExpiredOutcome {
327  if expired.retries > MAX_DISPATCH_RETRIES {
328    let task_id = expired.task.id;
329    ExpiredOutcome::Fatal(TaskReport {
330      task: expired.task,
331      status: TaskStatus::Fatal,
332      messages: vec![NewTaskMessage::new(
333        task_id,
334        "fatal",
335        "cortex".to_string(),
336        "never_completed_with_retries".to_string(),
337        String::new(),
338      )],
339    })
340  } else {
341    ExpiredOutcome::Requeue(TaskProgress {
342      task: expired.task,
343      created_at: expired.created_at,
344      retries: expired.retries + 1,
345      // D-17: a re-lease keeps the per-task lease captured at first dispatch.
346      lease_timeout_seconds: expired.lease_timeout_seconds,
347    })
348  }
349}
350
351/// Tally of one reaping pass — the dispatcher's re-lease / dead-letter health signal (Arm 8
352/// observability). Returned by [`reap_expired_into`] so the ventilator can log it without the
353/// reaper itself reaching for tracing.
354#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
355pub struct ReapSummary {
356  /// Timed-out tasks re-queued for another dispatch attempt (retry budget remained).
357  pub requeued: usize,
358  /// Timed-out tasks given up on and reported `Fatal` (retry budget exhausted) — dead-letters.
359  pub dead_lettered: usize,
360}
361
362/// Reaps timed-out in-flight tasks and routes each to **its own service's** dispatch queue (a
363/// retry) or the done queue (a `Fatal`). Decoupled from the refetch path so the in-flight set
364/// drains even under sustained backpressure (KNOWN_ISSUES D-6); routing by `task.service_id` rather
365/// than the requesting service fixes the latent cross-service requeue bug. Returns a
366/// [`ReapSummary`] of what it did (re-leases vs dead-letters) for the health log.
367pub fn reap_expired_into(
368  queues: &mut HashMap<i32, Vec<TaskProgress>>,
369  in_flight: &InFlightSet,
370  done_tx: &SyncSender<TaskReport>,
371) -> ReapSummary {
372  let mut summary = ReapSummary::default();
373  for expired in in_flight.take_expired() {
374    match classify_expired(expired) {
375      ExpiredOutcome::Requeue(task_progress) => {
376        queues
377          .entry(task_progress.task.service_id)
378          .or_default()
379          .push(task_progress);
380        summary.requeued += 1;
381      },
382      ExpiredOutcome::Fatal(report) => {
383        send_done(done_tx, report);
384        summary.dead_lettered += 1;
385      },
386    }
387  }
388  summary
389}
390
391/// Memoized getter for a `Service` record from the backend, populating the shared [`ServiceCache`]
392/// on a miss. The `or_insert_with` holds only the relevant DashMap *shard* during the one-time DB
393/// lookup (vs. the old whole-map `Mutex`), so concurrent lookups of *other* services are unblocked.
394pub fn get_sync_service(
395  service_name: &str,
396  services: &ServiceCache,
397  backend: &mut Backend,
398) -> Option<Service> {
399  services
400    .entry(service_name.to_string())
401    .or_insert_with(|| Service::find_by_name(service_name, &mut backend.connection).ok())
402    .value()
403    .clone()
404}
405
406/// Getter for a `Service` from the shared [`ServiceCache`], with no DB access (a `None` entry means
407/// a prior lookup found no such service).
408pub fn get_service(service_name: &str, services: &ServiceCache) -> Option<Service> {
409  services
410    .get(service_name)
411    .and_then(|entry| entry.value().clone())
412}
413
414/// Memoised getter for a corpus's sandbox id, populating the shared [`SandboxCache`] from the
415/// backend on a miss (the ventilator's DB-holding counterpart to [`get_sandbox_id`]). One lookup
416/// per `corpus_id` ever dispatched — bounded by the corpus count, not the task count.
417pub fn get_sync_sandbox_id(
418  corpus_id: i32,
419  sandboxes: &SandboxCache,
420  backend: &mut Backend,
421) -> Option<i32> {
422  *sandboxes
423    .entry(corpus_id)
424    .or_insert_with(|| {
425      crate::models::Corpus::find_by_id(corpus_id, &mut backend.connection)
426        .ok()
427        .and_then(|corpus| corpus.sandbox_id())
428    })
429    .value()
430}
431
432/// Getter for a corpus's sandbox id from the shared [`SandboxCache`], with no DB access — the
433/// sink's read on the result path. An absent entry yields `None` (treat as an ordinary corpus); the
434/// ventilator always memoises a task's corpus on dispatch, before its result can return, so the
435/// entry is present by the time the sink looks.
436pub fn get_sandbox_id(corpus_id: i32, sandboxes: &SandboxCache) -> Option<i32> {
437  sandboxes.get(&corpus_id).and_then(|entry| *entry.value())
438}
439
440#[cfg(test)]
441mod tests {
442  use super::*;
443  use crate::config::DispatcherConfig;
444  use crate::models::Task;
445  use std::sync::Arc;
446
447  fn dummy_progress(id: i64) -> TaskProgress {
448    TaskProgress {
449      task: Task {
450        id,
451        service_id: 1,
452        corpus_id: 1,
453        status: 0,
454        entry: String::new(),
455      },
456      created_at: 0,
457      retries: 0,
458      lease_timeout_seconds: 3600,
459    }
460  }
461
462  #[test]
463  fn reaper_honors_per_service_lease_timeouts() {
464    // D-17: two tasks both dispatched 100 s ago — one on a fast service (60 s lease → expired) and
465    // one on a slow service (3600 s lease → still valid). `take_expired` must reap only the
466    // short-lease task, so one dispatcher serves fast and slow workers without false-reaping the
467    // slow one mid-conversion.
468    let now = chrono::Utc::now().timestamp();
469    let set = InFlightSet::new();
470    let mut short = dummy_progress(1);
471    short.created_at = now - 100;
472    short.lease_timeout_seconds = 60; // expected_at = now - 40 → expired
473    set.insert(short);
474    let mut long = dummy_progress(2);
475    long.created_at = now - 100;
476    long.lease_timeout_seconds = 3600; // expected_at = now + 3500 → still leased
477    set.insert(long);
478    let expired = set.take_expired();
479    assert_eq!(expired.len(), 1, "only the short-lease task expired");
480    assert_eq!(
481      expired[0].task.id, 1,
482      "the short-lease (fast-service) task is reaped; the slow service's long lease is honored"
483    );
484  }
485
486  #[test]
487  fn reap_expired_into_tallies_requeue_and_dead_letter() {
488    use std::sync::mpsc::sync_channel;
489    // created_at = 0 (epoch) ⇒ every task's deadline is far in the past ⇒ all expired.
490    let set = InFlightSet::new();
491    set.insert(dummy_progress(1)); // retries 0 → within budget → re-queue
492    set.insert(dummy_progress(2)); // retries 0 → within budget → re-queue
493    set.insert(TaskProgress {
494      retries: MAX_DISPATCH_RETRIES + 1, // budget exhausted → dead-letter Fatal
495      ..dummy_progress(3)
496    });
497    let mut queues: HashMap<i32, Vec<TaskProgress>> = HashMap::new();
498    let (done_tx, done_rx) = sync_channel(16);
499    let summary = reap_expired_into(&mut queues, &set, &done_tx);
500    assert_eq!(summary.requeued, 2, "two within retry budget are re-leased");
501    assert_eq!(summary.dead_lettered, 1, "one over budget is dead-lettered");
502    assert_eq!(set.len(), 0, "the in-flight set fully drained");
503    assert_eq!(
504      done_rx.try_iter().count(),
505      1,
506      "the dead-letter Fatal reached the done channel"
507    );
508  }
509
510  #[test]
511  fn in_flight_saturation_is_inclusive_at_the_threshold() {
512    assert!(!in_flight_saturated(0, 5));
513    assert!(!in_flight_saturated(4, 5));
514    assert!(
515      in_flight_saturated(5, 5),
516      "at the threshold we already apply backpressure"
517    );
518    assert!(in_flight_saturated(6, 5));
519  }
520
521  #[test]
522  fn backpressure_engages_below_the_hard_panic_bound() {
523    // If the configured backpressure threshold ever reached the hard limit, backpressure would be
524    // dead code and the in-flight set could still grow to the panic — the very crash D-6 exists to
525    // prevent. Guard that invariant so a future config change can't silently reintroduce it.
526    assert!(
527      DispatcherConfig::default().max_in_flight < PROGRESS_QUEUE_HARD_LIMIT,
528      "backpressure must engage before the hard panic bound"
529    );
530  }
531
532  #[test]
533  fn inflight_len_tracks_dispatch_and_drain() {
534    let set = InFlightSet::new();
535    assert_eq!(set.len(), 0);
536    set.insert(dummy_progress(1));
537    set.insert(dummy_progress(2));
538    assert_eq!(set.len(), 2, "two tasks now in flight");
539    // The sink draining a returned result shrinks the in-flight set — how backpressure recovers.
540    set.remove(1);
541    assert_eq!(set.len(), 1);
542  }
543
544  #[test]
545  fn inflight_count_ignores_duplicate_insert_and_negative_remove() {
546    // The O(1) size counter must stay exactly equal to the map size under the edge cases the
547    // dispatcher actually hits: a re-leased task (same id inserted again) and the sink's mock-id
548    // (negative) results. A drift here would mis-fire backpressure or leak toward the hard bound.
549    let set = InFlightSet::new();
550    set.insert(dummy_progress(7));
551    set.insert(dummy_progress(7)); // same id → overwrite, not a second entry
552    assert_eq!(
553      set.len(),
554      1,
555      "re-inserting the same task id does not double-count"
556    );
557    assert!(
558      set.remove(-1).is_none(),
559      "negative (mock) ids are never tracked"
560    );
561    assert_eq!(set.len(), 1);
562    assert!(set.remove(7).is_some());
563    assert_eq!(set.len(), 0);
564    assert!(set.remove(7).is_none(), "removing a gone task is a no-op");
565    assert_eq!(
566      set.len(),
567      0,
568      "a redundant remove does not underflow the counter"
569    );
570  }
571
572  #[test]
573  fn inflight_handles_200_concurrent_tasks_with_consistent_count() {
574    // Deployment sizing is ~200 concurrent workers; the in-flight set must absorb 200 simultaneous
575    // leases from many threads (and then 200 concurrent drains) without losing entries or drifting
576    // its O(1) size counter — the property the sharded DashMap + AtomicUsize must guarantee in
577    // place of the old global Mutex. (Dispatcher rationalization phase 4.)
578    let set = Arc::new(InFlightSet::new());
579    let n: i64 = 200;
580    let leasers: Vec<_> = (1..=n)
581      .map(|id| {
582        let set = set.clone();
583        thread::spawn(move || set.insert(dummy_progress(id)))
584      })
585      .collect();
586    for h in leasers {
587      h.join().unwrap();
588    }
589    assert_eq!(
590      set.len(),
591      n as usize,
592      "all 200 concurrent leases tracked, counter consistent"
593    );
594    assert_eq!(
595      set.ids().len(),
596      n as usize,
597      "every id is present in the map"
598    );
599    let drainers: Vec<_> = (1..=n)
600      .map(|id| {
601        let set = set.clone();
602        thread::spawn(move || set.remove(id))
603      })
604      .collect();
605    let drained = drainers
606      .into_iter()
607      .filter_map(|h| h.join().unwrap())
608      .count();
609    assert_eq!(
610      drained, n as usize,
611      "each of the 200 tasks drained exactly once"
612    );
613    assert_eq!(
614      set.len(),
615      0,
616      "counter back to zero after a full concurrent drain"
617    );
618    assert!(set.is_empty());
619  }
620
621  fn expired_progress(id: i64, service_id: i32, retries: i64) -> TaskProgress {
622    let mut tp = dummy_progress(id);
623    tp.task.service_id = service_id;
624    tp.created_at = 0; // expected_at = (retries+1)*3600, far in the past -> always expired
625    tp.retries = retries;
626    tp
627  }
628
629  #[test]
630  fn classify_expired_retries_then_gives_up() {
631    // Budget remains -> requeue with the retry count incremented.
632    match classify_expired(expired_progress(1, 3, MAX_DISPATCH_RETRIES)) {
633      ExpiredOutcome::Requeue(tp) => assert_eq!(tp.retries, MAX_DISPATCH_RETRIES + 1),
634      ExpiredOutcome::Fatal(_) => panic!("still within retry budget -> should requeue"),
635    }
636    // Budget exhausted -> Fatal.
637    match classify_expired(expired_progress(2, 3, MAX_DISPATCH_RETRIES + 1)) {
638      ExpiredOutcome::Fatal(report) => assert_eq!(report.task.id, 2),
639      ExpiredOutcome::Requeue(_) => panic!("retry budget exhausted -> should be Fatal"),
640    }
641  }
642
643  #[test]
644  fn rate_limited_log_emits_first_then_throttles_then_summarizes() {
645    let mut log = RateLimitedLog::new(Duration::from_millis(40));
646    // The first event always emits, so a problem is visible immediately.
647    assert_eq!(log.record(), Some(1));
648    // Subsequent events within the interval are suppressed (counted, not narrated).
649    assert_eq!(log.record(), None);
650    assert_eq!(log.record(), None);
651    // After the interval, the next event emits a summary carrying the suppressed count
652    // (the two suppressed above + this one).
653    std::thread::sleep(Duration::from_millis(50));
654    assert_eq!(log.record(), Some(3));
655    // ...and the counter resets for the next window.
656    assert_eq!(log.record(), None);
657  }
658
659  #[test]
660  fn reap_routes_to_own_service_and_drains() {
661    let progress = InFlightSet::new();
662    progress.insert(expired_progress(1, 7, 0)); // retriable, service 7
663    progress.insert(expired_progress(2, 9, MAX_DISPATCH_RETRIES + 1)); // fatal, svc 9
664    let mut queues: HashMap<i32, Vec<TaskProgress>> = HashMap::new();
665    let (done_tx, done_rx) = std::sync::mpsc::sync_channel::<TaskReport>(10);
666
667    reap_expired_into(&mut queues, &progress, &done_tx);
668
669    // The retriable task is requeued to ITS OWN service (7), not some requester's queue, retry++.
670    assert_eq!(queues.get(&7).map(Vec::len), Some(1));
671    assert_eq!(queues[&7][0].retries, 1);
672    assert!(
673      !queues.contains_key(&9),
674      "the exhausted task is not requeued"
675    );
676    // The exhausted task is reported Fatal on the done channel.
677    let reports: Vec<TaskReport> = done_rx.try_iter().collect();
678    assert_eq!(reports.len(), 1);
679    assert_eq!(reports[0].task.id, 2);
680    // The in-flight set is fully drained.
681    assert_eq!(progress.len(), 0);
682  }
683}