Skip to main content

cortex/models/
historical_runs.rs

1#![allow(clippy::extra_unused_lifetimes)]
2use chrono::prelude::*;
3use diesel::result::Error;
4use diesel::*;
5use serde::Serialize;
6use std::collections::HashSet;
7
8// use super::messages::*;
9// use super::tasks::Task;
10// use crate::helpers::TaskStatus;
11use crate::backend::progress_report;
12use crate::concerns::CortexInsertable;
13use crate::helpers::TaskStatus;
14use crate::models::{Corpus, Service};
15use crate::schema::historical_runs;
16
17#[derive(Identifiable, Queryable, Associations, Clone, Debug, PartialEq, Eq, QueryableByName)]
18#[diesel(table_name = historical_runs)]
19#[diesel(belongs_to(Corpus, foreign_key = corpus_id))]
20#[diesel(belongs_to(Service, foreign_key = service_id))]
21/// Historical `(Corpus, Service)` run records
22pub struct HistoricalRun {
23  /// id of the historical run
24  pub id: i32,
25  /// id of the service owning this task
26  pub service_id: i32,
27  /// id of the corpus hosting this task
28  pub corpus_id: i32,
29  /// total tasks in run
30  pub total: i32,
31  /// invalid tasks in run
32  pub invalid: i32,
33  /// fatal results in run
34  pub fatal: i32,
35  /// error results in run
36  pub error: i32,
37  /// warning results in run
38  pub warning: i32,
39  /// results with no notable problems in run
40  pub no_problem: i32,
41  /// tasks still in progress at end of run
42  pub in_progress: i32,
43  /// start timestamp of run
44  pub start_time: NaiveDateTime,
45  /// end timestamp of run, i.e. timestamp of next run initiation
46  pub end_time: Option<NaiveDateTime>,
47  /// owner who initiated the run
48  pub owner: String,
49  /// description of the purpose of this run
50  pub description: String,
51  /// Stable external handle (UUIDv7, DB-generated) for referencing this specific run independently
52  /// of its (corpus, service, start_time) coordinates. Immutable once assigned (Arm 3 / D8).
53  pub public_id: uuid::Uuid,
54}
55
56#[derive(Debug, Serialize, Clone, Default)]
57/// A JSON-friendly data structure, used for the frontend reports
58pub struct RunMetadata {
59  /// total tasks in run
60  pub total: i32,
61  /// invalid tasks in run
62  pub invalid: i32,
63  /// fatak tasks in run
64  pub fatal: i32,
65  /// error tasks in run
66  pub error: i32,
67  /// warning tasks in run
68  pub warning: i32,
69  /// no_problem tasks in run
70  pub no_problem: i32,
71  /// in_progress tasks in run
72  pub in_progress: i32,
73  /// start time of run, formatted for a report
74  pub start_time: String,
75  /// end time of run, formatted for a report
76  pub end_time: String,
77  /// initiator of the run
78  pub owner: String,
79  /// description of the run
80  pub description: String,
81}
82impl RunMetadata {
83  /// f32 type cast for the run frequency fields
84  pub fn field_f32(&self, field: &str) -> f32 {
85    let field_i32 = match field {
86      "invalid" => self.invalid,
87      "total" => self.total,
88      "fatal" => self.fatal,
89      "error" => self.error,
90      "warning" => self.warning,
91      "no_problem" => self.no_problem,
92      "in_progress" => self.in_progress,
93      // An unknown field is a programming error (callers pass fixed keys), but this feeds the
94      // **public** `/history` chart — degrade to 0 rather than `panic!` on a request path
95      // (robustness: no panic where a `Result`/default will do).
96      _ => 0,
97    };
98    field_i32 as f32
99  }
100}
101
102#[derive(Debug, Serialize, Clone)]
103/// A JSON-friendly data structure, used for vega-lite Stack figures
104/// https://vega.github.io/vega-lite/docs/stack.html
105pub struct RunMetadataStack {
106  /// type of messages
107  pub severity: String,
108  /// raw severity index
109  pub severity_numeric: i32,
110  /// percent to total
111  pub percent: f32,
112  /// total number of jobs
113  pub total: i32,
114  /// start time of run, formatted for a report
115  pub start_time: String,
116  /// end time of run, formatted for a report
117  pub end_time: String,
118  /// initiator of the run
119  pub owner: String,
120  /// description of the run
121  pub description: String,
122}
123impl RunMetadataStack {
124  /// Transforms to a vega-lite Stack -near representation
125  pub fn transform(runs_meta: &[RunMetadata]) -> Vec<RunMetadataStack> {
126    let mut start_time_guard = HashSet::new();
127    let mut runs_meta_vega = Vec::new();
128    for run in runs_meta.iter() {
129      // skip extension runs from Vega report, too noisy
130      // Tip: rename the "description" field if you want the run included
131      if run.description == "extending corpus with more entries" {
132        continue;
133      }
134      // Avoid adding more than one run at a given start_time for the vega metadata stack,
135      // as vega wrongly combines the data into a single entry.
136      if !run.start_time.is_empty() && !run.end_time.is_empty() {
137        if start_time_guard.contains(&run.start_time) {
138          continue;
139        } else {
140          start_time_guard.insert(run.start_time.clone());
141        }
142      }
143      let total = run.field_f32("total");
144      // A run with no valid tasks (total 0 — all-invalid, or an open run before any completion) has
145      // no success-rate to plot, and `field / 0.0` yields NaN/Inf which `serde_json` cannot encode.
146      // Critically, that failure is on the WHOLE array — one such run would blank the *entire*
147      // chart (the serialized payload falls back to empty) — so skip it rather than poison
148      // the series.
149      if total <= 0.0 {
150        continue;
151      }
152      for field in ["fatal", "error", "warning", "no_problem", "in_progress"].iter() {
153        runs_meta_vega.push(RunMetadataStack {
154          severity: (*field).to_string(),
155          severity_numeric: TaskStatus::from_key(field).unwrap().raw(),
156          percent: (100.0 * run.field_f32(field)) / total,
157          total: run.total,
158          start_time: run.start_time.clone(),
159          end_time: run.end_time.clone(),
160          owner: run.owner.clone(),
161          description: run.description.clone(),
162        })
163      }
164    }
165    runs_meta_vega
166  }
167}
168
169#[derive(Insertable, Debug, Clone)]
170#[diesel(table_name = historical_runs)]
171/// A new task, to be inserted into `CorTeX`
172pub struct NewHistoricalRun {
173  /// id of the service owning this task
174  pub service_id: i32,
175  /// id of the corpus hosting this task
176  pub corpus_id: i32,
177  /// description of the purpose of this run
178  pub description: String,
179  /// owner who initiated the run
180  pub owner: String,
181}
182
183impl CortexInsertable for NewHistoricalRun {
184  fn create(&self, connection: &mut PgConnection) -> Result<usize, Error> {
185    insert_into(historical_runs::table)
186      .values(self)
187      .execute(connection)
188  }
189}
190
191impl HistoricalRun {
192  /// Obtain all historical runs for a given `(Corpus, Service)` pair
193  pub fn find_by(
194    corpus: &Corpus,
195    service: &Service,
196    connection: &mut PgConnection,
197  ) -> Result<Vec<HistoricalRun>, Error> {
198    use crate::schema::historical_runs::dsl::{corpus_id, service_id, start_time};
199    let runs: Vec<HistoricalRun> = historical_runs::table
200      .filter(corpus_id.eq(corpus.id))
201      .filter(service_id.eq(service.id))
202      .order(start_time.desc())
203      .get_results(connection)?;
204    Ok(runs)
205  }
206
207  /// The most recent historical runs across **all** corpora/services, newest first — the
208  /// system-wide run-management overview. Capped at `limit`.
209  pub fn recent_all(
210    connection: &mut PgConnection,
211    limit: i64,
212  ) -> Result<Vec<HistoricalRun>, Error> {
213    Self::recent_filtered(connection, None, None, None, limit)
214  }
215
216  /// The most recent historical runs, newest first, optionally narrowed to a `corpus_id`,
217  /// `service_id`, and/or exact `owner` — the filter-driven run-management overview. Capped at
218  /// `limit`. Any combination of filters may be `None` (no constraint on that field).
219  pub fn recent_filtered(
220    connection: &mut PgConnection,
221    corpus: Option<i32>,
222    service: Option<i32>,
223    owner: Option<&str>,
224    limit: i64,
225  ) -> Result<Vec<HistoricalRun>, Error> {
226    use crate::schema::historical_runs::dsl;
227    let mut query = dsl::historical_runs.into_boxed();
228    if let Some(corpus) = corpus {
229      query = query.filter(dsl::corpus_id.eq(corpus));
230    }
231    if let Some(service) = service {
232      query = query.filter(dsl::service_id.eq(service));
233    }
234    if let Some(owner) = owner {
235      query = query.filter(dsl::owner.eq(owner.to_string()));
236    }
237    query
238      .order(dsl::start_time.desc())
239      .limit(limit)
240      .get_results(connection)
241  }
242
243  /// Obtain a currently ongoing run entry for a  `(Corpus, Service)` pair, if any
244  pub fn find_current(
245    corpus: &Corpus,
246    service: &Service,
247    connection: &mut PgConnection,
248  ) -> Result<Option<HistoricalRun>, Error> {
249    use crate::schema::historical_runs::dsl::{corpus_id, end_time, service_id};
250    historical_runs::table
251      .filter(corpus_id.eq(corpus.id))
252      .filter(service_id.eq(service.id))
253      .filter(end_time.is_null())
254      .first(connection)
255      .optional()
256  }
257
258  /// Mark this historical run as completed: freeze its live tallies and set `end_time`, as an
259  /// **atomic compare-and-set** (`WHERE id = ? AND end_time IS NULL`). Exactly one caller can ever
260  /// close a given run — even under duplicate finalize passes (a re-tried last task that lands
261  /// twice) or, hypothetically, concurrent writers — because the open-row predicate lives in the
262  /// UPDATE itself, not in a separate read. Returns `true` iff **this** call performed the close
263  /// (the UPDATE matched the still-open row); `false` if the run was already closed (a no-op). That
264  /// `true` is the safe edge to hang any run-completion side effect on: it can never double-fire.
265  pub fn mark_completed(&self, connection: &mut PgConnection) -> Result<bool, Error> {
266    use diesel::dsl::now;
267    // Fast path: a run we already know is closed needs no tally recompute. The atomic UPDATE below
268    // is the real guard — this only skips wasted work in the already-closed case.
269    if self.end_time.is_some() {
270      return Ok(false);
271    }
272    // Freeze the current statistics, then close — but the `end_time IS NULL` filter means a racing
273    // (or duplicate) close matches zero rows and is a clean no-op, never a re-mark.
274    let t = live_tally_fields(connection, self.corpus_id, self.service_id);
275    let rows = update(
276      historical_runs::table
277        .filter(historical_runs::id.eq(self.id))
278        .filter(historical_runs::end_time.is_null()),
279    )
280    .set((
281      historical_runs::end_time.eq(now),
282      historical_runs::total.eq(t.total),
283      historical_runs::in_progress.eq(t.in_progress),
284      historical_runs::invalid.eq(t.invalid),
285      historical_runs::no_problem.eq(t.no_problem),
286      historical_runs::warning.eq(t.warning),
287      historical_runs::error.eq(t.error),
288      historical_runs::fatal.eq(t.fatal),
289    ))
290    .execute(connection)?;
291    Ok(rows == 1)
292  }
293
294  /// **Run-completion-on-drain.** Close the open historical run for a `(corpus, service)` pair the
295  /// moment its work is exhausted — every task has reached a terminal severity, with nothing left
296  /// `TODO`, in-flight (`Queued`), or `Blocked` on a prerequisite. Freezes the run's tallies and
297  /// sets `end_time` (via [`Self::mark_completed`]), so a finished run stops reading as "ongoing"
298  /// *immediately*, instead of only when the next rerun supersedes it (the lazy
299  /// [`mark_new_run`](crate::backend::mark_new_run) path that left a run "ongoing" until the very
300  /// minute the next run started). Returns `true` iff a run was actually closed.
301  ///
302  /// Idempotent and safe to call repeatedly from the dispatcher's finalize loop: a no-op when work
303  /// still remains, when there is no open run (already closed, or never started), or for the
304  /// bookkeeping services `init` (1) / `import` (2) — those are not user-facing conversion runs and
305  /// are skipped.
306  pub fn complete_if_drained(
307    corpus: i32,
308    service: i32,
309    connection: &mut PgConnection,
310  ) -> Result<bool, Error> {
311    use crate::schema::historical_runs::dsl::{corpus_id, end_time, service_id};
312    use crate::schema::tasks;
313    // `init` (1) / `import` (2) are bookkeeping services, not conversion runs — no run to close.
314    if service <= 2 {
315      return Ok(false);
316    }
317    // Unfinished = anything OUTSIDE the terminal severity band [-5, -1]: `TODO` (0) and `Queued`
318    // (> 0) still to run, plus `Blocked` (< -5) gated on a prerequisite service. While any of these
319    // remain the run can still produce a result, so it is NOT drained. (Mirror of the raw status
320    // mapping in `TaskStatus::from_raw`.)
321    let unfinished: i64 = tasks::table
322      .filter(tasks::corpus_id.eq(corpus))
323      .filter(tasks::service_id.eq(service))
324      .filter(tasks::status.ge(0).or(tasks::status.lt(-5)))
325      .count()
326      .get_result(connection)?;
327    if unfinished > 0 {
328      return Ok(false);
329    }
330    // Drained: freeze + close the pair's open run, if one is still open.
331    let open: Option<HistoricalRun> = historical_runs::table
332      .filter(corpus_id.eq(corpus))
333      .filter(service_id.eq(service))
334      .filter(end_time.is_null())
335      .first(connection)
336      .optional()?;
337    // `mark_completed` is the atomic compare-and-set: it returns `true` only if THIS call closed
338    // the run, so a duplicate drain pass (or a racing writer) that finds the row already closed
339    // propagates as `false` here — never a second completion.
340    match open {
341      Some(run) => run.mark_completed(connection),
342      None => Ok(false),
343    }
344  }
345
346  /// Returns this run with its per-severity tallies reflecting **live** task state *when the run is
347  /// still open* (`end_time` is `None`). A run only freezes its tallies at [`Self::mark_completed`]
348  /// (i.e. when the next run supersedes it), so an open run's stored tallies are all zero;
349  /// overlaying the current progress makes the in-progress run show its real state across the
350  /// run-management surfaces — the "live + historical run state" north star — instead of a
351  /// misleading row of zeros (most visible on the *current* run and the dashboard's "last run").
352  /// A completed run is returned unchanged: its frozen snapshot is authoritative and this is a
353  /// no-op (no extra query). Cost is one grouped `progress_report` per *open* run only.
354  #[must_use]
355  pub fn with_live_tallies(mut self, connection: &mut PgConnection) -> HistoricalRun {
356    if self.end_time.is_none() {
357      let t = live_tally_fields(connection, self.corpus_id, self.service_id);
358      self.total = t.total;
359      self.no_problem = t.no_problem;
360      self.warning = t.warning;
361      self.error = t.error;
362      self.fatal = t.fatal;
363      self.invalid = t.invalid;
364      self.in_progress = t.in_progress;
365    }
366    self
367  }
368
369  /// Overlays live tallies onto every **open** run in `runs` using a **single** batched query,
370  /// replacing the per-open-run [`with_live_tallies`](Self::with_live_tallies) N+1 that made the
371  /// system-wide run overview cost one `progress_report` per open run (O(open runs) — a real drag
372  /// when many corpora convert at once). Completed runs keep their frozen tallies untouched. Use
373  /// this for any **multi-`(corpus, service)`** run list; a single-pair list (≤1 open run) can
374  /// keep `with_live_tallies`.
375  #[must_use]
376  pub fn overlay_live_tallies(
377    mut runs: Vec<HistoricalRun>,
378    connection: &mut PgConnection,
379  ) -> Vec<HistoricalRun> {
380    let pairs: Vec<(i32, i32)> = runs
381      .iter()
382      .filter(|run| run.end_time.is_none())
383      .map(|run| (run.corpus_id, run.service_id))
384      .collect::<HashSet<_>>()
385      .into_iter()
386      .collect();
387    if pairs.is_empty() {
388      return runs; // no open runs → nothing live to overlay
389    }
390    let tallies = live_tally_fields_batch(connection, &pairs);
391    for run in runs.iter_mut().filter(|run| run.end_time.is_none()) {
392      if let Some(t) = tallies.get(&(run.corpus_id, run.service_id)) {
393        run.total = t.total;
394        run.no_problem = t.no_problem;
395        run.warning = t.warning;
396        run.error = t.error;
397        run.fatal = t.fatal;
398        run.invalid = t.invalid;
399        run.in_progress = t.in_progress;
400      }
401    }
402    runs
403  }
404}
405
406/// The per-severity tallies of a `(corpus, service)` at a point in time.
407#[derive(Default)]
408struct RunTallies {
409  total: i32,
410  no_problem: i32,
411  warning: i32,
412  error: i32,
413  fatal: i32,
414  invalid: i32,
415  in_progress: i32,
416}
417
418/// Computes the live per-severity tallies for a `(corpus, service)` from the **current** task
419/// status distribution — the single source of truth shared by [`HistoricalRun::mark_completed`]
420/// (which freezes it) and [`HistoricalRun::with_live_tallies`] (which overlays it on an open run),
421/// so the in-progress numbers are identical to what gets frozen at completion. `in_progress` folds
422/// the not-yet-finished `todo` + `queued` tasks; `total` excludes invalids (as `progress_report`
423/// does).
424fn live_tally_fields(connection: &mut PgConnection, corpus_id: i32, service_id: i32) -> RunTallies {
425  let report = progress_report(connection, corpus_id, service_id);
426  let get = |key: &str| *report.get(key).unwrap_or(&0.0);
427  RunTallies {
428    total: get("total") as i32,
429    no_problem: get("no_problem") as i32,
430    warning: get("warning") as i32,
431    error: get("error") as i32,
432    fatal: get("fatal") as i32,
433    invalid: get("invalid") as i32,
434    in_progress: (get("queued") + get("todo")) as i32,
435  }
436}
437
438/// Live tallies for **several** `(corpus, service)` pairs in **one** grouped query — the batched
439/// twin of [`live_tally_fields`], so overlaying live progress onto a list of open runs costs one DB
440/// round-trip instead of one per run (the N+1 that made the system-wide run overview O(open runs);
441/// see [`HistoricalRun::overlay_live_tallies`]). The `ANY` filters may select cross pairs that were
442/// not requested; those are dropped via `wanted`, so the result is exact. Folds the status
443/// distribution exactly as [`live_tally_fields`]/`progress_report` do (total excludes invalids;
444/// `in_progress` = the unfinished `todo` + `queued` leases).
445fn live_tally_fields_batch(
446  connection: &mut PgConnection,
447  pairs: &[(i32, i32)],
448) -> std::collections::HashMap<(i32, i32), RunTallies> {
449  use crate::schema::tasks::dsl::{corpus_id, service_id, status, tasks};
450  use diesel::dsl::sql;
451  use diesel::sql_types::BigInt;
452  let mut out: std::collections::HashMap<(i32, i32), RunTallies> = std::collections::HashMap::new();
453  if pairs.is_empty() {
454    return out;
455  }
456  let wanted: HashSet<(i32, i32)> = pairs.iter().copied().collect();
457  let corpus_ids: Vec<i32> = wanted.iter().map(|(corpus, _)| *corpus).collect();
458  let service_ids: Vec<i32> = wanted.iter().map(|(_, service)| *service).collect();
459  // Raw `count(*)` (as `progress_report` does) sidesteps Diesel's aggregate/group-by type check
460  // while staying parameterized everywhere else.
461  let rows: Vec<(i32, i32, i32, i64)> = tasks
462    .select((corpus_id, service_id, status, sql::<BigInt>("count(*)")))
463    .filter(corpus_id.eq_any(&corpus_ids))
464    .filter(service_id.eq_any(&service_ids))
465    .group_by((corpus_id, service_id, status))
466    .load(connection)
467    .unwrap_or_default();
468  for (corpus, service, raw_status, count) in rows {
469    if !wanted.contains(&(corpus, service)) {
470      continue; // an over-selected cross pair (ANY × ANY) we did not ask for
471    }
472    let count = count as i32;
473    let tally = out.entry((corpus, service)).or_default();
474    let task_status = TaskStatus::from_raw(raw_status);
475    if task_status != TaskStatus::Invalid {
476      tally.total += count; // total excludes only invalids (matches progress_report)
477    }
478    match task_status {
479      TaskStatus::NoProblem => tally.no_problem += count,
480      TaskStatus::Warning => tally.warning += count,
481      TaskStatus::Error => tally.error += count,
482      TaskStatus::Fatal => tally.fatal += count,
483      TaskStatus::Invalid => tally.invalid += count,
484      // unfinished work: TODO (0) and any positive lease mark (Queued) fold into in_progress
485      TaskStatus::TODO => tally.in_progress += count,
486      _ if raw_status > TaskStatus::TODO.raw() => tally.in_progress += count,
487      _ => {}, // Blocked and other negatives count toward total only, as above
488    }
489  }
490  out
491}
492
493impl From<HistoricalRun> for RunMetadata {
494  fn from(run: HistoricalRun) -> RunMetadata {
495    let HistoricalRun {
496      total,
497      warning,
498      error,
499      no_problem,
500      invalid,
501      fatal,
502      start_time,
503      end_time,
504      description,
505      in_progress,
506      owner,
507      ..
508    } = run;
509    RunMetadata {
510      total,
511      invalid,
512      fatal,
513      warning,
514      error,
515      no_problem,
516      in_progress,
517      // Full RFC-3339 UTC timestamp, NOT date-only. The `/history` Vega chart keys each run by
518      // `start_time` at minute granularity (`timeUnit: yearmonthdatehoursminutes`) and
519      // `RunMetadataStack::transform` dedups by this exact string. With date-only granularity every
520      // same-day run shared the key "YYYY-MM-DD", so the dedup collapsed them to a single bar
521      // (e.g. 28 runs on one day → 1 point) and the table's time columns were indistinguishable.
522      // Seconds-precision RFC-3339 matches RunDto's `iso_utc` rendering (zone-unambiguous).
523      start_time: start_time
524        .and_utc()
525        .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
526      end_time: match end_time {
527        Some(etime) => etime
528          .and_utc()
529          .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
530        None => String::new(),
531      },
532      owner,
533      description,
534    }
535  }
536}
537
538#[cfg(test)]
539mod tests {
540  use super::{RunMetadata, RunMetadataStack};
541
542  /// A zero-total run (all-invalid, or an open run before any completion) must not blank the whole
543  /// history chart: it is skipped (no `field / 0.0` NaN/Inf percents, which would fail `serde_json`
544  /// on the *whole array* and empty the series), while runs with valid tasks still chart.
545  #[test]
546  fn transform_skips_zero_total_runs_so_one_doesnt_blank_the_chart() {
547    let zero_total = RunMetadata {
548      invalid: 5, // all-invalid → total 0 (the total excludes invalids)
549      start_time: "2026-01-01 00:00:00".to_string(),
550      end_time: "2026-01-01 01:00:00".to_string(),
551      ..Default::default()
552    };
553    let real = RunMetadata {
554      total: 4,
555      warning: 1,
556      no_problem: 3,
557      start_time: "2026-01-02 00:00:00".to_string(),
558      end_time: "2026-01-02 01:00:00".to_string(),
559      ..Default::default()
560    };
561    let stack = RunMetadataStack::transform(&[zero_total, real]);
562    assert!(!stack.is_empty(), "the valid run still charts");
563    assert!(
564      stack.iter().all(|row| row.percent.is_finite()),
565      "no NaN/Inf percents — a zero-total run is skipped, never divided by zero"
566    );
567    assert!(
568      serde_json::to_string(&stack).is_ok(),
569      "the series serializes — the bug was serde failing on a NaN and blanking the chart"
570    );
571  }
572}