Skip to main content

cortex/dispatcher/
manager.rs

1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8use std::sync::Arc;
9use std::sync::atomic::AtomicBool;
10use std::sync::mpsc::sync_channel;
11use std::thread::{self, sleep};
12use std::time::Duration;
13
14use tracing::{error, info};
15
16use crate::backend::{build_pool, default_db_address};
17use crate::config::config;
18use crate::dispatcher::finalize::Finalize;
19use crate::dispatcher::server::{InFlightSet, SandboxCache, ServiceCache};
20use crate::dispatcher::sink::Sink;
21use crate::dispatcher::ventilator::Ventilator;
22use crate::helpers::TaskReport;
23use crate::models::start_metadata_writer;
24use zmq::Error;
25
26/// Manager struct responsible for dispatching and receiving tasks
27pub struct TaskManager {
28  /// port for requesting/dispatching jobs
29  pub source_port: usize,
30  /// port for responding/receiving results
31  pub result_port: usize,
32  /// the size of the dispatch queue
33  /// (also the batch size for Task store queue requests)
34  pub queue_size: usize,
35  /// size of an individual message chunk sent via zeromq
36  /// (keep this small to avoid large RAM use, increase to reduce network bandwidth)
37  pub message_size: usize,
38  /// backpressure threshold: max in-flight (dispatched-but-unfinished) tasks before the ventilator
39  /// stops leasing new work and mock-replies (KNOWN_ISSUES D-6)
40  pub max_in_flight: usize,
41  /// address for the Task store postgres endpoint
42  pub backend_address: String,
43}
44
45impl Default for TaskManager {
46  fn default() -> TaskManager {
47    TaskManager {
48      source_port: 51695,
49      result_port: 51696,
50      queue_size: 100,
51      message_size: 100_000,
52      max_in_flight: config().dispatcher.max_in_flight,
53      backend_address: default_db_address().to_string(),
54    }
55  }
56}
57
58impl TaskManager {
59  /// Starts a new manager, spinning of dispatch/sink servers, listening on the specified ports
60  pub fn start(&self, job_limit: Option<usize>) -> Result<(), Error> {
61    // Shared dispatcher state between the source (ventilator) and sink threads, now lock-free
62    // (phase 4): a sharded `ServiceCache` (`service_name → Option<Service>` memo) and the
63    // `InFlightSet` (dispatched-but-unfinished tasks + an O(1) size counter for backpressure).
64    let services_arc = Arc::new(ServiceCache::new());
65    let sandboxes_arc = Arc::new(SandboxCache::new());
66    // The in-flight set also holds reaped tasks for late results for the configured window
67    // (KNOWN_ISSUES D-26).
68    let progress_queue_arc = Arc::new(InFlightSet::with_late_result_window(
69      crate::config::config()
70        .dispatcher
71        .late_result_window_seconds,
72    ));
73
74    // Shared bounded-run completion signal (KNOWN_ISSUES D-5). The ventilator sets it once it has
75    // dispatched the `job_limit`'s worth of real tasks (or drained the source); the sink reads it
76    // to know it may terminate as soon as the in-flight set empties. It stays `false` forever
77    // in perpetual production mode (`job_limit = None`), so that path is provably unchanged.
78    let dispatch_complete = Arc::new(AtomicBool::new(false));
79
80    // Done queue (phase 1): a **bounded** channel instead of `Arc<Mutex<Vec<TaskReport>>>` + a
81    // panic backstop. The sink + ventilator-reaper `send` finished reports (cloning the
82    // sender); the finalize thread owns the single receiver. A full channel blocks the
83    // producers (backpressure), never drops. `done_tx` is kept alive in this scope so the
84    // channel stays open across ventilator restarts — it disconnects (a clean finalize
85    // shutdown) only when this method returns.
86    let (done_tx, done_rx) =
87      sync_channel::<TaskReport>(crate::dispatcher::server::DONE_QUEUE_CAPACITY);
88
89    // Single background worker-metadata writer fed by a non-blocking channel: O(1) threads instead
90    // of a detached thread per ZMQ event (KNOWN_ISSUES D-1), writing over a pooled connection
91    // (~11us vs a ~4.5ms fresh connect; the Arm 14 spike). The ventilator/sink clone the sender;
92    // the writer stops when all senders drop (i.e. when this method returns).
93    let metadata = start_metadata_writer(build_pool(
94      &self.backend_address,
95      config().database.pool_size,
96    ));
97
98    // First prepare the source ventilator
99    let source_port = self.source_port;
100    let source_queue_size = self.queue_size;
101    let source_message_size = self.message_size;
102    let source_max_in_flight = self.max_in_flight;
103    let source_backend_address = self.backend_address.clone();
104
105    // Next prepare the finalize thread which will persist finished jobs to the DB. It owns the
106    // single receiver end of the bounded done channel (moved in here).
107    let finalize_backend_address = self.backend_address.clone();
108    let finalize_thread = thread::spawn(move || {
109      Finalize {
110        backend_address: finalize_backend_address,
111      }
112      .start(done_rx)
113      .unwrap_or_else(|e| panic!("Failed in finalize thread: {e:?}"));
114    });
115
116    // Now prepare the results sink
117    let result_port = self.result_port;
118    let result_queue_size = self.queue_size;
119    let result_message_size = self.message_size;
120    let result_backend_address = self.backend_address.clone();
121
122    let sink_services_arc = services_arc.clone();
123    let sink_sandboxes_arc = sandboxes_arc.clone();
124    let sink_progress_queue_arc = progress_queue_arc.clone();
125
126    let sink_done_tx = done_tx.clone();
127    let sink_metadata = metadata.clone();
128    let sink_dispatch_complete = dispatch_complete.clone();
129    let sink_thread = thread::spawn(move || {
130      Sink {
131        port: result_port,
132        queue_size: result_queue_size,
133        message_size: result_message_size,
134        backend_address: result_backend_address.clone(),
135        metadata: sink_metadata,
136      }
137      .start(
138        &sink_services_arc,
139        &sink_sandboxes_arc,
140        &sink_progress_queue_arc,
141        &sink_done_tx,
142        job_limit,
143        &sink_dispatch_complete,
144      )
145      .unwrap_or_else(|e| panic!("Failed in sink thread: {e:?}"));
146    });
147
148    // 09.2025, Currently the ventilator has some hard to reproduce fragility to empty messages
149    //          which necessitates a restart of the thread. If we can reproduce that better,
150    //          it may be possible to return to the previous single-threaded lifecycle.
151    loop {
152      let vent_services_arc = services_arc.clone();
153      let vent_sandboxes_arc = sandboxes_arc.clone();
154      let vent_progress_queue_arc = progress_queue_arc.clone();
155      let vent_done_tx = done_tx.clone();
156      let vent_backend_address = source_backend_address.clone();
157      let vent_metadata = metadata.clone();
158      let vent_dispatch_complete = dispatch_complete.clone();
159      let vent_thread = thread::spawn(move || {
160        let ventilator = Ventilator {
161          port: source_port,
162          queue_size: source_queue_size,
163          message_size: source_message_size,
164          max_in_flight: source_max_in_flight,
165          backend_address: vent_backend_address,
166          metadata: vent_metadata,
167        };
168        ventilator
169          .start(
170            &vent_services_arc,
171            &vent_sandboxes_arc,
172            &vent_progress_queue_arc,
173            &vent_done_tx,
174            job_limit,
175            &vent_dispatch_complete,
176          )
177          .unwrap_or_else(|e| panic!("Failed in ventilator thread: {e:?}"));
178      });
179      // Wait for the ventilator to return, but POLL rather than block — so a dead
180      // finalize/sink thread is detected even while the ventilator is actively leasing.
181      // Under load the ventilator can run indefinitely, so the plain blocking `join()`
182      // that used to be here starved the perpetual-mode health checks below: a
183      // finalize/sink thread that panicked (e.g. a `mark_done` DB bind-parameter
184      // overflow) left the pipeline silently wedged — workers converting into a dead
185      // sink forever — instead of aborting for a supervised restart. (2026-06-17.)
186      while !vent_thread.is_finished() {
187        if job_limit.is_none() {
188          if finalize_thread.is_finished() {
189            error!("Finalize (DB) thread died unexpectedly! Aborting for a supervised restart.");
190            return Err(zmq::Error::ETERM);
191          }
192          if sink_thread.is_finished() {
193            error!("Sink thread died unexpectedly! Aborting for a supervised restart.");
194            return Err(zmq::Error::ETERM);
195          }
196        }
197        sleep(Duration::from_millis(500));
198      }
199      if vent_thread.join().is_err() {
200        error!("Ventilator thread died unexpectedly!");
201        return Err(zmq::Error::ETERM);
202      }
203      if job_limit.is_some() {
204        break;
205      }
206      // Graceful shutdown (O-1): the ventilator returned because a SIGTERM/SIGINT set the flag (it
207      // set `dispatch_complete` on the way out, exactly like a bounded run finishing). Break
208      // to the drain path below — join the sink (it finishes the in-flight set), drop the
209      // done-sender, flush finalize, exit `Ok` — instead of restarting the ventilator. A
210      // dead-worker straggler whose result never returns is backstopped by the supervisor's
211      // stop-timeout SIGKILL; its task recovers via `clear_limbo_tasks_except` on the next
212      // start.
213      if crate::dispatcher::server::shutdown_requested() {
214        info!("Graceful shutdown requested — draining in-flight results, then stopping.");
215        break;
216      }
217      // Perpetual mode (`job_limit = None`, i.e. production): the sink and finalize threads are
218      // spawned **once** (only the ventilator is restart-looped), and this loop never reaches their
219      // joins below — so a sink/finalize that died (e.g. a panic on a DB runaway, or an unexpected
220      // result) would otherwise leave the pipeline **silently stalled**: results pile up
221      // unprocessed, the in-flight set saturates, the ventilator mock-replies forever, and nothing
222      // aborts. Surface it as the intended fail-fast — abort so the external supervisor restarts
223      // the whole dispatcher (CLAUDE.md "process abort → external restart"), rather than
224      // stall unnoticed. (In `job_limit` mode we already `break`ed above, so a *cleanly
225      // finished* sink/finalize is never mistaken for a death here.)
226      if sink_thread.is_finished() {
227        error!("Sink thread died unexpectedly! Aborting for a supervised restart.");
228        return Err(zmq::Error::ETERM);
229      }
230      if finalize_thread.is_finished() {
231        error!("Finalize (DB) thread died unexpectedly! Aborting for a supervised restart.");
232        return Err(zmq::Error::ETERM);
233      }
234      sleep(Duration::from_secs(1));
235    }
236    // Bounded run only (perpetual mode returns `ETERM` inside the loop and never reaches here). The
237    // ventilator has signalled completion; join the sink (it drains the in-flight set, then stops),
238    // then DROP the manager's done-channel sender so the finalize thread sees `Disconnected` — its
239    // clean shutdown — and persists the last batch before stopping. This shared completion
240    // handshake (dispatch_complete → sink drains → drop sender → finalize disconnects) replaced
241    // the three mismatched per-thread `job_limit` counters that used to deadlock (KNOWN_ISSUES
242    // D-5).
243    if sink_thread.join().is_err() {
244      error!("Sink thread died unexpectedly!");
245      return Err(zmq::Error::ETERM);
246    }
247    drop(done_tx);
248    if finalize_thread.join().is_err() {
249      error!("DB thread died unexpectedly!");
250      Err(zmq::Error::ETERM)
251    } else {
252      info!("Manager successfully terminated!");
253      Ok(())
254    }
255  }
256}