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 let mut backend = backend::from_address(&self.backend_address);
64 let in_flight_ids: Vec<i64> = progress_queue_arc.ids();
65 backend.clear_limbo_tasks_except(&in_flight_ids)?;
66 // Ok, let's bind to a port and start broadcasting
67 let context = zmq::Context::new();
68 let ventilator = context.socket(zmq::ROUTER)?;
69 ventilator.set_router_handover(true)?;
70 // Keep idle remote-worker connections alive across NAT/firewall idle-timeouts (set before bind
71 // so accepted connections inherit it). Correctness is the reaper's job, not keepalive's.
72 server::apply_tcp_keepalive(
73 &ventilator,
74 crate::config::config()
75 .dispatcher
76 .tcp_keepalive_idle_seconds,
77 )?;
78
79 let address = format!("tcp://*:{}", self.port);
80 // Propagate a bind failure (e.g. `EADDRINUSE` when the port is still held by a just-restarted
81 // dispatcher in TIME_WAIT, or a second instance) instead of an opaque `.unwrap()` panic — same
82 // fail-fast (the manager aborts the thread → external restart), but with a diagnosable cause
83 // and consistent with every other fallible call here + the sink's bind (KNOWN_ISSUES
84 // robustness: no bare `unwrap` on the dispatch path).
85 // Retry the bind: on a restart the port can sit in TCP TIME_WAIT held by the
86 // previous dispatcher's closed connections. That lasts ~`tcp_fin_timeout` (60s on
87 // Linux by default), NOT "a second or two" — so a short retry window crash-loops the
88 // new process on EADDRINUSE the whole time TIME_WAIT persists (observed 2026-07-01: a
89 // 7.5s / 15-attempt window kept failing on the sink port through the full 60s TIME_WAIT,
90 // systemd hit its start-limit and gave up). Size the window to comfortably OUTLAST
91 // TIME_WAIT (75s > 60s + margin) so a normal restart self-heals without operator action;
92 // a genuinely-occupied port still fails (with a diagnosable cause → manager aborts →
93 // external restart), just after the full window.
94 {
95 // 150 * 500ms = 75s > tcp_fin_timeout (60s) TIME_WAIT, with margin.
96 const BIND_ATTEMPTS: u32 = 150;
97 let mut attempt = 1u32;
98 loop {
99 match ventilator.bind(&address) {
100 Ok(()) => break,
101 Err(e) if attempt < BIND_ATTEMPTS => {
102 warn!(
103 "ventilator: bind {address} attempt {attempt}/{BIND_ATTEMPTS} failed ({e}); \
104 retrying in 500ms (port handover from a restarting dispatcher?)"
105 );
106 std::thread::sleep(Duration::from_millis(500));
107 attempt += 1;
108 },
109 Err(e) => {
110 return Err(
111 std::io::Error::other(format!(
112 "ventilator: zeromq bind {address} failed after {BIND_ATTEMPTS} attempts: {e}"
113 ))
114 .into(),
115 );
116 },
117 }
118 }
119 }
120 // Wake the blocking `recv` periodically instead of blocking forever when idle, so the
121 // graceful-shutdown flag (checked at the top of the loop) is observed promptly even with no
122 // worker traffic. Without this an idle ventilator blocks in `recv` until the supervisor's
123 // stop-timeout SIGKILL — the ~120s `systemctl restart` hang (KNOWN_ISSUES D-15). 250ms matches
124 // the sink's termination poll; the only cost is one no-op wakeup per quarter-second while idle.
125 ventilator.set_rcvtimeo(250)?;
126 let mut source_job_count: usize = 0;
127 // Count of *fresh* tasks actually leased to a worker (a `retries == 0` lease), as distinct from
128 // `source_job_count` which counts every request — including the mock-replies (unknown service,
129 // backpressure, momentary-empty-queue). A bounded run (`job_limit = Some(N)`) terminates on N
130 // **real dispatches**, not N requests: the unit mismatch — three threads counting `job_limit`
131 // in three incompatible units — was the KNOWN_ISSUES D-5 lockstep-termination hang.
132 let mut real_dispatched: usize = 0;
133 // Reap timed-out in-flight tasks on a cadence rather than only on refetch (KNOWN_ISSUES D-6),
134 // so the in-flight set drains even under sustained backpressure (when refetch never runs). The
135 // cadence is runtime-configurable (`dispatcher.reap_interval_seconds`, default 60s — well below
136 // the lease timeout, so an expired task is recovered promptly without scanning on every
137 // request).
138 let reap_interval_secs = crate::config::config().dispatcher.reap_interval_seconds;
139 let mut last_reap_sec = chrono::Utc::now().timestamp();
140 // Rate-limited logging for discarded requests (malformed framing, unknown service names). A
141 // sustained malformed-request flood must not turn per-message `stderr` writes into a
142 // throughput-DoS (KNOWN_ISSUES D-11) — count, don't narrate.
143 let mut discard_log = server::RateLimitedLog::new(Duration::from_secs(5));
144
145 // Input-archive prefetcher (D-20): warm each fetched batch's archives into the OS page cache
146 // ahead of dispatch, so the inline `/data` read below is served from RAM (~0.02 ms) instead of
147 // the cold QLC-RAID6 platter (~10 ms — the single-thread dispatch ceiling at full-arXiv scale).
148 // A no-op when `input_prefetchers = 0`; the read path stays byte-for-byte unchanged. Each
149 // warmer's channel is sized to a full batch so the round-robin feed never drops within one.
150 let dispatcher_cfg = &crate::config::config().dispatcher;
151 let prefetcher = Prefetcher::new(
152 dispatcher_cfg.input_prefetchers,
153 self.queue_size,
154 dispatcher_cfg.prefetch_max_entry_mb * 1024 * 1024,
155 dispatcher_cfg.prefetch_budget_mb * 1024 * 1024,
156 );
157
158 loop {
159 // Graceful shutdown (O-1): a SIGTERM/SIGINT set the flag. Stop leasing new work, signal
160 // completion (so the sink drains the in-flight set and finalize flushes its last batch), and
161 // return cleanly — the manager then takes the drain path instead of restarting us. Reached on
162 // the next loop iteration; the recv below polls on a 250ms `RCVTIMEO`, so even a *fully idle*
163 // dispatcher (no worker requests to cycle the loop) observes the flag within ~250ms and stops
164 // promptly — no waiting out the supervisor's stop timeout (KNOWN_ISSUES D-15).
165 if server::shutdown_requested() {
166 info!(
167 "ventilator: graceful shutdown requested — ceasing to lease; the sink will drain in-flight work"
168 );
169 dispatch_complete.store(true, Ordering::SeqCst);
170 return Ok(real_dispatched);
171 }
172 // Reap timed-out in-flight tasks on a **wall-clock** cadence, decoupled from worker request
173 // traffic (D-19). Runs at the top of every loop iteration — including the idle ~250ms
174 // RCVTIMEO ticks below — so an expired task is requeued/dead-lettered within
175 // ~`reap_interval` even at a fully idle tail (e.g. D-18-deferred empty results awaiting
176 // retry after the fleet drained), not just when a worker next happens to ask for work.
177 // The `reap_interval` gate keeps the actual scan to its cadence; the per-iteration
178 // timestamp compare is cheap. Routes each expired task back to its own service's queue
179 // or reports it Fatal, so the in-flight set drains even while saturated (backpressure)
180 // — closes the D-6 reaping-coupling residual.
181 let now_sec = chrono::Utc::now().timestamp();
182 if now_sec - last_reap_sec >= reap_interval_secs {
183 last_reap_sec = now_sec;
184 let reaped = server::reap_expired_into(&mut queues, progress_queue_arc, done_tx);
185 // Health signal (Arm 8 observability; transport-independent): the in-flight gauge plus the
186 // re-lease / dead-letter counts for this reaping pass. Only logged when something actually
187 // timed out (the cadence is otherwise quiet), at `info` because a dead-letter is a task we
188 // gave up on — an operator-relevant event.
189 if reaped.requeued + reaped.dead_lettered > 0 {
190 info!(
191 in_flight = progress_queue_arc.len(),
192 requeued = reaped.requeued,
193 dead_lettered = reaped.dead_lettered,
194 "dispatcher: reaped timed-out in-flight tasks"
195 );
196 }
197 }
198 let mut identity = zmq::Message::new();
199 let mut msg = zmq::Message::new();
200 // A worker request is exactly `[identity, service_name]` on the ROUTER: the DEALER worker
201 // sends a single service-name frame and ROUTER prepends its identity. Read with strict
202 // multipart-framing discipline so a malformed / empty / over-long request cannot desync the
203 // message boundary and *permanently shuffle* every later request — the rare "3 adjacent empty
204 // messages" failure seen in 08.2025 sandbox testing (KNOWN_ISSUES D-4). The previous code
205 // read a second frame unconditionally, so a truncated `[identity]`-only message made it
206 // read the *next* request's identity as this request's service (the shuffle), and
207 // bailed the whole ventilator on the both-empty case (a restart band-aid). Instead:
208 // require the service frame via `RCVMORE` before reading it (never read across a
209 // message boundary), drain any unexpected trailing frames to stay aligned, and *skip* a
210 // malformed request rather than restarting. This mirrors the sink's `[identity,
211 // service, taskid, …]` envelope hardening.
212 match ventilator.recv(&mut identity, 0) {
213 Ok(()) => {},
214 // RCVTIMEO elapsed with no request — loop back to re-check the shutdown flag and run the
215 // wall-clock reap at the top of the loop (D-15 + D-19: an idle ventilator both stops within
216 // ~250ms of SIGTERM and keeps reaping expired in-flight tasks).
217 Err(zmq::Error::EAGAIN) => continue,
218 Err(e) => return Err(e.into()),
219 }
220 if !ventilator.get_rcvmore().unwrap_or(false) {
221 // `[identity]` with no service frame — truncated. Skipping consumes nothing further, so the
222 // next request's frames are left intact (no desync).
223 if let Some(n) = discard_log.record() {
224 warn!(
225 "ventilator: discarded {n} malformed request(s) [latest: truncated, no service frame] (rate-limited)"
226 );
227 }
228 continue;
229 }
230 ventilator.recv(&mut msg, 0)?;
231 // A well-formed request ends at the service frame; drain anything beyond it (an over-long /
232 // malformed request) so it can't bleed into the next request — frame-alignment is exactly
233 // what D-4 lost.
234 while ventilator.get_rcvmore().unwrap_or(false) {
235 let mut extra = zmq::Message::new();
236 if ventilator.recv(&mut extra, 0).is_err() {
237 break;
238 }
239 }
240 let identity_str = identity.as_str().unwrap_or_default().to_string();
241 let service_name = msg.as_str().unwrap_or_default().to_string();
242 if service_name.is_empty() {
243 // Empty service request (e.g. the "3 adjacent empty messages") — skip without desyncing.
244 if let Some(n) = discard_log.record() {
245 warn!(
246 "ventilator: discarded {n} malformed request(s) [latest: empty service from {identity_str:?}] (rate-limited)"
247 );
248 }
249 continue;
250 }
251
252 let request_time = chrono::Utc::now();
253 source_job_count += 1;
254 // Whether this iteration actually leased a task to the worker (vs. a mock-reply). Drives the
255 // bounded-run source-drain check at the bottom of the loop.
256 let mut dispatched_this_iter = false;
257 // (Reaping happens at the loop top now, on a wall-clock cadence — see D-19 above.)
258 // Requests for unknown service names will be silently ignored.
259 let service_opt = match server::get_sync_service(&service_name, services_arc, &mut backend) {
260 Some(s) => Some(s),
261 None => {
262 // An unknown service name is now handled gracefully — mock-reply so the (mis)configured
263 // worker backs off — rather than the old fatal desync (the request framing is robust now,
264 // D-4). Rate-limit the log so a flood of bad-service requests can't DoS us (D-11).
265 if let Some(n) = discard_log.record() {
266 warn!(
267 "ventilator: discarded {n} request(s) [latest: unknown service {service_name:?} from {identity_str:?}, mock-replied] (rate-limited)"
268 );
269 }
270 ventilator.send(identity, SNDMORE)?;
271 ventilator.send("0", SNDMORE)?;
272 ventilator.send(Vec::new(), 0)?;
273 continue;
274 },
275 };
276 if let Some(service) = service_opt {
277 // Backpressure (KNOWN_ISSUES D-6, principle #4): if the in-flight set is saturated, don't
278 // lease more work — mock-reply so the worker backs off and retries. The set then drains as
279 // the sink receives results, instead of growing toward the hard panic bound. Degrade
280 // gracefully under overload rather than crash.
281 if server::in_flight_saturated(progress_queue_arc.len(), self.max_in_flight) {
282 debug!(
283 in_flight_cap = self.max_in_flight,
284 worker = %identity_str,
285 "BACKPRESSURE: in-flight set at capacity; mock-replying to worker"
286 );
287 ventilator.send(identity, SNDMORE)?;
288 ventilator.send("0", SNDMORE)?;
289 ventilator.send(Vec::new(), 0)?;
290 continue;
291 }
292 let task_queue: &mut Vec<TaskProgress> = queues.entry(service.id).or_default();
293 if task_queue.is_empty() {
294 debug!(
295 "No tasks in task queue for service {:?}, fetching up to {:?} more from backend...",
296 service_name, self.queue_size
297 );
298 // Refetch a new batch of tasks
299 let now = chrono::Utc::now().timestamp();
300 let fetched_tasks = backend
301 .fetch_tasks(&service, self.queue_size)
302 .unwrap_or_default();
303 // D-20: warm this batch's input archives into page cache in DISPATCH order. `task_queue`
304 // is popped LIFO (from the end after the `extend` below), so the batch is dispatched in
305 // reverse of fetch order — feed reversed so the next-to-dispatch archives warm first.
306 // No-op when prefetching is disabled.
307 prefetcher.warm_batch(fetched_tasks.iter().rev().map(|task| task.entry.clone()));
308 // D-17: capture the effective lease for THIS service — its `lease_timeout_seconds`
309 // override, else the global dispatcher default — at dispatch time. `expected_at` uses the
310 // captured value, so one dispatcher serves fast (latexml-oxide) and slow (Perl) services
311 // with the right lease each, and a later config change never re-times an in-flight task.
312 let lease = service
313 .lease_timeout_seconds
314 .map(i64::from)
315 .unwrap_or_else(|| crate::config::config().dispatcher.lease_timeout_seconds);
316 task_queue.extend(fetched_tasks.into_iter().map(|task| TaskProgress {
317 task,
318 created_at: now,
319 retries: 0,
320 lease_timeout_seconds: lease,
321 }));
322 }
323
324 ventilator.send(identity, SNDMORE)?;
325 if let Some(current_task_progress) = task_queue.pop() {
326 dispatched_this_iter = true;
327 // A `retries == 0` task is entering the pipeline for the first time; a re-leased task
328 // (retries > 0, from the reaper requeue) was already counted on its first dispatch, so
329 // only fresh leases advance the bounded-run target — keeping `real_dispatched` a true
330 // unique-task count that finalize can eventually match.
331 if current_task_progress.retries == 0 {
332 real_dispatched += 1;
333 }
334 // Record the dispatch in the in-flight set BEFORE streaming the payload to the worker. A
335 // fast worker (e.g. echo) can return its result to the sink before this iteration even
336 // finishes; if the task were recorded only *after* the send (as it was), the sink's
337 // `pop_progress_task` could miss it and discard the result, stranding the task `Queued`
338 // until the ≥1h visibility-timeout reaper — the single-task-loss race that surfaced under
339 // higher worker concurrency (KNOWN_ISSUES D-4 / docs/DISPATCHER_BENCH.md 8-worker loss).
340 // Recording first also leaves a mid-stream send failure correctly in-flight for the
341 // reaper.
342 progress_queue_arc.insert(current_task_progress.clone());
343
344 let current_task = current_task_progress.task;
345 let mut taskid = current_task.id;
346 let serviceid = current_task.service_id;
347 // Memoise this task's corpus → sandbox id now, before the payload is sent (so before the
348 // result can return), so the sink scopes the result archive without its own DB hit (F-6).
349 server::get_sync_sandbox_id(current_task.corpus_id, sandboxes_arc, &mut backend);
350 trace!(
351 job = source_job_count,
352 worker = %identity_str,
353 task_id = taskid,
354 "ventilator: worker received task"
355 );
356 ventilator.send(&taskid.to_string(), SNDMORE)?;
357 if serviceid == 1 {
358 // No payload needed for init
359 ventilator.send(Vec::new(), 0)?;
360 } else {
361 // Regular services fetch the task payload and transfer it to the worker
362 let file_opt = helpers::prepare_input_stream(¤t_task);
363 if file_opt.is_ok() {
364 let mut file = file_opt?;
365 let mut total_outgoing: usize = 0;
366 loop {
367 // Stream input data via zmq
368 let mut data = vec![0; self.message_size];
369 let size = file.read(&mut data)?;
370 total_outgoing += size;
371 data.truncate(size);
372
373 if size < self.message_size {
374 // If exhausted, send the last frame
375 ventilator.send(&data, 0)?;
376 // And terminate
377 break;
378 } else {
379 // If more to go, send the frame and indicate there's more to come
380 ventilator.send(&data, SNDMORE)?;
381 }
382 }
383 let responded_time = chrono::Utc::now();
384 let request_duration = (responded_time - request_time).num_milliseconds();
385 trace!(
386 job = source_job_count,
387 task_id = taskid,
388 bytes = total_outgoing,
389 took_ms = request_duration,
390 "ventilator: streamed task payload to worker"
391 );
392 } else {
393 warn!(
394 task_id = taskid,
395 "ventilator: failed to prepare input stream for task"
396 );
397 debug!(task_id = taskid, "task details: {current_task:?}");
398 taskid = -1;
399 ventilator.send(Vec::new(), 0)?;
400 }
401 }
402 // A real task was leased — count it as a dispatch (drives total_dispatched /
403 // outstanding).
404 self.metadata.dispatched(identity_str, service.id, taskid);
405 } else {
406 trace!(
407 job = source_job_count,
408 worker = %identity_str,
409 "ventilator: worker received mock reply"
410 );
411 ventilator.send("0", SNDMORE)?;
412 ventilator.send(Vec::new(), 0)?;
413 // An empty-queue ping leased no task: record a liveness heartbeat (refreshes the worker's
414 // "last dispatch requested" time so an idle-but-alive poller still reads as fresh) but do
415 // NOT bump the dispatch tally. Counting these is what made a fully-drained corpus's idle
416 // pollers accrue phantom "outstanding" forever — total_dispatched meant "replies sent",
417 // not "tasks leased" (the 17k-outstanding /workers confusion).
418 self.metadata.heartbeat(identity_str, service.id);
419 }
420 } else {
421 warn!(
422 service = %service_name,
423 worker = %identity_str,
424 "ventilator: request for unknown service"
425 );
426 }
427 if let Some(limit_number) = job_limit {
428 // Bounded run terminates on N *real* dispatches (not N requests). Publish the shared
429 // completion signal so the sink (which then drains the in-flight set) and finalize (which
430 // the manager disconnects) agree on "done" — one condition instead of the three
431 // incompatible per-thread counters that used to deadlock (KNOWN_ISSUES D-5).
432 if real_dispatched >= limit_number {
433 info!(
434 "vent: bounded job limit ({limit_number}) reached after {real_dispatched} real dispatch(es); terminating ventilator"
435 );
436 dispatch_complete.store(true, Ordering::Release);
437 return Ok(real_dispatched);
438 }
439 // Source-drain: this iteration leased nothing (the queue was empty after a refetch) and no
440 // task is still in flight, so there is genuinely no more work to dispatch — stop rather
441 // than mock-reply forever toward an unreachable limit (the owner-noted "empty-queue
442 // mock-replies forever" gap). The in-flight-empty guard prevents terminating while
443 // results are still pending; it is exact for the single-service bounded runs
444 // `job_limit` is used for (a multi-service bounded run could drain one service
445 // early — acceptable, benchmark-only).
446 if !dispatched_this_iter && progress_queue_arc.is_empty() {
447 info!(
448 "vent: source exhausted after {real_dispatched} dispatch(es) (< limit {limit_number}); terminating ventilator"
449 );
450 dispatch_complete.store(true, Ordering::Release);
451 return Ok(real_dispatched);
452 }
453 }
454 }
455 }
456}