cortex/dispatcher/finalize.rs
1use crate::backend;
2use crate::dispatcher::server;
3use crate::helpers::TaskReport;
4use std::collections::HashSet;
5use std::error::Error;
6use std::sync::mpsc::{Receiver, RecvTimeoutError};
7use std::time::{Duration, Instant};
8use tracing::{debug, warn};
9
10/// Upper bound on how stale the `report_summary` rollup may get while a run is in flight. A single
11/// conversion run can take weeks, so an event-only refresh (on drain) is not enough — we also
12/// recompute the rollup at least this often during continuous processing. Runtime-configurable via
13/// `config().dispatcher.report_refresh_interval_seconds` (the automatic freshness guarantee;
14/// default 1h, cheap now that the refresh is non-blocking — `CONCURRENTLY`).
15fn report_refresh_interval() -> Duration {
16 Duration::from_secs(
17 crate::config::config()
18 .dispatcher
19 .report_refresh_interval_seconds,
20 )
21}
22
23/// Specifies the binding and operation parameters for a thread that saves finalized tasks to the DB
24pub struct Finalize {
25 /// the DB address to bind on
26 pub backend_address: String,
27}
28
29/// Accumulates a finalize batch off the bounded done channel: starting from `first`, keep pulling
30/// reports until the batch reaches `batch_size` (N) **or** `flush_window` (T) elapses since `first`
31/// landed — whichever fires first. Returns the batch and whether the channel **disconnected**
32/// mid-accumulation (all producers gone → shutdown).
33///
34/// This is the heart of the phase-2 DB coalescing knob: rather than flushing the instant a result
35/// lands (one DB write per result under light load), it deliberately groups writes — fewer, bigger
36/// transactions — while bounding the wait (and thus report staleness + crash re-work) to T. It is
37/// loss-free: an unflushed batch is never *lost*; its tasks remain `Queued` and recover on restart.
38/// Pure (no DB, no I/O), so its size-vs-time flush logic is unit-tested directly.
39fn accumulate_batch(
40 done_rx: &Receiver<TaskReport>,
41 first: TaskReport,
42 batch_size: usize,
43 flush_window: Duration,
44) -> (Vec<TaskReport>, bool) {
45 let batch_start = Instant::now();
46 let mut batch = vec![first];
47 let mut disconnected = false;
48 while batch.len() < batch_size {
49 let elapsed = batch_start.elapsed();
50 if elapsed >= flush_window {
51 break;
52 }
53 match done_rx.recv_timeout(flush_window - elapsed) {
54 Ok(report) => batch.push(report),
55 Err(RecvTimeoutError::Timeout) => break,
56 Err(RecvTimeoutError::Disconnected) => {
57 disconnected = true;
58 break;
59 },
60 }
61 }
62 (batch, disconnected)
63}
64
65impl Finalize {
66 /// Start the finalize loop: block on the bounded done channel for the first report of a batch,
67 /// then **accumulate** more via [`accumulate_batch`] until the size (N,
68 /// `finalize_batch_size`) or time (T, `finalize_flush_ms`) threshold trips, and persist the
69 /// whole batch in one `mark_done` transaction. This is **event-driven** (woken the instant a
70 /// result lands), **backpressured** by the bounded channel (not the old `Mutex<Vec>` +
71 /// panic-backstop), and **coalesced** so DB write frequency stays bounded under load (phase 2).
72 /// The outer `recv_timeout(1s)` preserves the 1s idle cadence for the refresh-on-drain. Shutdown
73 /// is driven entirely by `Disconnected` (every producer's done-sender dropped): in a bounded run
74 /// the manager drops the last sender once the ventilator + sink have finished, and the finalize
75 /// thread then drains the remaining reports and stops (KNOWN_ISSUES D-5 — no per-thread
76 /// `job_limit` counter, which was the unit that disagreed with the other two threads).
77 pub fn start(&self, done_rx: Receiver<TaskReport>) -> Result<(), Box<dyn Error>> {
78 let mut backend = backend::from_address(&self.backend_address);
79 let dispatcher = &crate::config::config().dispatcher;
80 let batch_size = dispatcher.finalize_batch_size.max(1);
81 let flush_window = Duration::from_millis(dispatcher.finalize_flush_ms);
82 let mut jobs_count: usize = 0;
83 // Whether finalized work has landed since the report rollup was last refreshed, and when that
84 // refresh happened — together these drive a "refresh on drain, but at least daily" cadence.
85 let mut reports_dirty = false;
86 let mut last_report_refresh = Instant::now();
87 // Distinct (corpus_id, service_id) scopes whose tasks we've persisted since the last report
88 // invalidation. On drain / periodic tick we drop ONLY these scopes' cached report grains (a
89 // cheap keyed DELETE), so each repopulates lazily on its next view — never a global scan.
90 let mut touched: HashSet<(i32, i32)> = HashSet::new();
91 loop {
92 match done_rx.recv_timeout(Duration::from_secs(1)) {
93 Ok(first) => {
94 // Coalesce a batch (up to N reports, or T elapsed) into one DB round-trip.
95 let (batch, disconnected) = accumulate_batch(&done_rx, first, batch_size, flush_window);
96 let batch_len = batch.len();
97 let persist_start = Instant::now();
98 server::mark_done_batch(&mut backend, &batch)?;
99 jobs_count += 1;
100 reports_dirty = true;
101 for report in &batch {
102 touched.insert((report.task.corpus_id, report.task.service_id));
103 }
104 // Pipeline health signals (Arm 8 / phase-5 observability; transport-independent): batch
105 // size, DB persist latency, and whether the batch hit the size cap. `size_capped` is the
106 // backpressure/lag proxy — the bounded done channel already had a full batch queued (std
107 // `sync_channel` can't expose its depth), i.e. the DB finalize is the bottleneck and lag
108 // is building. At `debug` (one event per batch ⇒ off at the default `info`; enable on
109 // demand with `RUST_LOG=cortex=debug`).
110 debug!(
111 batch = batch_len,
112 persist_ms = persist_start.elapsed().as_millis() as u64,
113 size_capped = batch_len >= batch_size,
114 batches_total = jobs_count,
115 "finalize: persisted batch"
116 );
117 // Long runs may never idle, so bound report staleness with a periodic refresh.
118 if last_report_refresh.elapsed() >= report_refresh_interval() {
119 settle_touched(&mut backend, &mut touched);
120 reports_dirty = false;
121 last_report_refresh = Instant::now();
122 }
123 if disconnected {
124 // Producers vanished mid-batch: we persisted what we had; make it visible and stop.
125 if reports_dirty {
126 settle_touched(&mut backend, &mut touched);
127 }
128 break;
129 }
130 },
131 Err(RecvTimeoutError::Timeout) => {
132 // The queue just idled: close any historical run whose work has drained, recompute the
133 // rollup so finished work shows up in reports immediately, then keep waiting.
134 if reports_dirty {
135 settle_touched(&mut backend, &mut touched);
136 reports_dirty = false;
137 last_report_refresh = Instant::now();
138 }
139 },
140 Err(RecvTimeoutError::Disconnected) => {
141 // Every producer (sink + ventilator) has gone — clean shutdown. Flush and stop.
142 if reports_dirty {
143 settle_touched(&mut backend, &mut touched);
144 }
145 break;
146 },
147 }
148 }
149 Ok(())
150 }
151}
152
153/// On drain/idle, settle the `(corpus, service)` scopes touched since the last tick: first close
154/// any historical runs whose work has drained (run-completion-on-drain), then drop those scopes'
155/// cached report grains so the next report view repopulates. Order matters — closing the run
156/// freezes its tallies, and we invalidate the report cache *after* so the next view reads the
157/// frozen, completed run rather than the stale open one. Drains `touched`.
158fn settle_touched(backend: &mut backend::Backend, touched: &mut HashSet<(i32, i32)>) {
159 complete_drained_runs(backend, touched);
160 invalidate_touched(backend, touched);
161}
162
163/// Run-completion-on-drain: for each scope we've persisted results for since the last tick, close
164/// its open historical run if the pair's work is now exhausted (no task left `TODO`, `Queued`, or
165/// `Blocked`). This fires the "run ended" event the instant the queue drains, instead of lazily
166/// when the next rerun starts — so a finished run stops showing as "ongoing" right away. Read-only
167/// over `touched` (the following [`invalidate_touched`] drains it). A per-scope failure is logged
168/// and skipped so the finalize thread keeps running; the next idle/periodic tick retries.
169fn complete_drained_runs(backend: &mut backend::Backend, touched: &HashSet<(i32, i32)>) {
170 for &(corpus_id, service_id) in touched {
171 match backend.complete_run_if_drained(corpus_id, service_id) {
172 Ok(true) => debug!(corpus_id, service_id, "finalize: closed drained run"),
173 Ok(false) => {},
174 Err(e) => warn!(
175 corpus_id,
176 service_id,
177 error = ?e,
178 "finalize: run-completion check failed (non-fatal)"
179 ),
180 }
181 }
182}
183
184/// Drop the cached report grains for the `(corpus, service)` scopes touched since the last
185/// invalidation (drains `touched`). DELETE-only and per-scope — the finalize thread does no heavy
186/// scan; each scope's report repopulates lazily on its next view. A failure (e.g. a transient lock)
187/// must not take down the finalize thread — log it and carry on; the next tick retries.
188fn invalidate_touched(backend: &mut backend::Backend, touched: &mut HashSet<(i32, i32)>) {
189 for (corpus_id, service_id) in touched.drain() {
190 if let Err(e) = backend.invalidate_report_cache(corpus_id, service_id) {
191 warn!(
192 corpus_id,
193 service_id,
194 error = ?e,
195 "finalize: report-cache invalidate failed (non-fatal)"
196 );
197 }
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crate::helpers::TaskStatus;
205 use crate::models::Task;
206 use std::sync::mpsc::sync_channel;
207
208 fn report(id: i64) -> TaskReport {
209 TaskReport {
210 task: Task {
211 id,
212 service_id: 2,
213 corpus_id: 1,
214 status: 0,
215 entry: String::new(),
216 },
217 status: TaskStatus::NoProblem,
218 messages: Vec::new(),
219 }
220 }
221
222 #[test]
223 fn batch_flushes_at_the_size_threshold_without_waiting() {
224 // With more than N reports already queued, the batch fills to exactly N and returns at once —
225 // it must NOT block for the (long) time window. This is the under-load path: bounded batches,
226 // no added latency.
227 let (tx, rx) = sync_channel::<TaskReport>(100);
228 for id in 1..=5 {
229 tx.send(report(id)).unwrap();
230 }
231 let first = rx.recv().unwrap(); // id 1, as the outer loop would have taken it
232 let start = Instant::now();
233 let (batch, disconnected) = accumulate_batch(&rx, first, 3, Duration::from_secs(30));
234 assert_eq!(batch.len(), 3, "fills to exactly the size threshold N");
235 assert!(!disconnected);
236 assert!(
237 start.elapsed() < Duration::from_secs(1),
238 "must return immediately on the size threshold, not wait out the window"
239 );
240 // The surplus stays queued for the next batch (loss-free hand-off).
241 assert_eq!(rx.try_iter().count(), 2);
242 }
243
244 #[test]
245 fn batch_flushes_at_the_time_threshold_when_under_n() {
246 // Under N reports and no more arriving: the batch flushes when the time window T elapses,
247 // bounding staleness. (Short T keeps the test fast.)
248 let (_tx, rx) = sync_channel::<TaskReport>(100);
249 let start = Instant::now();
250 let (batch, disconnected) = accumulate_batch(&rx, report(1), 512, Duration::from_millis(60));
251 assert_eq!(
252 batch.len(),
253 1,
254 "flushes the lone report at the time threshold"
255 );
256 assert!(!disconnected);
257 assert!(
258 start.elapsed() >= Duration::from_millis(60),
259 "waited out the time window before flushing"
260 );
261 }
262
263 #[test]
264 fn batch_signals_disconnect_when_producers_drop_mid_accumulation() {
265 // All producers gone while we are still under N and within T: accumulation ends early and
266 // signals shutdown, with whatever was collected so far still returned (persisted, not lost).
267 let (tx, rx) = sync_channel::<TaskReport>(100);
268 tx.send(report(2)).unwrap();
269 drop(tx);
270 let (batch, disconnected) = accumulate_batch(&rx, report(1), 512, Duration::from_secs(30));
271 assert_eq!(batch.len(), 2, "the first report plus the one still queued");
272 assert!(
273 disconnected,
274 "a dropped sender ends accumulation as a shutdown"
275 );
276 }
277}