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}