Skip to main content

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}