cortex/dispatcher/sink.rs
1use std::error::Error;
2use std::fs::File;
3use std::io;
4use std::io::Write;
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
9use std::thread::{self, JoinHandle};
10use std::time::Duration;
11
12use tracing::{info, trace, warn};
13use zeromq::{PullSocket, Socket, SocketRecv, ZmqMessage};
14
15use crate::config::config;
16use crate::dispatcher::server;
17use crate::helpers;
18use crate::helpers::{NewTaskMessage, TaskReport, TaskStatus};
19use crate::models::{Task, WorkerMetadataSender};
20
21/// Capacity of each archive-writer's command channel. A small bound keeps resident memory at
22/// O(chunk) per writer (a handful of `message_size` chunks at most) while letting a writer stay a
23/// little ahead of the receive loop; a full channel simply makes the receive loop wait, which is
24/// the correct backpressure when the disk cannot keep up.
25const SINK_WRITER_CHANNEL_CAPACITY: usize = 64;
26
27/// How often the sink wakes from `recv()` to re-check the shared completion signal, so it can
28/// terminate once the ventilator has finished dispatching and the in-flight set has drained. The
29/// ventilator sets that signal (`dispatch_complete`) both when a **bounded** run hits its limit
30/// (KNOWN_ISSUES D-5) and on a **graceful SIGTERM/SIGINT** (O-1) — so **every** run, production
31/// included, polls on this cadence. A production sink that instead blocked plainly in `recv()` with
32/// an idle fleet never noticed the shutdown and hung the supervisor's stop the full timeout →
33/// SIGKILL on every restart/deploy (KNOWN_ISSUES D-16). `recv()` is cancel-safe — its `FairQueue`
34/// buffers messages behind a `Mutex` and the `recv` future holds no message — so a timed-out poll
35/// loses nothing.
36const SINK_TERMINATION_POLL: Duration = Duration::from_millis(250);
37
38/// A unit of work streamed from the sink's receive loop to an archive-writer thread (dispatcher
39/// rationalization phase 3 — D-7 sink fan-out). The receive loop owns the ZMQ socket and reads each
40/// result's frames; rather than block on the `/data` write itself, it hands a writer the task, then
41/// the received chunks, then a commit/reject — so receiving the next result is no longer hostage to
42/// the current one's disk latency.
43enum WriteCommand {
44 /// Begin a new result file for `task` at `recv_path`.
45 Begin {
46 /// the task this result is for, as the sink found it (the writer claims it on commit)
47 task: Box<Task>,
48 /// the identity of the worker that sent the result (its dispatch attempt, D-26)
49 worker: String,
50 /// `<entry-dir>/<service>.zip` — where the result archive is published
51 recv_path: PathBuf,
52 },
53 /// Append a received data chunk to the in-progress result file. The bytes are owned/moved here
54 /// and dropped right after the write, so the per-job footprint stays O(chunk), never the whole
55 /// archive.
56 Chunk(Vec<u8>),
57 /// Close the in-progress file, parse its `cortex.log` into a report, claim it for its task,
58 /// publish it, and hand the report to finalize.
59 Commit,
60 /// Reject the in-progress (over-cap) result: remove the partial file and, if it claims its task,
61 /// report the task `Invalid` (`result_too_large`).
62 Reject {
63 /// the configured cap, for the rejection message
64 max_result_bytes: usize,
65 },
66}
67
68/// One result being written: the task it is for (as the sink found it), the worker that sent it,
69/// where it goes, the temporary file it is written to first, and that file (`None` once a create
70/// or write failed).
71struct InProgress {
72 task: Task,
73 worker: String,
74 recv_path: PathBuf,
75 part_path: PathBuf,
76 file: Option<File>,
77}
78
79/// The temporary path a result is written to before it is validated and claimed: beside its final
80/// `recv_path`, so the rename that publishes it stays on one filesystem.
81fn part_path(recv_path: &std::path::Path) -> PathBuf {
82 let mut name = recv_path.file_name().unwrap_or_default().to_os_string();
83 name.push(".part");
84 recv_path.with_file_name(name)
85}
86
87/// The result a claimed result replaced (its attempt and status), if any.
88type Replaced = Option<(usize, TaskStatus)>;
89
90/// Claim a validated result with status `status` for its task (KNOWN_ISSUES D-26):
91/// `Some((task, replaced))` when it is the result that stands — the caller publishes and reports
92/// it — and `None` when it must be dropped (a result that must stand already landed, or the task
93/// is gone), logged here.
94fn claim_validated(
95 in_flight: &server::InFlightSet,
96 task_id: i64,
97 worker: &str,
98 status: TaskStatus,
99) -> Option<(Task, Replaced)> {
100 match in_flight.claim_result(task_id, worker, status) {
101 server::ResultClaim::Accept {
102 task,
103 attempt,
104 attempts,
105 late,
106 replaced,
107 } => {
108 if let Some(late) = late {
109 info!(
110 task_id,
111 worker,
112 attempt = attempt + 1,
113 attempts,
114 late = ?late,
115 "sink writer: accepted a result that arrived after its lease expired (D-26)"
116 );
117 }
118 Some((task, replaced))
119 },
120 server::ResultClaim::Superseded { attempt, finished } => {
121 warn!(
122 task_id,
123 worker,
124 attempt = attempt + 1,
125 finished_attempt = finished + 1,
126 "sink writer: dropped a result superseded by one that stands (D-26)"
127 );
128 None
129 },
130 server::ResultClaim::Stale { attempt } => {
131 warn!(
132 task_id,
133 worker,
134 attempt = attempt + 1,
135 "sink writer: dropped a result of an earlier run of the task (re-run since, D-28)"
136 );
137 None
138 },
139 server::ResultClaim::Unknown => {
140 warn!(
141 task_id,
142 worker, "sink writer: dropped a result whose task is no longer in flight or held"
143 );
144 None
145 },
146 }
147}
148
149/// Publish the claimed result `report` for `task`: rename its archive from `part_path` over
150/// `recv_path`, and return the report to finalize. If the rename fails, the report must not
151/// disagree with the archive on disk:
152/// * when the result replaced an earlier accepted one (`replaced`), that one's archive and DB row
153/// stand — nothing is reported, and the task's accepted result is restored
154/// ([`server::InFlightSet::note_publish_failed`]): a publish failure never costs the task a good
155/// result;
156/// * otherwise the stale archive (an earlier run's, if any) is removed and a stand-in Fatal
157/// `cortex/archive_publish_failed` is reported; while the task is held for late results (it had
158/// more than one attempt), any later result of it still replaces the stand-in.
159fn publish(
160 in_flight: &server::InFlightSet,
161 task: Task,
162 mut report: TaskReport,
163 part_path: &std::path::Path,
164 recv_path: &std::path::Path,
165 replaced: Replaced,
166) -> Option<TaskReport> {
167 let task_id = task.id;
168 if let Err(e) = std::fs::rename(part_path, recv_path) {
169 std::fs::remove_file(part_path).ok();
170 in_flight.note_publish_failed(task_id, replaced);
171 if replaced.is_some() {
172 warn!(
173 task_id,
174 error = ?e,
175 "sink writer: could not publish the result archive; the earlier accepted result stands"
176 );
177 return None;
178 }
179 warn!(
180 task_id,
181 error = ?e,
182 "sink writer: could not publish the result archive; reporting a stand-in Fatal"
183 );
184 std::fs::remove_file(recv_path).ok();
185 report = TaskReport {
186 messages: vec![NewTaskMessage::new(
187 task_id,
188 "fatal",
189 "cortex".to_string(),
190 "archive_publish_failed".to_string(),
191 format!("the result archive could not be published: {e}"),
192 )],
193 status: TaskStatus::Fatal,
194 stand_in: true,
195 ..report
196 };
197 }
198 report.task = task;
199 Some(report)
200}
201
202/// One archive-writer thread. It drains [`WriteCommand`]s for the tasks the receive loop assigns
203/// it, performing the blocking `/data` write + `cortex.log` parse + finalize hand-off **off** the
204/// receive loop. Per-result ordering is guaranteed because the receive loop sends a result's
205/// `Begin → Chunk* → Commit|Reject` contiguously to one writer's FIFO channel, and per-task
206/// ordering because every result of a task goes to the same writer ([`writer_for`]); fan-out is
207/// across *different* tasks. The writer exits when its channel disconnects (the sink shutting down
208/// on `job_limit`), after draining any buffered commands (so the last result's `Commit` still
209/// reaches finalize — loss-free). A panic here is surfaced by the receive loop (its liveness check
210/// / a send error) → the manager aborts the dispatcher (fail-fast preserved).
211///
212/// A result is written to a temporary file, parsed, and only then **claimed** for its task
213/// ([`server::InFlightSet::claim_result`]); only a claimed result replaces the task's archive (a
214/// rename) and is reported. So an empty, unreadable or unwritable result claims nothing: the task
215/// stays in flight for the reaper (or held for another late result) — never stranded `Queued`
216/// (KNOWN_ISSUES D-24) — and a published archive is never truncated by a result that fails or
217/// loses to a newer attempt's (D-26).
218fn run_writer(
219 rx: &Receiver<WriteCommand>,
220 done_tx: &SyncSender<TaskReport>,
221 in_flight: &server::InFlightSet,
222) {
223 let mut current: Option<InProgress> = None;
224 while let Ok(cmd) = rx.recv() {
225 match cmd {
226 WriteCommand::Begin {
227 task,
228 worker,
229 recv_path,
230 } => {
231 let part_path = part_path(&recv_path);
232 let file = match File::create(&part_path) {
233 Ok(f) => Some(f),
234 Err(e) => {
235 warn!(
236 task_id = task.id,
237 path = ?part_path,
238 error = ?e,
239 "sink writer: File::create failed; result dropped, task left to the reaper"
240 );
241 None
242 },
243 };
244 current = Some(InProgress {
245 task: *task,
246 worker,
247 recv_path,
248 part_path,
249 file,
250 });
251 },
252 WriteCommand::Chunk(bytes) => {
253 if let Some(job) = current.as_mut()
254 && let Some(file) = job.file.as_mut()
255 && let Err(e) = file.write_all(&bytes)
256 {
257 warn!(
258 task_id = job.task.id,
259 error = ?e,
260 "sink writer: write to result file failed; abandoning result"
261 );
262 // Stop writing; the (now-partial) file makes the result untrustworthy, so Commit
263 // skips it and the task is left to the reaper.
264 job.file = None;
265 }
266 // `bytes` dropped here — tight deallocation, O(chunk) resident.
267 },
268 WriteCommand::Commit => {
269 let Some(job) = current.take() else {
270 continue;
271 };
272 let task_id = job.task.id;
273 let Some(file) = job.file else {
274 std::fs::remove_file(&job.part_path).ok();
275 in_flight.note_unusable_reply(task_id, &job.worker, true);
276 warn!(
277 task_id,
278 "sink writer: no result file (create/write failed); result dropped, task left to the reaper"
279 );
280 continue;
281 };
282 // On disk before it is parsed and published, so a published archive is a complete one.
283 if let Err(e) = file.sync_data() {
284 warn!(task_id, error = ?e, "sink writer: could not sync the result file");
285 }
286 drop(file); // close before generate_report reads the archive back
287 match helpers::generate_report(job.task, &job.part_path) {
288 // A retryable cause (the worker harness's timeout / shed / unrecorded death), not a
289 // result: back to the task's retry budget, its archive not published.
290 Some(report) if report.is_retryable() => {
291 let status = report.status;
292 let cause = report.into_cause();
293 match in_flight.defer_retryable(task_id, &job.worker, cause.clone()) {
294 server::RetryDecision::Deferred => {
295 // Not published while the task is retried.
296 std::fs::remove_file(&job.part_path).ok();
297 info!(
298 task_id,
299 worker = %job.worker,
300 "sink writer: a retryable cause; the task goes back to its retry budget"
301 );
302 },
303 // The last attempt's cause: published, and reported as the task's stand-in.
304 server::RetryDecision::Final { task } => {
305 info!(
306 task_id,
307 worker = %job.worker,
308 "sink writer: a retryable cause on the last attempt; reported as a stand-in"
309 );
310 let stand_in = TaskReport {
311 task: task.clone(),
312 status,
313 messages: cause,
314 stand_in: true,
315 };
316 if let Some(report) = publish(
317 in_flight,
318 task,
319 stand_in,
320 &job.part_path,
321 &job.recv_path,
322 None,
323 ) {
324 server::send_done(done_tx, report);
325 }
326 },
327 server::RetryDecision::Dropped(why) => {
328 std::fs::remove_file(&job.part_path).ok();
329 info!(
330 task_id,
331 worker = %job.worker,
332 why,
333 "sink writer: a retryable cause dropped"
334 );
335 },
336 }
337 },
338 Some(report) => match claim_validated(in_flight, task_id, &job.worker, report.status) {
339 Some((task, replaced)) => {
340 if let Some(report) = publish(
341 in_flight,
342 task,
343 report,
344 &job.part_path,
345 &job.recv_path,
346 replaced,
347 ) {
348 server::send_done(done_tx, report);
349 }
350 },
351 None => {
352 std::fs::remove_file(&job.part_path).ok();
353 },
354 },
355 // Unreadable / empty result archive (0-byte, truncated, no `cortex.log`) — an
356 // infrastructure failure, not a verdict. Claim nothing, so the lease reaper recovers
357 // the task: retry, then dead-letter with a message after MAX_DISPATCH_RETRIES — never
358 // a silent terminal Fatal (D-18). Drop the artifact so a retry writes a clean file.
359 None => {
360 std::fs::remove_file(&job.part_path).ok();
361 in_flight.note_unusable_reply(task_id, &job.worker, true);
362 warn!(
363 task_id,
364 "sink writer: empty/unreadable result archive; result dropped, task left to the reaper (D-18)"
365 );
366 },
367 }
368 },
369 WriteCommand::Reject { max_result_bytes } => {
370 let Some(job) = current.take() else {
371 continue;
372 };
373 drop(job.file);
374 std::fs::remove_file(&job.part_path).ok();
375 let taskid = job.task.id;
376 warn!(
377 task_id = taskid,
378 max_result_bytes, "sink: result exceeded byte cap — rejected (Invalid)"
379 );
380 if let Some((task, _)) =
381 claim_validated(in_flight, taskid, &job.worker, TaskStatus::Invalid)
382 {
383 let report = TaskReport {
384 stand_in: false,
385 task,
386 status: TaskStatus::Invalid,
387 messages: vec![NewTaskMessage::new(
388 taskid,
389 "invalid",
390 "cortex".to_string(),
391 "result_too_large".to_string(),
392 format!("worker result exceeded the {max_result_bytes}-byte hard cap"),
393 )],
394 };
395 server::send_done(done_tx, report);
396 }
397 },
398 }
399 }
400}
401
402/// The archive writer that handles every result of task `taskid`: one per task, so results for the
403/// same task (two attempts' late results, D-26) are written to its single result path one after
404/// the other, in arrival order — never by two writers at once, which corrupted the archive.
405fn writer_for(taskid: i64, num_writers: usize) -> usize {
406 taskid
407 .unsigned_abs()
408 .checked_rem(num_writers as u64)
409 .unwrap_or(0) as usize
410}
411
412/// The parsed header of a worker reply envelope `[identity, service, taskid, <≥1 data frame>]`.
413/// The data frames (index 3..) are read directly off the [`ZmqMessage`] by the caller.
414struct ReplyHeader {
415 /// the worker's ZMQ identity (for metadata + logs)
416 identity: String,
417 /// the service name the worker claims to have run
418 service: String,
419 /// the task id this result is for (`-1` if the frame wasn't a valid integer)
420 taskid: i64,
421}
422
423/// Validates + parses the reply envelope from one **atomically-received** `zeromq` message. Because
424/// `zeromq` delivers the whole multipart message at once (unlike libzmq's frame-by-frame `recv` +
425/// `RCVMORE`), a short/truncated/malformed reply is simply a message with too few frames — dropping
426/// it cannot desync the *next* reply, so the entire libzmq desync bug class (KNOWN_ISSUES D-4/D-12)
427/// is structurally gone and a frame-count check replaces the four chained `RCVMORE` guards. A valid
428/// reply has **≥4** frames (`identity, service, taskid`, then ≥1 data frame — even an empty result
429/// carries one empty data frame); fewer ⇒ `Err(reason)` for the rate-limited discard log. Pure
430/// (no I/O), so it is unit-tested directly.
431fn parse_reply_envelope(msg: &ZmqMessage) -> Result<ReplyHeader, &'static str> {
432 if msg.len() < 4 {
433 return Err("truncated reply: fewer than 4 frames (no data frame after taskid)");
434 }
435 let frame_str = |i: usize, default: &str| -> String {
436 msg
437 .get(i)
438 .and_then(|f| std::str::from_utf8(f).ok())
439 .unwrap_or(default)
440 .to_string()
441 };
442 Ok(ReplyHeader {
443 identity: frame_str(0, "_worker_"),
444 service: frame_str(1, "_unknown_"),
445 taskid: frame_str(2, "-1").parse::<i64>().unwrap_or(-1),
446 })
447}
448
449/// Specifies the binding and operation parameters for a ZMQ sink component
450pub struct Sink {
451 /// port to listen on
452 pub port: usize,
453 /// the size of the dispatch queue
454 /// (also the batch size for Task store queue requests)
455 pub queue_size: usize,
456 /// size of an individual message chunk sent via zeromq
457 /// (keep this small to avoid large RAM use, increase to reduce network bandwidth)
458 pub message_size: usize,
459 /// address for the Task store postgres endpoint
460 pub backend_address: String,
461 /// non-blocking handle to the background worker-metadata writer
462 pub metadata: WorkerMetadataSender,
463}
464
465impl Sink {
466 /// Starts a receiver/sink `Server` (ZMQ Pull), to accept processing responses.
467 /// The sink shares state with other manager threads via queues for tasks in progress,
468 /// as well as a queue for completed tasks pending persisting to disk.
469 /// Termination is driven entirely by the shared `dispatch_complete` signal (set by the ventilator
470 /// on a bounded run's end **or** a graceful shutdown), not by `_job_limit` — the sink stops once
471 /// dispatching is done and the in-flight set has drained.
472 ///
473 /// **Phase 5a (transport swap):** the receive loop now runs on the pure-Rust async `zeromq`
474 /// transport, driven by a current-thread tokio runtime this sink thread owns. `zeromq` delivers
475 /// each result as **one atomic multipart [`ZmqMessage`]**, which retires the libzmq
476 /// frame-by-frame desync bug class (D-4/D-12): a malformed reply is just a message with too few
477 /// frames, discarded by dropping it — it can no longer swallow the next reply. The blocking
478 /// `/data` write + `cortex.log` parse stay fanned out to the **unchanged** phase-3
479 /// [`run_writer`] std-thread pool (size `dispatcher.sink_writers`); the blocking channel sends
480 /// to it (and to the done channel) from this single-task runtime are the correct backpressure —
481 /// when the disk or DB can't keep up, the loop stops receiving and the workers back off.
482 /// **Note:** `zeromq` exposes no TCP-keepalive knob, so `dispatcher.tcp_keepalive_idle_seconds`
483 /// no longer applies to the sink PULL socket; keepalive was stability-only (remote-worker NAT
484 /// mappings) and the lease reaper remains the correctness net.
485 pub fn start(
486 &self,
487 services_arc: &Arc<server::ServiceCache>,
488 sandboxes_arc: &Arc<server::SandboxCache>,
489 progress_queue_arc: &Arc<server::InFlightSet>,
490 done_tx: &SyncSender<TaskReport>,
491 _job_limit: Option<usize>,
492 dispatch_complete: &Arc<AtomicBool>,
493 ) -> Result<(), Box<dyn Error>> {
494 // Hard cap on a single result's on-disk size (config `dispatcher.max_result_bytes`, default
495 // 2 GiB): a runaway worker must not fill `/data`.
496 let max_result_bytes = config().dispatcher.max_result_bytes;
497
498 // Spin up the archive-writer pool (D-7 fan-out). Each writer owns a bounded command channel and
499 // a clone of the done sender; the receive loop keeps the senders + handles to stream work and
500 // to detect a writer death.
501 let num_writers = config().dispatcher.sink_writers.max(1);
502 let mut writer_txs: Vec<SyncSender<WriteCommand>> = Vec::with_capacity(num_writers);
503 let mut writer_handles: Vec<JoinHandle<()>> = Vec::with_capacity(num_writers);
504 for _ in 0..num_writers {
505 let (tx, rx) = sync_channel::<WriteCommand>(SINK_WRITER_CHANNEL_CAPACITY);
506 let writer_done_tx = done_tx.clone();
507 let writer_in_flight = Arc::clone(progress_queue_arc);
508 writer_txs.push(tx);
509 writer_handles.push(thread::spawn(move || {
510 run_writer(&rx, &writer_done_tx, &writer_in_flight)
511 }));
512 }
513
514 let address = format!("tcp://0.0.0.0:{}", self.port);
515
516 // A current-thread runtime is right: there is one PULL socket and one receive loop, and
517 // blocking it on writer/done-channel backpressure is exactly the flow control we want (stop
518 // receiving when the pipeline downstream is full). The writer pool threads drain
519 // independently, so a blocking send here always makes progress.
520 let runtime = tokio::runtime::Builder::new_current_thread()
521 .enable_all()
522 .build()?;
523
524 let recv_result: Result<(), Box<dyn Error>> = runtime.block_on(async {
525 // Retry the bind (mirrors the ventilator) so a port handover from a restarting
526 // dispatcher doesn't crash-loop the sink on EADDRINUSE. The window must OUTLAST TCP
527 // TIME_WAIT (~60s = tcp_fin_timeout), not just a few seconds — see the ventilator's
528 // note (observed 2026-07-01: the old 7.5s window crash-looped the sink through the
529 // full 60s TIME_WAIT until systemd's start-limit gave up).
530 let mut pull = {
531 // 150 * 500ms = 75s > tcp_fin_timeout (60s) TIME_WAIT, with margin.
532 const BIND_ATTEMPTS: u32 = 150;
533 let mut attempt = 1u32;
534 loop {
535 let mut p = PullSocket::new();
536 match p.bind(&address).await {
537 Ok(_) => break p,
538 Err(e) if attempt < BIND_ATTEMPTS => {
539 warn!(
540 address = %address,
541 attempt,
542 max_attempts = BIND_ATTEMPTS,
543 error = %e,
544 "sink: bind failed; retrying in 500ms (port handover from a restarting dispatcher?)"
545 );
546 tokio::time::sleep(Duration::from_millis(500)).await;
547 attempt += 1;
548 },
549 Err(e) => {
550 return Err(
551 io::Error::other(format!(
552 "sink: zeromq bind {address} failed after {BIND_ATTEMPTS} attempts: {e}"
553 ))
554 .into(),
555 );
556 },
557 }
558 }
559 };
560
561 let mut sink_job_count: usize = 0;
562 // Rate-limited logging for discarded replies (malformed envelopes, unknown task ids). A
563 // sustained bad-reply flood must not turn per-message `stderr` writes into a throughput-DoS
564 // (KNOWN_ISSUES D-11) — count, don't narrate.
565 let mut discard_log = server::RateLimitedLog::new(Duration::from_secs(5));
566
567 loop {
568 // Fail-fast: a writer thread that died (e.g. a panic in `generate_report`) must bring down
569 // the dispatcher, not leave a half-working pipeline. Detect it promptly here (and again via
570 // a send error below) so the manager aborts for a supervised restart (CLAUDE.md fail-fast).
571 if let Some(idx) = writer_handles.iter().position(JoinHandle::is_finished) {
572 return Err(Box::<dyn Error>::from(io::Error::other(format!(
573 "sink writer thread {idx} died unexpectedly; aborting for a supervised restart"
574 ))));
575 }
576
577 // One atomic multipart message: `[identity, service, taskid, ...data]`. No RCVMORE dance —
578 // the whole message arrives at once, so a short/malformed reply is just a short frame
579 // vector (handled by `parse_reply_envelope`), never a desync of the next reply.
580 //
581 // Always poll the recv on a short timeout (every run, production included) so the sink
582 // wakes to re-check the shared completion signal even when idle —
583 // `dispatch_complete` is set by the ventilator both at a bounded run's end (D-5)
584 // and on a graceful SIGTERM/SIGINT (O-1). The timeout only fires while idle
585 // (nothing mid-delivery), and `recv()` is cancel-safe, so a dropped timed-out
586 // future loses no message (KNOWN_ISSUES D-5 + the D-16 idle-shutdown
587 // hang this closes: a production sink no longer blocks past the shutdown signal).
588 let recv_outcome = match tokio::time::timeout(SINK_TERMINATION_POLL, pull.recv()).await {
589 Ok(inner) => inner,
590 Err(_elapsed) => {
591 if dispatch_complete.load(Ordering::Acquire)
592 && progress_queue_arc.awaiting_replies() == 0
593 {
594 info!("sink: dispatching complete and in-flight drained; terminating sink");
595 break;
596 }
597 continue;
598 },
599 };
600 let msg = match recv_outcome {
601 Ok(m) => m,
602 Err(e) => {
603 return Err(Box::<dyn Error>::from(io::Error::other(format!(
604 "sink: zeromq recv failed: {e}"
605 ))));
606 },
607 };
608
609 let header = match parse_reply_envelope(&msg) {
610 Ok(h) => h,
611 Err(reason) => {
612 if let Some(n) = discard_log.record() {
613 warn!(
614 discarded = n,
615 reason = %reason,
616 "sink: discarded malformed reply(ies) (rate-limited)"
617 );
618 }
619 continue;
620 },
621 };
622
623 sink_job_count += 1;
624 let taskid = header.taskid;
625 let request_time = chrono::Utc::now();
626 trace!(
627 job = sink_job_count,
628 service = ?header.service,
629 worker = ?header.identity,
630 task_id = taskid,
631 "sink: incoming result"
632 );
633
634 // Look the task up without claiming it: a result for the wrong service, or one with no
635 // output path, leaves the task exactly where it was — in flight for the reaper, or held for
636 // a late result — rather than stranding it.
637 if let Some(task) = progress_queue_arc.task(taskid) {
638 match server::get_service(&header.service, services_arc) {
639 None => {
640 return Err(Box::<dyn Error>::from(io::Error::other(
641 "sink: get_service found nothing",
642 )));
643 },
644 Some(service) => {
645 if service.id == task.service_id {
646 // Where the result goes: `Some(None)` for init (no payload — its empty data frames
647 // are ignored, the report is inline), `Some(Some(path))` for a result archive:
648 // `<entry-dir>/<service>.zip`, or a sandbox-scoped name when this task's corpus is
649 // a sandbox (lock-free cache read, memoised by the ventilator on
650 // dispatch — F-6). An unknown corpus yields no path (fail closed):
651 // never guess the parent's published archive name — the result is
652 // dropped and the task left to the reaper, which retries it
653 // (re-dispatch re-runs the sandbox lookup) or dead-letters it.
654 let placement = if service.id == 1 {
655 Some(None)
656 } else {
657 let recv_path_opt = server::get_sandbox_id(task.corpus_id, sandboxes_arc)
658 .and_then(|sandbox_id| {
659 helpers::result_archive_path(&task.entry, &service.name, sandbox_id)
660 });
661 if recv_path_opt.is_none() {
662 progress_queue_arc.note_unusable_reply(task.id, &header.identity, false);
663 warn!(
664 task_id = task.id,
665 corpus_id = task.corpus_id,
666 entry = ?task.entry,
667 "sink: could not derive a result path (bad entry or unknown corpus); \
668 result dropped, task left to the reaper"
669 );
670 }
671 recv_path_opt.map(Some)
672 };
673 match placement {
674 // Init: no archive to validate — claim and report inline.
675 Some(None) => {
676 if let server::ResultClaim::Accept { task, .. } = progress_queue_arc
677 .claim_result(taskid, &header.identity, TaskStatus::NoProblem)
678 {
679 server::send_done(
680 done_tx,
681 TaskReport {
682 stand_in: false,
683 task,
684 status: TaskStatus::NoProblem,
685 messages: Vec::new(),
686 },
687 );
688 }
689 },
690 // A result archive: the writer writes and parses it, then claims it for the task
691 // — accepted unless a newer attempt's result already landed (D-26) — so a
692 // result that turns out empty or unreadable claims nothing.
693 Some(Some(recv_path)) => {
694 // Hard size cap (disk protection): sum the data frames; an over-cap result is
695 // rejected (Invalid) without being written. The whole multipart message is
696 // already received (ZMQ delivers it atomically — true of libzmq too), so this
697 // is the same disk-protection guarantee as the streamed check, just computed
698 // up front.
699 let data_bytes: usize = msg.iter().skip(3).map(|f| f.len()).sum();
700 let oversized = data_bytes > max_result_bytes;
701 // A task's results all go to the same writer, so two attempts' results for
702 // one task (D-26) are written, claimed and reported in arrival order.
703 let widx = writer_for(taskid, num_writers);
704 // The reaper leaves the task alone while its writer decides the result.
705 progress_queue_arc.note_received(taskid);
706 // A send error means that writer died → fail-fast.
707 if writer_txs[widx]
708 .send(WriteCommand::Begin {
709 task: Box::new(task.clone()),
710 worker: header.identity.clone(),
711 recv_path,
712 })
713 .is_err()
714 {
715 return Err(Box::<dyn Error>::from(io::Error::other(
716 "sink writer thread died while beginning a result; aborting",
717 )));
718 }
719 if oversized {
720 if writer_txs[widx]
721 .send(WriteCommand::Reject { max_result_bytes })
722 .is_err()
723 {
724 return Err(Box::<dyn Error>::from(io::Error::other(
725 "sink writer thread died while rejecting a result; aborting",
726 )));
727 }
728 } else {
729 for frame in msg.iter().skip(3) {
730 if writer_txs[widx]
731 .send(WriteCommand::Chunk(frame.to_vec()))
732 .is_err()
733 {
734 return Err(Box::<dyn Error>::from(io::Error::other(
735 "sink writer thread died while streaming a result; aborting",
736 )));
737 }
738 }
739 if writer_txs[widx].send(WriteCommand::Commit).is_err() {
740 return Err(Box::<dyn Error>::from(io::Error::other(
741 "sink writer thread died while finishing a result; aborting",
742 )));
743 }
744 }
745 },
746 None => {},
747 }
748 // Update worker metadata (non-blocking enqueue to the background writer).
749 self
750 .metadata
751 .received(header.identity.clone(), service.id, taskid);
752 } else if let Some(n) = discard_log.record() {
753 warn!(
754 discarded = n,
755 service = ?header.service,
756 service_id = service.id,
757 task_service_id = task.service_id,
758 task_id = taskid,
759 "sink: discarded reply(ies) [service-id mismatch] (rate-limited)"
760 );
761 }
762 },
763 };
764 } else if let Some(n) = discard_log.record() {
765 // No such task in flight or in its late-result window — drop the message (already fully
766 // received; nothing to drain).
767 warn!(
768 discarded = n,
769 task_id = taskid,
770 "sink: discarded reply(ies) for unknown task id(s) (rate-limited)"
771 );
772 }
773
774 let request_duration = (chrono::Utc::now() - request_time).num_milliseconds();
775 let total_incoming: usize = msg.iter().skip(3).map(|f| f.len()).sum();
776 trace!(
777 job = sink_job_count,
778 bytes = total_incoming,
779 recv_ms = request_duration,
780 "sink: received result"
781 );
782
783 // Terminate once the ventilator has signalled dispatching is done AND every dispatched task
784 // has come back (the in-flight set is empty) — the shared completion condition that
785 // replaced the old per-thread `sink_job_count >= limit` (KNOWN_ISSUES D-5), set on
786 // a bounded run's end or a graceful shutdown (D-16). Checked here right after a
787 // result lands (so the run ends promptly when the last one arrives) and on the
788 // recv-timeout poll above (so a sink already blocked when the signal flips still
789 // wakes to notice).
790 if dispatch_complete.load(Ordering::Acquire) && progress_queue_arc.awaiting_replies() == 0 {
791 info!("sink: dispatching complete and in-flight drained; terminating sink");
792 break;
793 }
794 }
795 Ok(())
796 });
797
798 // Shut the writer pool down cleanly: dropping the senders disconnects each writer's channel; it
799 // drains any buffered commands first (so the final `Commit` still reaches finalize —
800 // loss-free), then exits. Join them before returning so a `job_limit` run does not race
801 // teardown.
802 drop(writer_txs);
803 for handle in writer_handles {
804 let _ = handle.join();
805 }
806 recv_result
807 }
808}
809
810#[cfg(test)]
811mod tests {
812 use super::parse_reply_envelope;
813 use bytes::Bytes;
814 use zeromq::ZmqMessage;
815
816 /// Build a `ZmqMessage` from frame byte-slices (the test analogue of a received reply).
817 fn message(frames: &[&[u8]]) -> ZmqMessage {
818 let mut iter = frames.iter();
819 let first = iter.next().expect("at least one frame");
820 let mut msg = ZmqMessage::from(first.to_vec());
821 for f in iter {
822 msg.push_back(Bytes::copy_from_slice(f));
823 }
824 msg
825 }
826
827 #[test]
828 fn parses_a_well_formed_reply() {
829 // [identity, service, taskid, data] — the minimal valid reply (one data frame).
830 let msg = message(&[b"worker-7", b"tex_to_html", b"4242", b"<zip bytes>"]);
831 let header = parse_reply_envelope(&msg).expect("valid envelope");
832 assert_eq!(header.identity, "worker-7");
833 assert_eq!(header.service, "tex_to_html");
834 assert_eq!(header.taskid, 4242);
835 }
836
837 #[test]
838 fn rejects_a_reply_with_no_data_frame() {
839 // Exactly 3 frames (no data) is the D-12 shape — must be discarded, not accepted. With atomic
840 // message delivery this can no longer swallow the next reply (it's just a short frame vector).
841 let msg = message(&[b"worker-7", b"tex_to_html", b"4242"]);
842 assert!(parse_reply_envelope(&msg).is_err());
843 }
844
845 #[test]
846 fn rejects_an_empty_or_identity_only_reply() {
847 assert!(parse_reply_envelope(&message(&[b""])).is_err());
848 assert!(parse_reply_envelope(&message(&[b"worker-7"])).is_err());
849 assert!(parse_reply_envelope(&message(&[b"worker-7", b"svc"])).is_err());
850 }
851
852 #[test]
853 fn defaults_a_non_numeric_taskid_to_minus_one() {
854 let msg = message(&[b"w", b"svc", b"not-a-number", b"data"]);
855 let header = parse_reply_envelope(&msg).expect("valid frame count");
856 assert_eq!(
857 header.taskid, -1,
858 "an unpar'seable taskid is -1 (unknown), not a panic"
859 );
860 }
861
862 #[test]
863 fn a_tasks_results_share_one_writer() {
864 use super::writer_for;
865 for taskid in [0_i64, 1, 7, 4242, i64::from(i32::MAX) + 3] {
866 assert_eq!(writer_for(taskid, 4), writer_for(taskid, 4));
867 assert!(writer_for(taskid, 4) < 4);
868 assert_eq!(writer_for(taskid, 1), 0);
869 }
870 assert_eq!(
871 writer_for(-1, 4),
872 1,
873 "never out of range, even for a bad id"
874 );
875 assert_eq!(writer_for(9, 0), 0, "no writers configured is not a panic");
876 let spread: std::collections::HashSet<usize> = (0..8).map(|t| writer_for(t, 4)).collect();
877 assert_eq!(
878 spread.len(),
879 4,
880 "different tasks fan out across the writers"
881 );
882 }
883
884 /// A held task (attempt 0's usable result accepted, attempt 1 in flight) and a publish that will
885 /// fail: the result's final path is a non-empty directory, so the rename cannot replace it.
886 fn failing_publish(
887 tag: &str,
888 ) -> (
889 std::path::PathBuf,
890 crate::dispatcher::server::InFlightSet,
891 std::path::PathBuf,
892 std::path::PathBuf,
893 ) {
894 use crate::helpers::TaskProgress;
895 use crate::models::Task;
896 let dir =
897 std::env::temp_dir().join(format!("cortex_sink_publish_{tag}_{}", std::process::id()));
898 std::fs::remove_dir_all(&dir).ok();
899 std::fs::create_dir_all(&dir).unwrap();
900 let recv = dir.join("svc.zip");
901 std::fs::create_dir(&recv).unwrap();
902 std::fs::write(recv.join("keep"), b"x").unwrap();
903 let part = dir.join("svc.zip.part");
904 std::fs::write(&part, b"new archive").unwrap();
905 let set = crate::dispatcher::server::InFlightSet::new();
906 set.insert(TaskProgress {
907 run_start: 0,
908 task: Task {
909 id: 1,
910 service_id: 3,
911 corpus_id: 1,
912 status: 7,
913 entry: String::new(),
914 },
915 created_at: chrono::Utc::now().timestamp(),
916 retries: 1,
917 lease_timeout_seconds: 240,
918 attempt_workers: vec!["w0".into(), "w1".into()],
919 last_cause: Vec::new(),
920 });
921 (dir, set, recv, part)
922 }
923
924 fn report_for(
925 task: crate::models::Task,
926 status: crate::helpers::TaskStatus,
927 ) -> crate::helpers::TaskReport {
928 crate::helpers::TaskReport {
929 task,
930 status,
931 messages: Vec::new(),
932 stand_in: false,
933 }
934 }
935
936 #[test]
937 fn a_failed_publish_keeps_the_result_it_would_have_replaced() {
938 use crate::dispatcher::server::ResultClaim;
939 use crate::helpers::TaskStatus;
940 let (dir, set, recv, part) = failing_publish("kept");
941 assert!(matches!(
942 set.claim_result(1, "w0", TaskStatus::NoProblem),
943 ResultClaim::Accept { .. }
944 ));
945 let ResultClaim::Accept { task, replaced, .. } = set.claim_result(1, "w1", TaskStatus::Warning)
946 else {
947 panic!("the newer attempt's result is accepted");
948 };
949 let report = report_for(task.clone(), TaskStatus::Warning);
950 assert!(
951 super::publish(&set, task, report, &part, &recv, replaced).is_none(),
952 "nothing reported: attempt 0's result and archive stand"
953 );
954 assert!(
955 recv.join("keep").exists(),
956 "the earlier archive is not removed"
957 );
958 assert!(!part.exists());
959 assert!(matches!(
960 set.claim_result(1, "w1", TaskStatus::Fatal),
961 ResultClaim::Superseded { finished: 0, .. }
962 ));
963 std::fs::remove_dir_all(dir).ok();
964 }
965
966 #[test]
967 fn a_failed_publish_of_a_first_result_reports_a_stand_in() {
968 use crate::dispatcher::server::ResultClaim;
969 use crate::helpers::TaskStatus;
970 let (dir, set, recv, part) = failing_publish("stand_in");
971 // Here the final path holds a stale archive (an earlier run's file), and the rename fails
972 // because the written result is gone.
973 std::fs::remove_dir_all(&recv).unwrap();
974 std::fs::write(&recv, b"stale archive").unwrap();
975 std::fs::remove_file(&part).unwrap();
976 let ResultClaim::Accept { task, replaced, .. } = set.claim_result(1, "w1", TaskStatus::Fatal)
977 else {
978 panic!("accepted");
979 };
980 let report = report_for(task.clone(), TaskStatus::Fatal);
981 let stand_in = super::publish(&set, task, report, &part, &recv, replaced).expect("a report");
982 assert!(stand_in.stand_in && stand_in.status == TaskStatus::Fatal);
983 assert!(
984 !recv.exists(),
985 "the stale archive is removed, so disk and DB agree"
986 );
987 // Any later result can still land.
988 assert!(matches!(
989 set.claim_result(1, "w1", TaskStatus::Fatal),
990 ResultClaim::Accept { .. }
991 ));
992 std::fs::remove_dir_all(dir).ok();
993 }
994
995 #[test]
996 fn a_result_is_written_beside_its_archive_first() {
997 let part = super::part_path(std::path::Path::new("/data/x/doc/svc.zip"));
998 assert_eq!(part, std::path::PathBuf::from("/data/x/doc/svc.zip.part"));
999 }
1000
1001 #[test]
1002 fn tolerates_non_utf8_header_frames() {
1003 // A hostile/garbled identity must not panic the parse — it falls back to the default label.
1004 let msg = message(&[&[0xff, 0xfe], b"svc", b"7", b"data"]);
1005 let header = parse_reply_envelope(&msg).expect("valid frame count");
1006 assert_eq!(header.identity, "_worker_");
1007 assert_eq!(header.taskid, 7);
1008 }
1009}