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}