Skip to main content

cortex/dispatcher/
ventilator.rs

1use std::collections::HashMap;
2use std::io::Read;
3use std::sync::Arc;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::mpsc::SyncSender;
6use std::time::Duration;
7
8use crate::backend;
9use crate::dispatcher::prefetch::Prefetcher;
10use crate::dispatcher::server;
11use crate::helpers;
12use crate::helpers::{TaskProgress, TaskReport};
13use crate::models::WorkerMetadataSender;
14use std::error::Error;
15use tracing::{debug, info, trace, warn};
16use zmq::SNDMORE;
17
18/// Specifies the binding and operation parameters for a ZMQ ventilator component
19pub struct Ventilator {
20  /// port to listen on
21  pub port: usize,
22  /// the size of the dispatch queue
23  /// (also the batch size for Task store queue requests)
24  pub queue_size: usize,
25  /// size of an individual message chunk sent via zeromq
26  /// (keep this small to avoid large RAM use, increase to reduce network bandwidth)
27  pub message_size: usize,
28  /// address for the Task store postgres endpoint
29  pub backend_address: String,
30  /// backpressure threshold: stop leasing new work once this many tasks are in-flight
31  /// (dispatched-but-unfinished), so the in-flight set drains via the sink instead of growing
32  /// toward the hard panic bound (KNOWN_ISSUES D-6)
33  pub max_in_flight: usize,
34  /// non-blocking handle to the background worker-metadata writer
35  pub metadata: WorkerMetadataSender,
36}
37
38impl Ventilator {
39  /// Starts a new dispatch `Server` (ZMQ Ventilator), to serve tasks to processing workers.
40  /// The ventilator shares state with other manager threads via queues for tasks in progress,
41  /// as well as a queue for completed tasks pending persisting to disk.
42  /// A job limit can be provided as a termination condition for the sink server.
43  ///
44  /// Upon premature termination, returns the number of tasks processed.
45  pub fn start(
46    &self,
47    services_arc: &Arc<server::ServiceCache>,
48    sandboxes_arc: &Arc<server::SandboxCache>,
49    progress_queue_arc: &Arc<server::InFlightSet>,
50    done_tx: &SyncSender<TaskReport>,
51    job_limit: Option<usize>,
52    dispatch_complete: &Arc<AtomicBool>,
53  ) -> Result<usize, Box<dyn Error>> {
54    // We have a Ventilator-exclusive "queues" stack of tasks to be dispatched, keyed by service id
55    // so a reaped task is always re-queued to its own service (not whichever service is
56    // requesting).
57    let mut queues: HashMap<i32, Vec<TaskProgress>> = HashMap::new();
58    // Recover leftover Queued tasks from a previously-crashed run — but NOT the ones currently in
59    // flight. On a ventilator *restart* (KNOWN_ISSUES D-4) the sink is still processing dispatched
60    // tasks held in `progress_queue`; resetting those to TODO would re-lease them while their
61    // original results are still pending (a double-dispatch). On first start `progress_queue` is
62    // empty, so this recovers all leftover Queued tasks exactly as before.
63    // The retries the reaper had queued in the lost queue are queued again too (not reset): a
64    // reset would make them fresh fetches — a new run — and drop their earlier attempts' late
65    // results (D-26/D-28).
66    let mut backend = backend::from_address(&self.backend_address);
67    // So are the other held tasks: their report (a late result, a dead-letter) may not be written
68    // yet, and a reset would make finalize drop it (its row no longer at the report's mark).
69    let queued_retries = progress_queue_arc.queued_retries();
70    backend.clear_limbo_tasks_except(&progress_queue_arc.kept_on_restart())?;
71    for progress in queued_retries {
72      queues
73        .entry(progress.task.service_id)
74        .or_default()
75        .push(progress);
76    }
77    // Ok, let's bind to a port and start broadcasting
78    let context = zmq::Context::new();
79    let ventilator = context.socket(zmq::ROUTER)?;
80    ventilator.set_router_handover(true)?;
81    // Fail LOUD, not silent, when a reply can't be routed (KNOWN_ISSUES D-22). A ZMQ `ROUTER`
82    // *silently discards* a message addressed to an identity it can't route unless
83    // `ZMQ_ROUTER_MANDATORY` is set — so a worker that vanished between its request and our reply
84    // used to swallow the whole dispatch with no error, stranding the (already-leased) task until
85    // the lease reaper reclaimed it 240s+ later, with zero observability (exactly what
86    // DESIGN_PRINCIPLES forbids). With mandatory set, an unroutable send returns `EHOSTUNREACH`
87    // instead, which every routing-frame send below catches → logs, counts, and skips. Note the
88    // libzmq contract this relies on (verified empirically 2026-07-20): only the *routing*
89    // (first) frame reports `EHOSTUNREACH`; a rejected routing frame is NOT started as a
90    // multipart, so skipping the reply's remaining frames leaves the socket cleanly at
91    // message-start (no desync). Body frames after a *successful* routing frame never surface it
92    // (a peer dying mid-stream drops silently, and the task is already recorded in-flight for the
93    // reaper — see the ordering note further down). The reverted first attempt (D-22 history)
94    // crashed not from desync but from letting `EHOSTUNREACH` propagate via `?` on the *unguarded*
95    // mock-reply routing sends; guarding all three sites below is the fix.
96    ventilator.set_router_mandatory(true)?;
97    // Keep idle remote-worker connections alive across NAT/firewall idle-timeouts (set before bind
98    // so accepted connections inherit it). Correctness is the reaper's job, not keepalive's.
99    server::apply_tcp_keepalive(
100      &ventilator,
101      crate::config::config()
102        .dispatcher
103        .tcp_keepalive_idle_seconds,
104    )?;
105
106    let address = format!("tcp://*:{}", self.port);
107    // Propagate a bind failure (e.g. `EADDRINUSE` when the port is still held by a just-restarted
108    // dispatcher in TIME_WAIT, or a second instance) instead of an opaque `.unwrap()` panic — same
109    // fail-fast (the manager aborts the thread → external restart), but with a diagnosable cause
110    // and consistent with every other fallible call here + the sink's bind (KNOWN_ISSUES
111    // robustness: no bare `unwrap` on the dispatch path).
112    // Retry the bind: on a restart the port can sit in TCP TIME_WAIT held by the
113    // previous dispatcher's closed connections. That lasts ~`tcp_fin_timeout` (60s on
114    // Linux by default), NOT "a second or two" — so a short retry window crash-loops the
115    // new process on EADDRINUSE the whole time TIME_WAIT persists (observed 2026-07-01: a
116    // 7.5s / 15-attempt window kept failing on the sink port through the full 60s TIME_WAIT,
117    // systemd hit its start-limit and gave up). Size the window to comfortably OUTLAST
118    // TIME_WAIT (75s > 60s + margin) so a normal restart self-heals without operator action;
119    // a genuinely-occupied port still fails (with a diagnosable cause → manager aborts →
120    // external restart), just after the full window.
121    {
122      // 150 * 500ms = 75s > tcp_fin_timeout (60s) TIME_WAIT, with margin.
123      const BIND_ATTEMPTS: u32 = 150;
124      let mut attempt = 1u32;
125      loop {
126        match ventilator.bind(&address) {
127          Ok(()) => break,
128          Err(e) if attempt < BIND_ATTEMPTS => {
129            warn!(
130              "ventilator: bind {address} attempt {attempt}/{BIND_ATTEMPTS} failed ({e}); \
131               retrying in 500ms (port handover from a restarting dispatcher?)"
132            );
133            std::thread::sleep(Duration::from_millis(500));
134            attempt += 1;
135          },
136          Err(e) => {
137            return Err(
138              std::io::Error::other(format!(
139                "ventilator: zeromq bind {address} failed after {BIND_ATTEMPTS} attempts: {e}"
140              ))
141              .into(),
142            );
143          },
144        }
145      }
146    }
147    // Wake the blocking `recv` periodically instead of blocking forever when idle, so the
148    // graceful-shutdown flag (checked at the top of the loop) is observed promptly even with no
149    // worker traffic. Without this an idle ventilator blocks in `recv` until the supervisor's
150    // stop-timeout SIGKILL — the ~120s `systemctl restart` hang (KNOWN_ISSUES D-15). 250ms matches
151    // the sink's termination poll; the only cost is one no-op wakeup per quarter-second while idle.
152    ventilator.set_rcvtimeo(250)?;
153    let mut source_job_count: usize = 0;
154    // Count of *fresh* tasks actually leased to a worker (a `retries == 0` lease), as distinct from
155    // `source_job_count` which counts every request — including the mock-replies (unknown service,
156    // backpressure, momentary-empty-queue). A bounded run (`job_limit = Some(N)`) terminates on N
157    // **real dispatches**, not N requests: the unit mismatch — three threads counting `job_limit`
158    // in three incompatible units — was the KNOWN_ISSUES D-5 lockstep-termination hang.
159    let mut real_dispatched: usize = 0;
160    // Reap timed-out in-flight tasks on a cadence rather than only on refetch (KNOWN_ISSUES D-6),
161    // so the in-flight set drains even under sustained backpressure (when refetch never runs). The
162    // cadence is runtime-configurable (`dispatcher.reap_interval_seconds`, default 60s — well below
163    // the lease timeout, so an expired task is recovered promptly without scanning on every
164    // request).
165    let reap_interval_secs = crate::config::config().dispatcher.reap_interval_seconds;
166    let mut last_reap_sec = chrono::Utc::now().timestamp();
167    // Rate-limited logging for discarded requests (malformed framing, unknown service names). A
168    // sustained malformed-request flood must not turn per-message `stderr` writes into a
169    // throughput-DoS (KNOWN_ISSUES D-11) — count, don't narrate.
170    let mut discard_log = server::RateLimitedLog::new(Duration::from_secs(5));
171    // Rate-limited logging for dispatches/replies dropped because the worker was unroutable at send
172    // time (D-22). A dying-worker or restart storm can make this fire per-request, so aggregate it
173    // (like `discard_log`) — count, don't narrate — so it can never become a throughput-DoS (D-11).
174    let mut unroutable_log = server::RateLimitedLog::new(Duration::from_secs(5));
175
176    // Input-archive prefetcher (D-20): warm each fetched batch's archives into the OS page cache
177    // ahead of dispatch, so the inline `/data` read below is served from RAM (~0.02 ms) instead of
178    // the cold QLC-RAID6 platter (~10 ms — the single-thread dispatch ceiling at full-arXiv scale).
179    // A no-op when `input_prefetchers = 0`; the read path stays byte-for-byte unchanged. Each
180    // warmer's channel is sized to a full batch so the round-robin feed never drops within one.
181    let dispatcher_cfg = &crate::config::config().dispatcher;
182    let prefetcher = Prefetcher::new(
183      dispatcher_cfg.input_prefetchers,
184      self.queue_size,
185      dispatcher_cfg.prefetch_max_entry_mb * 1024 * 1024,
186      dispatcher_cfg.prefetch_budget_mb * 1024 * 1024,
187    );
188
189    loop {
190      // Graceful shutdown (O-1): a SIGTERM/SIGINT set the flag. Stop leasing new work, signal
191      // completion (so the sink drains the in-flight set and finalize flushes its last batch), and
192      // return cleanly — the manager then takes the drain path instead of restarting us. Reached on
193      // the next loop iteration; the recv below polls on a 250ms `RCVTIMEO`, so even a *fully idle*
194      // dispatcher (no worker requests to cycle the loop) observes the flag within ~250ms and stops
195      // promptly — no waiting out the supervisor's stop timeout (KNOWN_ISSUES D-15).
196      if server::shutdown_requested() {
197        info!(
198          "ventilator: graceful shutdown requested — ceasing to lease; the sink will drain in-flight work"
199        );
200        dispatch_complete.store(true, Ordering::SeqCst);
201        return Ok(real_dispatched);
202      }
203      // Reap timed-out in-flight tasks on a **wall-clock** cadence, decoupled from worker request
204      // traffic (D-19). Runs at the top of every loop iteration — including the idle ~250ms
205      // RCVTIMEO ticks below — so an expired task is requeued/dead-lettered within
206      // ~`reap_interval` even at a fully idle tail (e.g. D-18-deferred empty results awaiting
207      // retry after the fleet drained), not just when a worker next happens to ask for work.
208      // The `reap_interval` gate keeps the actual scan to its cadence; the per-iteration
209      // timestamp compare is cheap. Routes each expired task back to its own service's queue
210      // or reports it Fatal, so the in-flight set drains even while saturated (backpressure)
211      // — closes the D-6 reaping-coupling residual.
212      let now_sec = chrono::Utc::now().timestamp();
213      if now_sec - last_reap_sec >= reap_interval_secs {
214        last_reap_sec = now_sec;
215        let reaped = server::reap_expired_into(&mut queues, progress_queue_arc, done_tx);
216        // Health signal (Arm 8 observability; transport-independent): the in-flight gauge plus the
217        // re-lease / dead-letter counts for this reaping pass. Only logged when something actually
218        // timed out (the cadence is otherwise quiet), at `info` because a dead-letter is a task we
219        // gave up on — an operator-relevant event.
220        if reaped.requeued + reaped.dead_lettered > 0 {
221          info!(
222            in_flight = progress_queue_arc.len(),
223            requeued = reaped.requeued,
224            dead_lettered = reaped.dead_lettered,
225            awaiting_late_results = progress_queue_arc.settling_len(),
226            "dispatcher: reaped timed-out in-flight tasks"
227          );
228        }
229      }
230      let mut identity = zmq::Message::new();
231      let mut msg = zmq::Message::new();
232      // A worker request is exactly `[identity, service_name]` on the ROUTER: the DEALER worker
233      // sends a single service-name frame and ROUTER prepends its identity. Read with strict
234      // multipart-framing discipline so a malformed / empty / over-long request cannot desync the
235      // message boundary and *permanently shuffle* every later request — the rare "3 adjacent empty
236      // messages" failure seen in 08.2025 sandbox testing (KNOWN_ISSUES D-4). The previous code
237      // read a second frame unconditionally, so a truncated `[identity]`-only message made it
238      // read the *next* request's identity as this request's service (the shuffle), and
239      // bailed the whole ventilator on the both-empty case (a restart band-aid). Instead:
240      // require the service frame via `RCVMORE` before reading it (never read across a
241      // message boundary), drain any unexpected trailing frames to stay aligned, and *skip* a
242      // malformed request rather than restarting. This mirrors the sink's `[identity,
243      // service, taskid, …]` envelope hardening.
244      match ventilator.recv(&mut identity, 0) {
245        Ok(()) => {},
246        // RCVTIMEO elapsed with no request — loop back to re-check the shutdown flag and run the
247        // wall-clock reap at the top of the loop (D-15 + D-19: an idle ventilator both stops within
248        // ~250ms of SIGTERM and keeps reaping expired in-flight tasks).
249        Err(zmq::Error::EAGAIN) => continue,
250        Err(e) => return Err(e.into()),
251      }
252      if !ventilator.get_rcvmore().unwrap_or(false) {
253        // `[identity]` with no service frame — truncated. Skipping consumes nothing further, so the
254        // next request's frames are left intact (no desync).
255        if let Some(n) = discard_log.record() {
256          warn!(
257            "ventilator: discarded {n} malformed request(s) [latest: truncated, no service frame] (rate-limited)"
258          );
259        }
260        continue;
261      }
262      ventilator.recv(&mut msg, 0)?;
263      // A well-formed request ends at the service frame; drain anything beyond it (an over-long /
264      // malformed request) so it can't bleed into the next request — frame-alignment is exactly
265      // what D-4 lost.
266      while ventilator.get_rcvmore().unwrap_or(false) {
267        let mut extra = zmq::Message::new();
268        if ventilator.recv(&mut extra, 0).is_err() {
269          break;
270        }
271      }
272      let identity_str = identity.as_str().unwrap_or_default().to_string();
273      let service_name = msg.as_str().unwrap_or_default().to_string();
274      if service_name.is_empty() {
275        // Empty service request (e.g. the "3 adjacent empty messages") — skip without desyncing.
276        if let Some(n) = discard_log.record() {
277          warn!(
278            "ventilator: discarded {n} malformed request(s) [latest: empty service from {identity_str:?}] (rate-limited)"
279          );
280        }
281        continue;
282      }
283
284      let request_time = chrono::Utc::now();
285      source_job_count += 1;
286      // Whether this iteration actually leased a task to the worker (vs. a mock-reply). Drives the
287      // bounded-run source-drain check at the bottom of the loop.
288      let mut dispatched_this_iter = false;
289      // (Reaping happens at the loop top now, on a wall-clock cadence — see D-19 above.)
290      // Requests for unknown service names will be silently ignored.
291      let service_opt = match server::get_sync_service(&service_name, services_arc, &mut backend) {
292        Some(s) => Some(s),
293        None => {
294          // An unknown service name is now handled gracefully — mock-reply so the (mis)configured
295          // worker backs off — rather than the old fatal desync (the request framing is robust now,
296          // D-4). Rate-limit the log so a flood of bad-service requests can't DoS us (D-11).
297          if let Some(n) = discard_log.record() {
298            warn!(
299              "ventilator: discarded {n} request(s) [latest: unknown service {service_name:?} from {identity_str:?}, mock-replied] (rate-limited)"
300            );
301          }
302          if !route_worker_frame(&ventilator, identity, &identity_str, &mut unroutable_log)? {
303            continue;
304          }
305          ventilator.send("0", SNDMORE)?;
306          ventilator.send(Vec::new(), 0)?;
307          continue;
308        },
309      };
310      if let Some(service) = service_opt {
311        // Backpressure (KNOWN_ISSUES D-6, principle #4): if the in-flight set is saturated, don't
312        // lease more work — mock-reply so the worker backs off and retries. The set then drains as
313        // the sink receives results, instead of growing toward the hard panic bound. Degrade
314        // gracefully under overload rather than crash.
315        if server::in_flight_saturated(progress_queue_arc.len(), self.max_in_flight) {
316          debug!(
317            in_flight_cap = self.max_in_flight,
318            worker = %identity_str,
319            "BACKPRESSURE: in-flight set at capacity; mock-replying to worker"
320          );
321          if !route_worker_frame(&ventilator, identity, &identity_str, &mut unroutable_log)? {
322            continue;
323          }
324          ventilator.send("0", SNDMORE)?;
325          ventilator.send(Vec::new(), 0)?;
326          continue;
327        }
328        let task_queue: &mut Vec<TaskProgress> = queues.entry(service.id).or_default();
329        if task_queue.is_empty() {
330          debug!(
331            "No tasks in task queue for service {:?}, fetching up to {:?} more from backend...",
332            service_name, self.queue_size
333          );
334          // Refetch a new batch of tasks
335          let now = chrono::Utc::now().timestamp();
336          let fetched_tasks = backend
337            .fetch_tasks(&service, self.queue_size)
338            .unwrap_or_default();
339          // D-20: warm this batch's input archives into page cache in DISPATCH order. `task_queue`
340          // is popped LIFO (from the end after the `extend` below), so the batch is dispatched in
341          // reverse of fetch order — feed reversed so the next-to-dispatch archives warm first.
342          // No-op when prefetching is disabled.
343          prefetcher.warm_batch(fetched_tasks.iter().rev().map(|task| task.entry.clone()));
344          // D-17: capture the effective lease for THIS service — its `lease_timeout_seconds`
345          // override, else the global dispatcher default — at dispatch time. `expected_at` uses the
346          // captured value, so one dispatcher serves fast (latexml-oxide) and slow (Perl) services
347          // with the right lease each, and a later config change never re-times an in-flight task.
348          let lease = service
349            .lease_timeout_seconds
350            .map(i64::from)
351            .unwrap_or_else(|| crate::config::config().dispatcher.lease_timeout_seconds);
352          task_queue.extend(fetched_tasks.into_iter().map(|task| TaskProgress {
353            run_start: 0,
354            last_cause: Vec::new(),
355            task,
356            // Re-stamped when the task is leased (D-27): the lease clock starts at dispatch.
357            created_at: now,
358            retries: 0,
359            lease_timeout_seconds: lease,
360            attempt_workers: Vec::new(),
361          }));
362        }
363
364        // Routing frame under ROUTER_MANDATORY, sent BEFORE the lease (the `pop()` below): if the
365        // worker vanished between its request and now, `EHOSTUNREACH` is caught here having leased
366        // nothing, so the task stays queued for the next worker — no strand, no reaper wait (D-22).
367        if !route_worker_frame(&ventilator, identity, &identity_str, &mut unroutable_log)? {
368          continue;
369        }
370        if let Some(current_task_progress) =
371          lease_next(task_queue, progress_queue_arc, &identity_str)
372        {
373          dispatched_this_iter = true;
374          // A `retries == 0` task is entering the pipeline for the first time; a re-leased task
375          // (retries > 0, from the reaper requeue) was already counted on its first dispatch, so
376          // only fresh leases advance the bounded-run target — keeping `real_dispatched` a true
377          // unique-task count that finalize can eventually match.
378          if current_task_progress.retries == 0 {
379            real_dispatched += 1;
380          }
381          // Record the dispatch in the in-flight set BEFORE streaming the payload to the worker. A
382          // fast worker (e.g. echo) can return its result to the sink before this iteration even
383          // finishes; if the task were recorded only *after* the send (as it was), the sink's
384          // `pop_progress_task` could miss it and discard the result, stranding the task `Queued`
385          // until the ≥1h visibility-timeout reaper — the single-task-loss race that surfaced under
386          // NOTE (D-21, 2026-07-20): two caveats on the citation above. (1) DISPATCHER_BENCH.md
387          // attributes that 8-worker loss to **D-10**, not D-4 — this reference is stale. (2) The
388          // bench harness that produced it gave all N worker threads ONE shared ZMQ identity
389          // (D-21), which was never controlled for. The ordering fix below is not in doubt — it
390          // held for 18 consecutive clean runs at the previously-failing concurrencies — but the
391          // two defects coexisted, so read that bench number as D-10 plus an uncontrolled D-21
392          // component, not as clean proof of this race alone.
393          // higher worker concurrency (KNOWN_ISSUES D-4 / docs/DISPATCHER_BENCH.md 8-worker loss).
394          // Recording first also leaves a mid-stream send failure correctly in-flight for the
395          // reaper. (`lease_next` recorded it.)
396
397          let current_task = current_task_progress.task;
398          let mut taskid = current_task.id;
399          let serviceid = current_task.service_id;
400          // Memoise this task's corpus → sandbox id now, before the payload is sent (so before the
401          // result can return), so the sink scopes the result archive without its own DB hit (F-6).
402          server::get_sync_sandbox_id(current_task.corpus_id, sandboxes_arc, &mut backend);
403          trace!(
404            job = source_job_count,
405            worker = %identity_str,
406            task_id = taskid,
407            "ventilator: worker received task"
408          );
409          ventilator.send(&taskid.to_string(), SNDMORE)?;
410          if serviceid == 1 {
411            // No payload needed for init
412            ventilator.send(Vec::new(), 0)?;
413          } else {
414            // Regular services fetch the task payload and transfer it to the worker
415            let file_opt = helpers::prepare_input_stream(&current_task);
416            if file_opt.is_ok() {
417              let mut file = file_opt?;
418              let mut total_outgoing: usize = 0;
419              loop {
420                // Stream input data via zmq
421                let mut data = vec![0; self.message_size];
422                let size = file.read(&mut data)?;
423                total_outgoing += size;
424                data.truncate(size);
425
426                if size < self.message_size {
427                  // If exhausted, send the last frame
428                  ventilator.send(&data, 0)?;
429                  // And terminate
430                  break;
431                } else {
432                  // If more to go, send the frame and indicate there's more to come
433                  ventilator.send(&data, SNDMORE)?;
434                }
435              }
436              let responded_time = chrono::Utc::now();
437              let request_duration = (responded_time - request_time).num_milliseconds();
438              trace!(
439                job = source_job_count,
440                task_id = taskid,
441                bytes = total_outgoing,
442                took_ms = request_duration,
443                "ventilator: streamed task payload to worker"
444              );
445            } else {
446              warn!(
447                task_id = taskid,
448                "ventilator: failed to prepare input stream for task"
449              );
450              debug!(task_id = taskid, "task details: {current_task:?}");
451              taskid = -1;
452              ventilator.send(Vec::new(), 0)?;
453            }
454          }
455          // A real task was leased — count it as a dispatch (drives total_dispatched /
456          // outstanding).
457          self.metadata.dispatched(identity_str, service.id, taskid);
458        } else {
459          trace!(
460            job = source_job_count,
461            worker = %identity_str,
462            "ventilator: worker received mock reply"
463          );
464          ventilator.send("0", SNDMORE)?;
465          ventilator.send(Vec::new(), 0)?;
466          // An empty-queue ping leased no task: record a liveness heartbeat (refreshes the worker's
467          // "last dispatch requested" time so an idle-but-alive poller still reads as fresh) but do
468          // NOT bump the dispatch tally. Counting these is what made a fully-drained corpus's idle
469          // pollers accrue phantom "outstanding" forever — total_dispatched meant "replies sent",
470          // not "tasks leased" (the 17k-outstanding /workers confusion).
471          self.metadata.heartbeat(identity_str, service.id);
472        }
473      } else {
474        warn!(
475          service = %service_name,
476          worker = %identity_str,
477          "ventilator: request for unknown service"
478        );
479      }
480      if let Some(limit_number) = job_limit {
481        // Bounded run terminates on N *real* dispatches (not N requests). Publish the shared
482        // completion signal so the sink (which then drains the in-flight set) and finalize (which
483        // the manager disconnects) agree on "done" — one condition instead of the three
484        // incompatible per-thread counters that used to deadlock (KNOWN_ISSUES D-5).
485        if real_dispatched >= limit_number {
486          info!(
487            "vent: bounded job limit ({limit_number}) reached after {real_dispatched} real dispatch(es); terminating ventilator"
488          );
489          dispatch_complete.store(true, Ordering::Release);
490          return Ok(real_dispatched);
491        }
492        // Source-drain: this iteration leased nothing (the queue was empty after a refetch) and no
493        // task is still in flight, so there is genuinely no more work to dispatch — stop rather
494        // than mock-reply forever toward an unreachable limit (the owner-noted "empty-queue
495        // mock-replies forever" gap). The in-flight-empty guard prevents terminating while
496        // results are still pending; it is exact for the single-service bounded runs
497        // `job_limit` is used for (a multi-service bounded run could drain one service
498        // early — acceptable, benchmark-only).
499        if !dispatched_this_iter && progress_queue_arc.is_empty() {
500          info!(
501            "vent: source exhausted after {real_dispatched} dispatch(es) (< limit {limit_number}); terminating ventilator"
502          );
503          dispatch_complete.store(true, Ordering::Release);
504          return Ok(real_dispatched);
505        }
506      }
507    }
508  }
509}
510
511/// Lease the next task of `queue` to `worker`: stamp the attempt's dispatch time (the lease clock
512/// starts now — KNOWN_ISSUES D-27), record the worker as this attempt's (D-26), and record it in
513/// flight. A re-queued task whose late result already finished it is dropped instead (it is not
514/// converted twice); the next one is tried. `None` once the queue is empty.
515pub fn lease_next(
516  queue: &mut Vec<TaskProgress>,
517  in_flight: &server::InFlightSet,
518  worker: &str,
519) -> Option<TaskProgress> {
520  // A rerun's first attempt is not handed to a worker that worked an earlier run of the task:
521  // its late result would be taken for the new run's (`attempt_of` matches the newest attempt).
522  let mut passed_over = Vec::new();
523  let leased = loop {
524    let Some(mut progress) = queue.pop() else {
525      break None;
526    };
527    if progress.retries == 0 && in_flight.worked_earlier_run(progress.task.id, worker) {
528      passed_over.push(progress);
529      continue;
530    }
531    progress.created_at = chrono::Utc::now().timestamp();
532    progress.attempt_workers.push(worker.to_string());
533    if in_flight.lease(progress.clone()) {
534      break Some(progress);
535    }
536    info!(
537      task_id = progress.task.id,
538      attempt = progress.retries,
539      "ventilator: a late result already finished this re-queued task; not re-leasing it (D-26)"
540    );
541  };
542  queue.extend(passed_over);
543  leased
544}
545
546/// Send the ROUTER routing (identity) frame for a worker reply under `ZMQ_ROUTER_MANDATORY`
547/// (KNOWN_ISSUES D-22). Returns `Ok(true)` when the worker routed and the caller may stream the
548/// rest of the reply; `Ok(false)` when the worker was unroutable (`EHOSTUNREACH`) — the caller MUST
549/// emit nothing further for this reply and `continue`. A rejected routing frame is never *started*
550/// as a multipart (verified empirically against libzmq 4.3.5), so skipping the reply's remaining
551/// frames leaves the socket cleanly aligned at message-start — no desync. The drop is counted and
552/// rate-limit-logged here so it is never silent (the D-22 gap DESIGN_PRINCIPLES forbids) and never
553/// a per-request log-DoS (D-11). Any *other* send error is a genuine fault and propagates
554/// (fail-fast → manager aborts the thread → external restart), consistent with the rest of the
555/// dispatch loop.
556fn route_worker_frame(
557  sock: &zmq::Socket,
558  identity: zmq::Message,
559  identity_str: &str,
560  unroutable_log: &mut server::RateLimitedLog,
561) -> Result<bool, zmq::Error> {
562  match sock.send(identity, SNDMORE) {
563    Ok(()) => Ok(true),
564    Err(zmq::Error::EHOSTUNREACH) => {
565      if let Some(n) = unroutable_log.record() {
566        warn!(
567          "ventilator: {n} reply/dispatch(es) dropped — worker unroutable at send time \
568           (EHOSTUNREACH) [latest: {identity_str:?}]; no work lost, task stays queued (rate-limited)"
569        );
570      }
571      Ok(false)
572    },
573    Err(e) => Err(e),
574  }
575}