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    let progress_queue_arc = Arc::new(InFlightSet::new());
67
68    // Shared bounded-run completion signal (KNOWN_ISSUES D-5). The ventilator sets it once it has
69    // dispatched the `job_limit`'s worth of real tasks (or drained the source); the sink reads it
70    // to know it may terminate as soon as the in-flight set empties. It stays `false` forever
71    // in perpetual production mode (`job_limit = None`), so that path is provably unchanged.
72    let dispatch_complete = Arc::new(AtomicBool::new(false));
73
74    // Done queue (phase 1): a **bounded** channel instead of `Arc<Mutex<Vec<TaskReport>>>` + a
75    // panic backstop. The sink + ventilator-reaper `send` finished reports (cloning the
76    // sender); the finalize thread owns the single receiver. A full channel blocks the
77    // producers (backpressure), never drops. `done_tx` is kept alive in this scope so the
78    // channel stays open across ventilator restarts — it disconnects (a clean finalize
79    // shutdown) only when this method returns.
80    let (done_tx, done_rx) =
81      sync_channel::<TaskReport>(crate::dispatcher::server::DONE_QUEUE_CAPACITY);
82
83    // Single background worker-metadata writer fed by a non-blocking channel: O(1) threads instead
84    // of a detached thread per ZMQ event (KNOWN_ISSUES D-1), writing over a pooled connection
85    // (~11us vs a ~4.5ms fresh connect; the Arm 14 spike). The ventilator/sink clone the sender;
86    // the writer stops when all senders drop (i.e. when this method returns).
87    let metadata = start_metadata_writer(build_pool(
88      &self.backend_address,
89      config().database.pool_size,
90    ));
91
92    // First prepare the source ventilator
93    let source_port = self.source_port;
94    let source_queue_size = self.queue_size;
95    let source_message_size = self.message_size;
96    let source_max_in_flight = self.max_in_flight;
97    let source_backend_address = self.backend_address.clone();
98
99    // Next prepare the finalize thread which will persist finished jobs to the DB. It owns the
100    // single receiver end of the bounded done channel (moved in here).
101    let finalize_backend_address = self.backend_address.clone();
102    let finalize_thread = thread::spawn(move || {
103      Finalize {
104        backend_address: finalize_backend_address,
105      }
106      .start(done_rx)
107      .unwrap_or_else(|e| panic!("Failed in finalize thread: {e:?}"));
108    });
109
110    // Now prepare the results sink
111    let result_port = self.result_port;
112    let result_queue_size = self.queue_size;
113    let result_message_size = self.message_size;
114    let result_backend_address = self.backend_address.clone();
115
116    let sink_services_arc = services_arc.clone();
117    let sink_sandboxes_arc = sandboxes_arc.clone();
118    let sink_progress_queue_arc = progress_queue_arc.clone();
119
120    let sink_done_tx = done_tx.clone();
121    let sink_metadata = metadata.clone();
122    let sink_dispatch_complete = dispatch_complete.clone();
123    let sink_thread = thread::spawn(move || {
124      Sink {
125        port: result_port,
126        queue_size: result_queue_size,
127        message_size: result_message_size,
128        backend_address: result_backend_address.clone(),
129        metadata: sink_metadata,
130      }
131      .start(
132        &sink_services_arc,
133        &sink_sandboxes_arc,
134        &sink_progress_queue_arc,
135        &sink_done_tx,
136        job_limit,
137        &sink_dispatch_complete,
138      )
139      .unwrap_or_else(|e| panic!("Failed in sink thread: {e:?}"));
140    });
141
142    // 09.2025, Currently the ventilator has some hard to reproduce fragility to empty messages
143    //          which necessitates a restart of the thread. If we can reproduce that better,
144    //          it may be possible to return to the previous single-threaded lifecycle.
145    loop {
146      let vent_services_arc = services_arc.clone();
147      let vent_sandboxes_arc = sandboxes_arc.clone();
148      let vent_progress_queue_arc = progress_queue_arc.clone();
149      let vent_done_tx = done_tx.clone();
150      let vent_backend_address = source_backend_address.clone();
151      let vent_metadata = metadata.clone();
152      let vent_dispatch_complete = dispatch_complete.clone();
153      let vent_thread = thread::spawn(move || {
154        let ventilator = Ventilator {
155          port: source_port,
156          queue_size: source_queue_size,
157          message_size: source_message_size,
158          max_in_flight: source_max_in_flight,
159          backend_address: vent_backend_address,
160          metadata: vent_metadata,
161        };
162        ventilator
163          .start(
164            &vent_services_arc,
165            &vent_sandboxes_arc,
166            &vent_progress_queue_arc,
167            &vent_done_tx,
168            job_limit,
169            &vent_dispatch_complete,
170          )
171          .unwrap_or_else(|e| panic!("Failed in ventilator thread: {e:?}"));
172      });
173      // Wait for the ventilator to return, but POLL rather than block — so a dead
174      // finalize/sink thread is detected even while the ventilator is actively leasing.
175      // Under load the ventilator can run indefinitely, so the plain blocking `join()`
176      // that used to be here starved the perpetual-mode health checks below: a
177      // finalize/sink thread that panicked (e.g. a `mark_done` DB bind-parameter
178      // overflow) left the pipeline silently wedged — workers converting into a dead
179      // sink forever — instead of aborting for a supervised restart. (2026-06-17.)
180      while !vent_thread.is_finished() {
181        if job_limit.is_none() {
182          if finalize_thread.is_finished() {
183            error!("Finalize (DB) thread died unexpectedly! Aborting for a supervised restart.");
184            return Err(zmq::Error::ETERM);
185          }
186          if sink_thread.is_finished() {
187            error!("Sink thread died unexpectedly! Aborting for a supervised restart.");
188            return Err(zmq::Error::ETERM);
189          }
190        }
191        sleep(Duration::from_millis(500));
192      }
193      if vent_thread.join().is_err() {
194        error!("Ventilator thread died unexpectedly!");
195        return Err(zmq::Error::ETERM);
196      }
197      if job_limit.is_some() {
198        break;
199      }
200      // Graceful shutdown (O-1): the ventilator returned because a SIGTERM/SIGINT set the flag (it
201      // set `dispatch_complete` on the way out, exactly like a bounded run finishing). Break
202      // to the drain path below — join the sink (it finishes the in-flight set), drop the
203      // done-sender, flush finalize, exit `Ok` — instead of restarting the ventilator. A
204      // dead-worker straggler whose result never returns is backstopped by the supervisor's
205      // stop-timeout SIGKILL; its task recovers via `clear_limbo_tasks_except` on the next
206      // start.
207      if crate::dispatcher::server::shutdown_requested() {
208        info!("Graceful shutdown requested — draining in-flight results, then stopping.");
209        break;
210      }
211      // Perpetual mode (`job_limit = None`, i.e. production): the sink and finalize threads are
212      // spawned **once** (only the ventilator is restart-looped), and this loop never reaches their
213      // joins below — so a sink/finalize that died (e.g. a panic on a DB runaway, or an unexpected
214      // result) would otherwise leave the pipeline **silently stalled**: results pile up
215      // unprocessed, the in-flight set saturates, the ventilator mock-replies forever, and nothing
216      // aborts. Surface it as the intended fail-fast — abort so the external supervisor restarts
217      // the whole dispatcher (CLAUDE.md "process abort → external restart"), rather than
218      // stall unnoticed. (In `job_limit` mode we already `break`ed above, so a *cleanly
219      // finished* sink/finalize is never mistaken for a death here.)
220      if sink_thread.is_finished() {
221        error!("Sink thread died unexpectedly! Aborting for a supervised restart.");
222        return Err(zmq::Error::ETERM);
223      }
224      if finalize_thread.is_finished() {
225        error!("Finalize (DB) thread died unexpectedly! Aborting for a supervised restart.");
226        return Err(zmq::Error::ETERM);
227      }
228      sleep(Duration::from_secs(1));
229    }
230    // Bounded run only (perpetual mode returns `ETERM` inside the loop and never reaches here). The
231    // ventilator has signalled completion; join the sink (it drains the in-flight set, then stops),
232    // then DROP the manager's done-channel sender so the finalize thread sees `Disconnected` — its
233    // clean shutdown — and persists the last batch before stopping. This shared completion
234    // handshake (dispatch_complete → sink drains → drop sender → finalize disconnects) replaced
235    // the three mismatched per-thread `job_limit` counters that used to deadlock (KNOWN_ISSUES
236    // D-5).
237    if sink_thread.join().is_err() {
238      error!("Sink thread died unexpectedly!");
239      return Err(zmq::Error::ETERM);
240    }
241    drop(done_tx);
242    if finalize_thread.join().is_err() {
243      error!("DB thread died unexpectedly!");
244      Err(zmq::Error::ETERM)
245    } else {
246      info!("Manager successfully terminated!");
247      Ok(())
248    }
249  }
250}