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}