Skip to main content

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}