Skip to main content

cortex/backend/
reports.rs

1use chrono::NaiveDateTime;
2use diesel::dsl::sql;
3use diesel::*;
4use regex::Regex;
5use std::collections::HashMap;
6use std::sync::{LazyLock, Mutex};
7use std::time::{Duration, Instant};
8
9use super::rollup;
10use crate::frontend::helpers::severity_highlight;
11use crate::helpers::TaskStatus;
12use crate::models::{
13  Corpus, DiffStatusFilter, DiffStatusRow, HistoricalTask, LogError, LogFatal, LogInfo, LogInvalid,
14  LogRecord, LogWarning, Service, Task, TaskRunMetadata,
15};
16use crate::reports::{AggregateReport, TaskDetailReport};
17use crate::schema::tasks;
18
19static TASK_REPORT_NAME_REGEX: LazyLock<Regex> =
20  LazyLock::new(|| Regex::new(r"^.+/(.+)\..+$").unwrap());
21
22/// Live run-diff aggregate: of the tasks **completed so far** in the current run, how many
23/// improved / regressed / stayed the same vs their outcome in the previous run's baseline snapshot
24/// (`historical_tasks`, captured at the prior run's completion-on-drain). A coarse early-divergence
25/// signal — "is this run tracking the last one, or diverging?" — readable before the run finishes.
26#[derive(Clone, Debug, Default)]
27pub struct LiveRunDiff {
28  /// completed tasks whose current outcome is *better* than the baseline (status int higher)
29  pub improved: i64,
30  /// completed tasks whose current outcome is *worse* than the baseline (status int lower) —
31  /// excludes `Invalid` transitions (those are `reclassified`, not a quality regression)
32  pub regressed: i64,
33  /// completed tasks at the same outcome as the baseline
34  pub unchanged: i64,
35  /// completed tasks where one side is `Invalid` (unprocessable input) and the other is not — a
36  /// reclassification (e.g. a source that became / stopped being convertible), NOT a quality
37  /// improvement or regression, so it gets its own bucket rather than masquerading as one.
38  pub reclassified: i64,
39}
40impl LiveRunDiff {
41  /// Total completed tasks that had a baseline to compare against.
42  pub fn compared(&self) -> i64 {
43    self.improved + self.regressed + self.unchanged + self.reclassified
44  }
45}
46
47#[derive(QueryableByName)]
48struct DiffRow {
49  #[diesel(sql_type = diesel::sql_types::BigInt)]
50  improved: i64,
51  #[diesel(sql_type = diesel::sql_types::BigInt)]
52  regressed: i64,
53  #[diesel(sql_type = diesel::sql_types::BigInt)]
54  unchanged: i64,
55  #[diesel(sql_type = diesel::sql_types::BigInt)]
56  reclassified: i64,
57}
58
59/// Process-local read-through cache for [`live_run_diff`]. The diff is a join (`tasks` ⋈ the
60/// baseline snapshot) — the only *expensive* part of the report — so compute it at most once per
61/// `LIVE_DIFF_TTL` per scope and serve every page load from the memo. This decouples the heavy
62/// compute from the frequent reads (the report is loaded tens of times/min by collaborators), so
63/// read volume can't load the DB. The headline severity counts stay LIVE (a cheap grouped count);
64/// only this join earns a cache. Bounded staleness (≤ TTL) is acceptable — a live-diff is an early
65/// *trend*, not an exact figure.
66/// `(corpus_id, service_id)` → (computed-at, diff). Process-local memo behind [`LIVE_DIFF_CACHE`].
67type LiveDiffCache = Mutex<HashMap<(i32, i32), (Instant, LiveRunDiff)>>;
68static LIVE_DIFF_CACHE: LazyLock<LiveDiffCache> = LazyLock::new(|| Mutex::new(HashMap::new()));
69const LIVE_DIFF_TTL: Duration = Duration::from_secs(5);
70
71/// Read-through-cached [`compute_live_run_diff`] (see [`LIVE_DIFF_CACHE`]).
72pub fn live_run_diff(
73  connection: &mut PgConnection,
74  corpus_id: i32,
75  service_id: i32,
76) -> LiveRunDiff {
77  let key = (corpus_id, service_id);
78  {
79    // Recover a poisoned lock rather than panic — a stale diff is harmless.
80    let cache = LIVE_DIFF_CACHE.lock().unwrap_or_else(|e| e.into_inner());
81    match cache.get(&key) {
82      Some((at, diff)) if at.elapsed() < LIVE_DIFF_TTL => return diff.clone(),
83      _ => {},
84    }
85    // Lock dropped here, BEFORE the DB query — never hold it across the join.
86  }
87  let diff = compute_live_run_diff(connection, corpus_id, service_id);
88  let mut cache = LIVE_DIFF_CACHE.lock().unwrap_or_else(|e| e.into_inner());
89  cache.insert(key, (Instant::now(), diff.clone()));
90  diff
91}
92
93/// Of the tasks completed so far (`status < 0`), compare each to its status in the baseline
94/// snapshot. Severity ints are ordered best→worst (`no_problem` -1 … `invalid` -5), so a *higher*
95/// current status than the baseline is an improvement, lower a regression. This join is the cached
96/// cost.
97///
98/// **Baseline = the most-recent snapshot taken STRICTLY BEFORE the current run started**
99/// (`h.saved_at < max(historical_runs.start_time)` for the scope), NOT simply the latest snapshot.
100/// This is the fix for the "−0 regressed · +0 improved · all unchanged" idle-scope bug: the
101/// completion-on-drain path (`backend::complete_run_if_drained`) snapshots a run's OWN outcomes at
102/// the moment it finishes, as the baseline for the *next* run. Once a run completes and the scope
103/// goes idle, the live `tasks` table and that latest snapshot are the SAME run, so a naive
104/// "latest snapshot" baseline compares the run against itself → a vacuous all-unchanged diff.
105/// Anchoring on the current run's `start_time` excludes that self-snapshot (it was saved at this
106/// run's *end*, after its *start*) and falls back to the prior run's snapshot — the true "vs last
107/// run". It is uniform: while a run is in progress its self-snapshot does not exist yet, so nothing
108/// is excluded and the in-progress early-divergence signal is unchanged; and a genuine no-op rerun
109/// still reads 0/0 vs the real last run rather than silently walking back to an older one. The
110/// `COALESCE(..., 'infinity')` preserves legacy behaviour for a scope with manual snapshots but no
111/// run boundary (no `historical_runs` row) — there, all snapshots remain eligible. `DISTINCT ON`
112/// then picks each task's most-recent *eligible* snapshot.
113fn compute_live_run_diff(
114  connection: &mut PgConnection,
115  corpus_id: i32,
116  service_id: i32,
117) -> LiveRunDiff {
118  // Invalid (-5) is "unprocessable input", not a quality grade — so an Invalid transition is
119  // counted as `reclassified`, never improved/regressed (it would otherwise read as the worst
120  // regression by raw int order). improved/regressed compare only NON-invalid outcomes.
121  let row: Option<DiffRow> = sql_query(
122    "SELECT \
123       count(*) FILTER (WHERE t.status <> b.status AND t.status <> -5 AND b.status <> -5 AND t.status > b.status)::bigint AS improved, \
124       count(*) FILTER (WHERE t.status <> b.status AND t.status <> -5 AND b.status <> -5 AND t.status < b.status)::bigint AS regressed, \
125       count(*) FILTER (WHERE t.status = b.status)::bigint AS unchanged, \
126       count(*) FILTER (WHERE t.status <> b.status AND (t.status = -5 OR b.status = -5))::bigint AS reclassified \
127     FROM tasks t \
128     JOIN ( \
129       SELECT DISTINCT ON (h.task_id) h.task_id, h.status \
130       FROM historical_tasks h JOIN tasks tt ON tt.id = h.task_id \
131       WHERE tt.corpus_id = $1 AND tt.service_id = $2 \
132         AND h.saved_at < COALESCE( \
133           (SELECT max(start_time) FROM historical_runs \
134            WHERE corpus_id = $1 AND service_id = $2), 'infinity'::timestamp) \
135       ORDER BY h.task_id, h.saved_at DESC \
136     ) b ON b.task_id = t.id \
137     WHERE t.corpus_id = $1 AND t.service_id = $2 AND t.status < 0",
138  )
139  .bind::<diesel::sql_types::Integer, _>(corpus_id)
140  .bind::<diesel::sql_types::Integer, _>(service_id)
141  .get_result(connection)
142  .optional()
143  .ok()
144  .flatten();
145  match row {
146    Some(r) => LiveRunDiff {
147      improved: r.improved,
148      regressed: r.regressed,
149      unchanged: r.unchanged,
150      reclassified: r.reclassified,
151    },
152    None => LiveRunDiff::default(),
153  }
154}
155
156/// Maximum messages loaded **per severity** for a single document's forensic view. A hostile or
157/// pathological document can carry millions of messages of one severity (observed in production: a
158/// single arXiv task with 1.6M warnings); loading them all would allocate gigabytes and hang the
159/// request — an unbounded per-request resource acquisition the design principles forbid. The view
160/// is a *sample* bounded by this cap; the true per-severity totals are reported separately
161/// ([`MessageCounts`]) so the cap is transparent, never silent.
162pub const DOCUMENT_MESSAGE_CAP: i64 = 1000;
163
164/// True per-severity message totals for a task (the real counts, **before** the
165/// [`DOCUMENT_MESSAGE_CAP`] sampling cap), so a forensic view can show "showing N of M".
166#[derive(Debug, Default, Clone, Copy)]
167pub struct MessageCounts {
168  /// info-level messages
169  pub info: i64,
170  /// warning-level messages
171  pub warning: i64,
172  /// error-level messages
173  pub error: i64,
174  /// fatal-level messages
175  pub fatal: i64,
176  /// invalid-level messages
177  pub invalid: i64,
178}
179impl MessageCounts {
180  /// Grand total across all severities.
181  pub fn total(&self) -> i64 { self.info + self.warning + self.error + self.fatal + self.invalid }
182}
183
184/// Worker-log messages attached to a `task` — the forensic evidence behind that document's
185/// conversion status — loaded through the Diesel-generated row structs (`LogInfo` … `LogInvalid`)
186/// via their `belongs_to(Task)` association, so the column mapping is compiler-checked. Each is
187/// returned as a [`LogRecord`] trait object (which carries its own
188/// `severity()`/`category()`/`what()` /`details()`), in info → invalid order.
189///
190/// **Bounded:** at most [`DOCUMENT_MESSAGE_CAP`] rows are loaded **per severity** (so the worst
191/// case is a few thousand records, never the full millions a pathological document can hold). The
192/// second return value is the [`MessageCounts`] of *true* per-severity totals, so a caller can
193/// render the real magnitude and flag truncation rather than silently dropping evidence. Every
194/// query is keyed by the indexed `task_id` and this serves a single document, never a hot path; a
195/// failed sub-query contributes no rows rather than erroring the whole report.
196pub fn task_messages(
197  connection: &mut PgConnection,
198  task: &Task,
199) -> (Vec<Box<dyn LogRecord>>, MessageCounts) {
200  let mut messages: Vec<Box<dyn LogRecord>> = Vec::new();
201  let mut counts = MessageCounts::default();
202  macro_rules! collect {
203    ($row:ty, $field:ident) => {
204      counts.$field = <$row>::belonging_to(task)
205        .count()
206        .get_result(connection)
207        .unwrap_or(0);
208      if let Ok(rows) = <$row>::belonging_to(task)
209        .limit(DOCUMENT_MESSAGE_CAP)
210        .load::<$row>(connection)
211      {
212        messages.extend(
213          rows
214            .into_iter()
215            .map(|row| Box::new(row) as Box<dyn LogRecord>),
216        );
217      }
218    };
219  }
220  collect!(LogInfo, info);
221  collect!(LogWarning, warning);
222  collect!(LogError, error);
223  collect!(LogFatal, fatal);
224  collect!(LogInvalid, invalid);
225  (messages, counts)
226}
227
228/// An options object describing a CorTeX report request
229#[derive(Debug, Clone)]
230pub struct TaskReportOptions<'a> {
231  /// Corpus object to report over
232  pub corpus: &'a Corpus,
233  /// Service object to report over
234  pub service: &'a Service,
235  /// Optional: severity level for report
236  pub severity_opt: Option<String>,
237  /// Optional: category name for report
238  pub category_opt: Option<String>,
239  /// Optional: `what` name for report
240  pub what_opt: Option<String>,
241  /// Optional: show messages from all severities?
242  pub all_messages: bool,
243  /// Offset fixed number of messages
244  pub offset: i64,
245  /// Size limit for report
246  pub page_size: i64,
247}
248
249pub(crate) fn progress_report(
250  connection: &mut PgConnection,
251  corpus: i32,
252  service: i32,
253) -> HashMap<String, f64> {
254  use crate::schema::tasks::{corpus_id, service_id, status};
255  use diesel::sql_types::BigInt;
256
257  let mut stats_hash: HashMap<String, f64> = HashMap::new();
258  for status_key in TaskStatus::keys() {
259    stats_hash.insert(status_key, 0.0);
260  }
261  stats_hash.insert("total".to_string(), 0.0);
262  let rows: Vec<(i32, i64)> = tasks::table
263    .select((status, sql::<BigInt>("count(*) AS status_count")))
264    .filter(service_id.eq(service))
265    .filter(corpus_id.eq(corpus))
266    .group_by(tasks::status)
267    .order(sql::<BigInt>("status_count").desc())
268    .load(connection)
269    .unwrap_or_default();
270  for &(raw_status, count) in &rows {
271    let task_status = TaskStatus::from_raw(raw_status);
272    let status_key = task_status.to_key();
273    {
274      let status_frequency = stats_hash.entry(status_key).or_insert(0.0);
275      *status_frequency += count as f64;
276    }
277    if task_status != TaskStatus::Invalid {
278      // DIScount invalids from the total numbers
279      let total_frequency = stats_hash.entry("total".to_string()).or_insert(0.0);
280      *total_frequency += count as f64;
281    }
282  }
283  aux_stats_compute_percentages(&mut stats_hash, None);
284  stats_hash
285}
286
287/// Computes a `(corpus, service)` progress report at the granularity implied by the optional
288/// `severity`/`category`/`what` selectors.
289///
290/// The aggregate grains — the category report and its `what` drill-down — are served from the
291/// per-`(corpus, service, severity)` `report_grain_cache` (the cached `category_rollup`/
292/// `what_rollup`/`severity_total` readers: populated on a cold miss, invalidated on the scoped
293/// rerun / run-completion / force-refresh paths) rather than the expensive live aggregation in
294/// [`task_report_live`]. The per-task drill-downs (`no_problem` and `no_messages` entry lists, the
295/// `what`-detail list) and the all-severities (`all_messages`) view have no cached grain, so they
296/// fall through to the live path. Both paths share [`aux_task_rows_stats`], so the cached numbers
297/// are identical to the live ones (pinned by `live_report_grains_are_correct`).
298pub(crate) fn task_report(
299  connection: &mut PgConnection,
300  options: TaskReportOptions,
301) -> Vec<HashMap<String, String>> {
302  // [`report_uses_rollup`] routes the aggregate grains to the per-scope `report_grain_cache` and
303  // everything else to the live path.
304  if report_uses_rollup(
305    options.severity_opt.as_deref(),
306    options.category_opt.as_deref(),
307    options.what_opt.as_deref(),
308    options.all_messages,
309  ) {
310    // The gate guarantees a cached drill-down severity; the URL severity string IS the cache's
311    // `severity` key (`warning`/`error`/`fatal`/`invalid`/`info`). The `if let` + fall-through
312    // keeps this panic-free on the request path regardless.
313    if let Some(severity) = options.severity_opt.clone() {
314      match (options.category_opt.as_deref(), options.what_opt.as_deref()) {
315        // Category report: one row per category, plus the severity totals.
316        (None, None) => {
317          let severity_tasks =
318            severity_task_count(connection, options.corpus, options.service, &severity);
319          return category_grain_from_rollup(
320            connection,
321            options.corpus,
322            options.service,
323            &severity,
324            severity_tasks,
325            options.page_size,
326            options.offset,
327          );
328        },
329        // `what` drill-down within a category.
330        (Some(category), None) => {
331          return what_grain_from_rollup(
332            connection,
333            options.corpus,
334            options.service,
335            &severity,
336            category,
337            options.page_size,
338            options.offset,
339          );
340        },
341        _ => {},
342      }
343    }
344  }
345  task_report_live(connection, options)
346}
347
348/// Whether a report request is served from the **`report_summary` rollup** (a fast, matview-backed
349/// indexed lookup) rather than the live `log_*` aggregation in [`task_report_live`]. The matview
350/// covers only the category and `what`-drill-down **aggregate grains** of the five rollup
351/// severities (`warning`/`error`/`fatal`/`invalid`/`info`); the top-level overview
352/// (`progress_report`, a live `tasks` count), the all-severities `all=true` view, the `no_messages`
353/// row, and every per-task entry list are computed live.
354///
355/// This is the single source of truth for both the serving branch ([`task_report`]) **and** the
356/// report-freshness footer: the "data refreshed …" matview timestamp must be shown **iff** this is
357/// `true` — otherwise the data is live ("just now"), and stamping it with the matview's age lies
358/// about freshness (the bug fixed alongside this oracle).
359pub fn report_uses_rollup(
360  severity_opt: Option<&str>,
361  category_opt: Option<&str>,
362  what_opt: Option<&str>,
363  all_messages: bool,
364) -> bool {
365  // Route the aggregate grains — the category report `(None, None)` and the `what` drill-down
366  // `(Some(category), None)` — through the per-(corpus, service, severity) `report_grain_cache`
367  // (the cached `category_rollup`/`what_rollup`/`severity_total` readers), so the HTML report page
368  // is instant once a scope is warm instead of re-running the live 30M-row aggregation on every
369  // view. The all-severities view and the per-task entry lists (`no_messages`, and the
370  // `what`-detail list) have no cached grain and fall through to the live `task_report_live`
371  // path.
372  if all_messages {
373    return false;
374  }
375  let Some(severity) = severity_opt else {
376    return false;
377  };
378  if !matches!(severity, "warning" | "error" | "fatal" | "invalid" | "info") {
379    return false;
380  }
381  match (category_opt, what_opt) {
382    (None, None) => true,
383    (Some(category), None) => category != "no_messages",
384    _ => false,
385  }
386}
387
388/// Total tasks counted toward percentage denominators: all tasks for the pair minus `Invalid` ones
389/// (which were never processed, so they would dilute the service percentages).
390fn total_valid_task_count(
391  connection: &mut PgConnection,
392  corpus: &Corpus,
393  service: &Service,
394) -> i64 {
395  use crate::schema::tasks::dsl::{corpus_id, service_id, status};
396  let total: i64 = tasks::table
397    .filter(service_id.eq(service.id))
398    .filter(corpus_id.eq(corpus.id))
399    .count()
400    .get_result(connection)
401    .unwrap_or(0);
402  let invalid: i64 = tasks::table
403    .filter(service_id.eq(service.id))
404    .filter(corpus_id.eq(corpus.id))
405    .filter(status.eq(TaskStatus::Invalid.raw()))
406    .count()
407    .get_result(connection)
408    .unwrap_or(0);
409  total - invalid
410}
411
412/// Counts the tasks of a `(corpus, service)` pair currently in a given raw status.
413fn count_in_status(
414  connection: &mut PgConnection,
415  corpus: &Corpus,
416  service: &Service,
417  raw_status: i32,
418) -> i64 {
419  use crate::schema::tasks::dsl::{corpus_id, service_id, status};
420  tasks::table
421    .filter(service_id.eq(service.id))
422    .filter(corpus_id.eq(corpus.id))
423    .filter(status.eq(raw_status))
424    .count()
425    .get_result(connection)
426    .unwrap_or(0)
427}
428
429/// The denominator a rollup severity's report is computed against, matching the live path: the four
430/// message severities count tasks **in that status**; `info` is the all-messages dimension, so its
431/// denominator is the **full task count** (the `all_messages` branch of [`task_report_live`] counts
432/// every task of the pair, invalids included).
433fn severity_task_count(
434  connection: &mut PgConnection,
435  corpus: &Corpus,
436  service: &Service,
437  severity: &str,
438) -> i64 {
439  let raw_status = match severity {
440    "warning" => TaskStatus::Warning.raw(),
441    "error" => TaskStatus::Error.raw(),
442    "fatal" => TaskStatus::Fatal.raw(),
443    "invalid" => TaskStatus::Invalid.raw(),
444    // `info` aggregates across all tasks, not one status.
445    _ => return total_task_count(connection, corpus, service),
446  };
447  count_in_status(connection, corpus, service, raw_status)
448}
449
450/// Total tasks of a `(corpus, service)` pair (all statuses, invalids included) — the `info`
451/// report's denominator.
452fn total_task_count(connection: &mut PgConnection, corpus: &Corpus, service: &Service) -> i64 {
453  use crate::schema::tasks::dsl::{corpus_id, service_id};
454  tasks::table
455    .filter(service_id.eq(service.id))
456    .filter(corpus_id.eq(corpus.id))
457    .count()
458    .get_result(connection)
459    .unwrap_or(0)
460}
461
462/// Category report for a severity, assembled from the rollup: one row per category (distinct tasks
463/// + messages), a `no_messages` row for tasks that completed silently, and the severity total.
464fn category_grain_from_rollup(
465  connection: &mut PgConnection,
466  corpus: &Corpus,
467  service: &Service,
468  severity: &str,
469  severity_tasks: i64,
470  limit: i64,
471  offset: i64,
472) -> Vec<HashMap<String, String>> {
473  let category_rows =
474    rollup::category_rollup(connection, corpus.id, service.id, severity, limit, offset)
475      .unwrap_or_default();
476  let grand_total =
477    rollup::severity_total(connection, corpus.id, service.id, severity).unwrap_or_default();
478  let total_valid_count = total_valid_task_count(connection, corpus, service);
479  // Tasks that carry at least one message of this severity, and the total message count.
480  let logged_task_count = grand_total.as_ref().map_or(0, |g| g.task_count);
481  let logged_message_count = grand_total.as_ref().map_or(0, |g| g.message_count);
482  let silent_task_count = if logged_task_count >= severity_tasks {
483    None
484  } else {
485    Some(severity_tasks - logged_task_count)
486  };
487  let report_rows = rows_to_aggregates(category_rows, |row| row.category);
488  aux_task_rows_stats(
489    &report_rows,
490    total_valid_count,
491    severity_tasks,
492    logged_message_count,
493    silent_task_count,
494  )
495}
496
497/// `what` drill-down within a category, assembled from the rollup: one row per `what`, with the
498/// owning category's totals as the denominators.
499fn what_grain_from_rollup(
500  connection: &mut PgConnection,
501  corpus: &Corpus,
502  service: &Service,
503  severity: &str,
504  category: &str,
505  limit: i64,
506  offset: i64,
507) -> Vec<HashMap<String, String>> {
508  let what_rows = rollup::what_rollup(
509    connection, corpus.id, service.id, severity, category, limit, offset,
510  )
511  .unwrap_or_default();
512  let category_total =
513    rollup::category_total(connection, corpus.id, service.id, severity, category)
514      .unwrap_or_default();
515  let total_valid_count = total_valid_task_count(connection, corpus, service);
516  let (category_tasks, category_messages) = category_total
517    .as_ref()
518    .map_or((0, 0), |c| (c.task_count, c.message_count));
519  let report_rows = rows_to_aggregates(what_rows, |row| row.what.unwrap_or_default());
520  aux_task_rows_stats(
521    &report_rows,
522    total_valid_count,
523    category_tasks,
524    category_messages,
525    None,
526  )
527}
528
529/// Adapts rollup rows into the `AggregateReport` shape `aux_task_rows_stats` consumes, naming each
530/// row via `name_of` (the category, or the `what`).
531fn rows_to_aggregates(
532  rows: Vec<rollup::ReportSummaryRow>,
533  name_of: impl Fn(rollup::ReportSummaryRow) -> String,
534) -> Vec<AggregateReport> {
535  rows
536    .into_iter()
537    .map(|row| {
538      let task_count = row.task_count;
539      let message_count = row.message_count;
540      AggregateReport {
541        report_name: Some(name_of(row)),
542        task_count,
543        message_count,
544      }
545    })
546    .collect()
547}
548
549/// Live (non-materialized) computation of a task report, used for the per-task drill-down grains
550/// and the all-severities view, and as the equivalence reference for the rollup-backed aggregate
551/// grains.
552pub(crate) fn task_report_live(
553  connection: &mut PgConnection,
554  options: TaskReportOptions,
555) -> Vec<HashMap<String, String>> {
556  use crate::schema::tasks::dsl::{corpus_id, service_id, status};
557  use diesel::sql_types::{BigInt, Text};
558  // destructure options
559  let TaskReportOptions {
560    corpus,
561    service,
562    severity_opt,
563    category_opt,
564    what_opt,
565    mut all_messages,
566    offset,
567    page_size,
568  } = options;
569  // The final report, populated based on the specific selectors
570  let mut report = Vec::new();
571
572  if let Some(severity_name) = severity_opt {
573    let task_status = TaskStatus::from_key(&severity_name);
574    // NoProblem report is a bit special, as it provides a simple list of entries - we assume no
575    // logs of notability for this severity.
576    if task_status == Some(TaskStatus::NoProblem) {
577      let entry_rows: Vec<(String, i64)> = tasks::table
578        .select((tasks::entry, tasks::id))
579        .filter(service_id.eq(service.id))
580        .filter(corpus_id.eq(corpus.id))
581        // In this branch `task_status == Some(NoProblem)`; use the constant directly so the report
582        // path carries no `.unwrap()` (structurally panic-free, not merely provably-so today).
583        .filter(status.eq(TaskStatus::NoProblem.raw()))
584        .order(tasks::entry.asc())
585        .offset(offset)
586        .limit(page_size)
587        .load(connection)
588        .unwrap_or_default();
589      for &(ref entry_fixedwidth, entry_taskid) in &entry_rows {
590        let mut entry_map = HashMap::new();
591        let entry_trimmed = entry_fixedwidth.trim_end().to_string();
592        let entry_name = TASK_REPORT_NAME_REGEX
593          .replace(&entry_trimmed, "$1")
594          .to_string();
595
596        entry_map.insert("entry".to_string(), entry_trimmed);
597        entry_map.insert("entry_name".to_string(), entry_name);
598        entry_map.insert("entry_taskid".to_string(), entry_taskid.to_string());
599        entry_map.insert("details".to_string(), "OK".to_string());
600        report.push(entry_map);
601      }
602    } else {
603      // The "total tasks" used in the divison denominators for computing the percentage
604      // distributions are all valid tasks (total - invalid), as we don't want to dilute
605      // the service percentage with jobs that were never processed. For now the fastest
606      // way to obtain that number is using 2 queries for each and subtracting the numbers in Rust
607      // Degrade to 0 on a query error rather than panicking the report request (principle #2,
608      // matching the `unwrap_or_default` siblings on this path); the percentage math clamps the
609      // denominator to ≥1, so a degraded total yields 0% instead of a div-by-zero.
610      let total_count: i64 = tasks::table
611        .filter(service_id.eq(service.id))
612        .filter(corpus_id.eq(corpus.id))
613        .count()
614        .get_result(connection)
615        .unwrap_or(0);
616      let invalid_count: i64 = tasks::table
617        .filter(service_id.eq(service.id))
618        .filter(corpus_id.eq(corpus.id))
619        .filter(status.eq(TaskStatus::Invalid.raw()))
620        .count()
621        .get_result(connection)
622        .unwrap_or(0);
623      let total_valid_count = (total_count - invalid_count).max(0);
624
625      let log_table = match task_status {
626        Some(ref ts) => ts.to_table(),
627        None => {
628          all_messages = true;
629          "log_infos".to_string()
630        },
631      };
632
633      let task_status_raw = task_status.unwrap_or(TaskStatus::NoProblem).raw();
634      let (status_clause, bind_status) = if !all_messages {
635        (String::from("status=$3 "), task_status_raw)
636      } else {
637        (
638          String::from("status < $3 and status > ") + &TaskStatus::Invalid.raw().to_string(),
639          0,
640        ) // all completed tasks are negative integers, so 0 is a safe upper bound
641      };
642      match category_opt {
643        None => {
644          // Bad news, query is close to line noise
645          // Good news, we avoid the boilerplate of dispatching to 4 distinct log tables for now
646          let category_report_string =
647            "SELECT category as report_name, count(*) as task_count, COALESCE(SUM(total_counts::integer),0) as message_count FROM (".to_string()+
648              "SELECT "+&log_table+".category, "+&log_table+".task_id, count(*) as total_counts FROM "+
649                "tasks LEFT OUTER JOIN "+&log_table+" ON (tasks.id="+&log_table+".task_id) WHERE service_id=$1 and corpus_id=$2 and "+ &status_clause +
650                  " GROUP BY "+&log_table+".category, "+&log_table+".task_id) as tmp "+
651            "GROUP BY category ORDER BY task_count desc";
652          let category_report_query = sql_query(category_report_string);
653          let category_report_rows: Vec<AggregateReport> = category_report_query
654            .bind::<BigInt, i64>(i64::from(service.id))
655            .bind::<BigInt, i64>(i64::from(corpus.id))
656            .bind::<BigInt, i64>(i64::from(bind_status))
657            .load(connection)
658            .unwrap_or_default();
659
660          // How many tasks total in this severity-status?
661          let severity_tasks: i64 = if !all_messages {
662            tasks::table
663              .filter(service_id.eq(service.id))
664              .filter(corpus_id.eq(corpus.id))
665              .filter(status.eq(task_status_raw))
666              .count()
667              .get_result(connection)
668              .unwrap_or(-1)
669          } else {
670            tasks::table
671              .filter(service_id.eq(service.id))
672              .filter(corpus_id.eq(corpus.id))
673              .count()
674              .get_result(connection)
675              .unwrap_or(-1)
676          };
677          let status_report_query_string =
678          "SELECT NULL as report_name, count(*) as task_count, COALESCE(SUM(inner_message_count::integer),0) as message_count FROM ( ".to_string()+
679            "SELECT tasks.id, count(*) as inner_message_count FROM "+
680            "tasks, "+&log_table+" where tasks.id="+&log_table+".task_id and "+
681            "service_id=$1 and corpus_id=$2 and "+&status_clause+" group by tasks.id) as tmp";
682          let status_report_query = sql_query(status_report_query_string)
683            .bind::<BigInt, i64>(i64::from(service.id))
684            .bind::<BigInt, i64>(i64::from(corpus.id))
685            .bind::<BigInt, i64>(i64::from(bind_status));
686          let status_report_rows: AggregateReport = status_report_query
687            .get_result(connection)
688            .unwrap_or_default();
689
690          let logged_task_count: i64 = status_report_rows.task_count;
691          let logged_message_count: i64 = status_report_rows.message_count;
692          let silent_task_count = if logged_task_count >= severity_tasks {
693            None
694          } else {
695            Some(severity_tasks - logged_task_count)
696          };
697          report = aux_task_rows_stats(
698            &category_report_rows,
699            total_valid_count,
700            severity_tasks,
701            logged_message_count,
702            silent_task_count,
703          )
704        },
705        Some(category_name) => {
706          if category_name == "no_messages" {
707            let no_messages_query_string = "SELECT * FROM tasks t WHERE ".to_string()
708              + "service_id=$1 and corpus_id=$2 and "
709              + &status_clause
710              + " and "
711              + "NOT EXISTS (SELECT null FROM "
712              + &log_table
713              + " where "
714              + &log_table
715              + ".task_id=t.id) limit 100";
716            let no_messages_query = sql_query(no_messages_query_string)
717              .bind::<BigInt, i64>(i64::from(service.id))
718              .bind::<BigInt, i64>(i64::from(corpus.id))
719              .bind::<BigInt, i64>(i64::from(bind_status))
720              .bind::<BigInt, i64>(i64::from(task_status_raw));
721            let no_message_tasks: Vec<Task> = no_messages_query
722              .get_results(connection)
723              .unwrap_or_default();
724
725            for task in &no_message_tasks {
726              let mut entry_map = HashMap::new();
727              let entry = task.entry.trim_end().to_string();
728              let entry_name = TASK_REPORT_NAME_REGEX.replace(&entry, "$1").to_string();
729
730              entry_map.insert("entry".to_string(), entry);
731              entry_map.insert("entry_name".to_string(), entry_name);
732              entry_map.insert("entry_taskid".to_string(), task.id.to_string());
733              entry_map.insert("details".to_string(), "OK".to_string());
734              report.push(entry_map);
735            }
736          } else {
737            match what_opt {
738              None => {
739                let what_report_query_string =
740            "SELECT what as report_name, count(*) as task_count, COALESCE(SUM(total_counts::integer),0) as message_count FROM ( ".to_string() +
741              "SELECT "+&log_table+".what, "+&log_table+".task_id, count(*) as total_counts FROM "+
742                "tasks LEFT OUTER JOIN "+&log_table+" ON (tasks.id="+&log_table+".task_id) "+
743                "WHERE service_id=$1 and corpus_id=$2 and "+&status_clause+" and category=$4 "+
744                "GROUP BY "+&log_table+".what, "+&log_table+".task_id) as tmp GROUP BY what ORDER BY task_count desc";
745                let what_report_query = sql_query(what_report_query_string)
746                  .bind::<BigInt, i64>(i64::from(service.id))
747                  .bind::<BigInt, i64>(i64::from(corpus.id))
748                  .bind::<BigInt, i64>(i64::from(bind_status))
749                  .bind::<Text, _>(category_name.clone());
750                let what_report: Vec<AggregateReport> = what_report_query
751                  .get_results(connection)
752                  .unwrap_or_default();
753                // How many tasks and messages total in this category?
754                let this_category_report_query_string = "SELECT NULL as report_name, count(*) as task_count, COALESCE(SUM(inner_message_count::integer),0) as message_count FROM".to_string() +
755              " (SELECT tasks.id, count(*) as inner_message_count "+
756              "FROM tasks, "+&log_table+" WHERE tasks.id="+&log_table+".task_id and "+
757                "service_id=$1 and corpus_id=$2 and "+&status_clause+" and category=$4 group by tasks.id) as tmp";
758                let this_category_report_query = sql_query(this_category_report_query_string)
759                  .bind::<BigInt, i64>(i64::from(service.id))
760                  .bind::<BigInt, i64>(i64::from(corpus.id))
761                  .bind::<BigInt, i64>(i64::from(bind_status))
762                  .bind::<Text, _>(category_name);
763                let this_category_report: AggregateReport = this_category_report_query
764                  .get_result(connection)
765                  .unwrap_or_default();
766
767                report = aux_task_rows_stats(
768                  &what_report,
769                  total_valid_count,
770                  this_category_report.task_count,
771                  this_category_report.message_count,
772                  None,
773                )
774              },
775              Some(what_name) => {
776                let details_report_query_string = "SELECT tasks.id, tasks.entry, ".to_string()
777                  + &log_table
778                  + ".details from tasks, "
779                  + &log_table
780                  + " WHERE tasks.id="
781                  + &log_table
782                  + ".task_id and service_id=$1 and corpus_id=$2 and "
783                  + &status_clause
784                  + " and category=$4 and what=$5 ORDER BY tasks.entry ASC offset $6 limit $7";
785
786                let details_report_query = sql_query(details_report_query_string)
787                  .bind::<BigInt, i64>(i64::from(service.id))
788                  .bind::<BigInt, i64>(i64::from(corpus.id))
789                  .bind::<BigInt, i64>(i64::from(bind_status))
790                  .bind::<Text, _>(category_name)
791                  .bind::<Text, _>(what_name)
792                  .bind::<BigInt, i64>(offset)
793                  .bind::<BigInt, i64>(page_size);
794                let details_report: Vec<TaskDetailReport> = details_report_query
795                  .get_results(connection)
796                  .unwrap_or_default();
797                for details_row in details_report {
798                  let mut entry_map = HashMap::new();
799                  let entry = details_row.entry.trim_end().to_string();
800                  let entry_name = TASK_REPORT_NAME_REGEX.replace(&entry, "$1").to_string();
801                  // TODO: Also use url-escape
802                  entry_map.insert("entry".to_string(), entry);
803                  entry_map.insert("entry_name".to_string(), entry_name);
804                  entry_map.insert("entry_taskid".to_string(), details_row.id.to_string());
805                  entry_map.insert("details".to_string(), details_row.details);
806                  report.push(entry_map);
807                }
808              },
809            }
810          }
811        },
812      }
813    }
814  }
815  report
816}
817
818fn aux_stats_compute_percentages(stats_hash: &mut HashMap<String, f64>, total_given: Option<f64>) {
819  // Compute percentages, now that we have a total
820  let total: f64 = 1.0_f64.max(match total_given {
821    // Defensive: fall back to 0.0 (then clamped to 1.0 below) if a caller omitted the "total" key,
822    // rather than `.unwrap()`-panicking on this report path.
823    None => stats_hash.get("total").copied().unwrap_or(0.0),
824    Some(total_num) => total_num,
825  });
826  let stats_keys = stats_hash.keys().cloned().collect::<Vec<_>>();
827  for stats_key in stats_keys {
828    {
829      let key_percent_value: f64 = 100.0 * (*stats_hash.get_mut(&stats_key).unwrap() / total);
830      let key_percent_rounded: f64 = (key_percent_value * 100.0).round() / 100.0;
831      let key_percent_name = stats_key + "_percent";
832      stats_hash.insert(key_percent_name, key_percent_rounded);
833    }
834  }
835}
836
837fn aux_task_rows_stats(
838  report_rows: &[AggregateReport],
839  mut total_valid_tasks: i64,
840  these_tasks: i64,
841  mut these_messages: i64,
842  these_silent: Option<i64>,
843) -> Vec<HashMap<String, String>> {
844  let mut report = Vec::new();
845  // Guard against dividing by 0
846  if total_valid_tasks <= 0 {
847    total_valid_tasks = 1;
848  }
849  if these_messages <= 0 {
850    these_messages = 1;
851  }
852
853  for row in report_rows {
854    let stat_type: String = match row.report_name {
855      Some(ref name) => name.trim_end().to_string(),
856      None => String::new(),
857    };
858    if stat_type.is_empty() {
859      continue;
860    } // skip, empty
861    let stat_tasks: i64 = row.task_count;
862    let stat_messages: i64 = row.message_count;
863    let mut stats_hash: HashMap<String, String> = HashMap::new();
864    stats_hash.insert("name".to_string(), stat_type);
865    stats_hash.insert("tasks".to_string(), stat_tasks.to_string());
866    stats_hash.insert("messages".to_string(), stat_messages.to_string());
867
868    let tasks_percent_value: f64 = 100.0 * (stat_tasks as f64 / total_valid_tasks as f64);
869    let tasks_percent_rounded: f64 = (tasks_percent_value * 100.0).round() / 100.0;
870    stats_hash.insert(
871      "tasks_percent".to_string(),
872      tasks_percent_rounded.to_string(),
873    );
874    let messages_percent_value: f64 = 100.0 * (stat_messages as f64 / these_messages as f64);
875    let messages_percent_rounded: f64 = (messages_percent_value * 100.0).round() / 100.0;
876    stats_hash.insert(
877      "messages_percent".to_string(),
878      messages_percent_rounded.to_string(),
879    );
880
881    report.push(stats_hash);
882  }
883
884  let these_tasks_percent_value: f64 = 100.0 * (these_tasks as f64 / total_valid_tasks as f64);
885  let these_tasks_percent_rounded: f64 = (these_tasks_percent_value * 100.0).round() / 100.0;
886  // Append the total to the end of the report:
887  let mut total_hash = HashMap::new();
888  total_hash.insert("name".to_string(), "total".to_string());
889  match these_silent {
890    None => {},
891    Some(silent_count) => {
892      let mut no_messages_hash: HashMap<String, String> = HashMap::new();
893      no_messages_hash.insert("name".to_string(), "no_messages".to_string());
894      no_messages_hash.insert("tasks".to_string(), silent_count.to_string());
895      let silent_tasks_percent_value: f64 =
896        100.0 * (silent_count as f64 / total_valid_tasks as f64);
897      let silent_tasks_percent_rounded: f64 = (silent_tasks_percent_value * 100.0).round() / 100.0;
898      no_messages_hash.insert(
899        "tasks_percent".to_string(),
900        silent_tasks_percent_rounded.to_string(),
901      );
902      no_messages_hash.insert("messages".to_string(), "0".to_string());
903      no_messages_hash.insert("messages_percent".to_string(), "0".to_string());
904      report.push(no_messages_hash);
905    },
906  };
907  total_hash.insert("tasks".to_string(), these_tasks.to_string());
908  total_hash.insert(
909    "tasks_percent".to_string(),
910    these_tasks_percent_rounded.to_string(),
911  );
912  total_hash.insert("messages".to_string(), these_messages.to_string());
913  total_hash.insert("messages_percent".to_string(), "100".to_string());
914  report.push(total_hash);
915  report
916}
917
918pub(crate) fn list_tasks(
919  connection: &mut PgConnection,
920  corpus: &Corpus,
921  service: &Service,
922  task_status: &TaskStatus,
923) -> Vec<Task> {
924  use crate::schema::tasks::dsl::{corpus_id, service_id, status};
925  tasks::table
926    .filter(service_id.eq(service.id))
927    .filter(corpus_id.eq(corpus.id))
928    .filter(status.eq(task_status.raw()))
929    .load(connection)
930    .unwrap_or_default()
931}
932
933pub(crate) fn list_entries(
934  connection: &mut PgConnection,
935  corpus: &Corpus,
936  service: &Service,
937  task_status: &TaskStatus,
938) -> Vec<String> {
939  list_tasks(connection, corpus, service, task_status)
940    .into_iter()
941    .map(|task| {
942      if service.name == "import" {
943        task.entry.trim_end().to_string()
944      } else {
945        crate::helpers::result_archive_path(&task.entry, &service.name, corpus.sandbox_id())
946          .map(|p| p.to_string_lossy().into_owned())
947          .unwrap_or_default()
948      }
949    })
950    .collect()
951}
952
953/// Prepares a template-friendly list of task differences
954pub fn list_task_diffs(
955  connection: &mut PgConnection,
956  corpus: &Corpus,
957  service: &Service,
958  filters: DiffStatusFilter,
959) -> Vec<TaskRunMetadata> {
960  match HistoricalTask::report_for(corpus, service, Some(filters), connection) {
961    Ok((_dates, report)) => report
962      .into_iter()
963      .map(|row| {
964        let previous_status = TaskStatus::from_raw(row.0.status).to_key();
965        let current_status = TaskStatus::from_raw(row.1.status).to_key();
966        let previous_highlight = severity_highlight(&previous_status).to_owned();
967        let current_highlight = severity_highlight(&current_status).to_owned();
968        TaskRunMetadata {
969          task_id: row.0.task_id.to_string(),
970          entry: TASK_REPORT_NAME_REGEX
971            .replace(&row.0.entry, "$1")
972            .to_string(),
973          previous_status,
974          current_status,
975          previous_highlight,
976          current_highlight,
977          previous_saved_at: row.0.saved_at.format("%Y-%m-%d").to_string(),
978          current_saved_at: row.1.saved_at.format("%Y-%m-%d").to_string(),
979        }
980      })
981      .collect(),
982    _ => Vec::new(),
983  }
984}
985
986/// Prepares a template-friendly summary of task differences
987pub fn summary_task_diffs(
988  connection: &mut PgConnection,
989  corpus: &Corpus,
990  service: &Service,
991  previous_date: Option<NaiveDateTime>,
992  current_date: Option<NaiveDateTime>,
993) -> (Vec<String>, Vec<DiffStatusRow>) {
994  // Aggregate the (previous → current) status transitions **in SQL** (one row per status pair),
995  // never loading the per-task snapshots into the application — a corpus with two
996  // multi-million-task snapshots is summarized in constant application memory (KNOWN_ISSUES R-8).
997  // Degrades to an empty matrix (→ all-zero table) on any DB error, like the rest of this report
998  // path.
999  let (dates, matrix) =
1000    HistoricalTask::status_change_matrix(corpus, service, previous_date, current_date, connection)
1001      .unwrap_or_default();
1002  let mut summary: HashMap<i32, HashMap<i32, i64>> = HashMap::new();
1003  for (prev_status, current_status, count) in matrix {
1004    summary
1005      .entry(prev_status)
1006      .or_default()
1007      .insert(current_status, count);
1008  }
1009  // Here we are only interested to report on the 4 "completed" severities; we could add invalid,
1010  // but not yet a focus.
1011  use TaskStatus::*;
1012  let mut tabular = Vec::new();
1013  for prev in [NoProblem, Warning, Error, Fatal].iter() {
1014    for current in [NoProblem, Warning, Error, Fatal].iter() {
1015      let previous_status = prev.to_key();
1016      let current_status = current.to_key();
1017      let previous_highlight = severity_highlight(&previous_status).to_owned();
1018      let current_highlight = severity_highlight(&current_status).to_owned();
1019      let task_count = summary
1020        .get(&prev.raw())
1021        .and_then(|row| row.get(&current.raw()))
1022        .copied()
1023        .unwrap_or(0) as usize;
1024      tabular.push(DiffStatusRow {
1025        previous_status,
1026        current_status,
1027        previous_highlight,
1028        current_highlight,
1029        task_count,
1030      });
1031    }
1032  }
1033  (dates, tabular)
1034}
1035
1036#[cfg(test)]
1037mod rollup_equivalence_tests {
1038  //! Pins behavioral equivalence: the rollup-backed [`task_report`] must return exactly what the
1039  //! live aggregation ([`task_report_live`]) returns for the category and `what` grains it now
1040  //! serves — so wiring reports to the materialized view changed performance, not numbers.
1041  use super::{TaskReportOptions, rollup, task_report, task_report_live};
1042  use crate::backend;
1043  use crate::helpers::TaskStatus;
1044  use crate::models::{Corpus, NewCorpus, NewService, Service};
1045  use crate::schema::{corpora, log_errors, log_infos, log_warnings, services, tasks};
1046  use diesel::prelude::*;
1047  use std::collections::HashMap;
1048
1049  const CORPUS_NAME: &str = "rollup-equivalence corpus";
1050  const SERVICE_NAME: &str = "rollup_equiv_svc";
1051
1052  fn add_task(conn: &mut PgConnection, entry: &str, service: i32, corpus: i32, status: i32) -> i64 {
1053    diesel::insert_into(tasks::table)
1054      .values((
1055        tasks::entry.eq(entry),
1056        tasks::service_id.eq(service),
1057        tasks::corpus_id.eq(corpus),
1058        tasks::status.eq(status),
1059      ))
1060      .returning(tasks::id)
1061      .get_result(conn)
1062      .expect("insert task")
1063  }
1064
1065  fn add_warning(conn: &mut PgConnection, task_id: i64, category: &str, what: &str) {
1066    diesel::insert_into(log_warnings::table)
1067      .values((
1068        log_warnings::task_id.eq(task_id),
1069        log_warnings::category.eq(category),
1070        log_warnings::what.eq(what),
1071        log_warnings::details.eq(""),
1072      ))
1073      .execute(conn)
1074      .expect("insert log_warning");
1075  }
1076
1077  fn add_error(conn: &mut PgConnection, task_id: i64, category: &str, what: &str) {
1078    diesel::insert_into(log_errors::table)
1079      .values((
1080        log_errors::task_id.eq(task_id),
1081        log_errors::category.eq(category),
1082        log_errors::what.eq(what),
1083        log_errors::details.eq(""),
1084      ))
1085      .execute(conn)
1086      .expect("insert log_error");
1087  }
1088
1089  fn add_info(conn: &mut PgConnection, task_id: i64, category: &str, what: &str) {
1090    diesel::insert_into(log_infos::table)
1091      .values((
1092        log_infos::task_id.eq(task_id),
1093        log_infos::category.eq(category),
1094        log_infos::what.eq(what),
1095        log_infos::details.eq(""),
1096      ))
1097      .execute(conn)
1098      .expect("insert log_info");
1099  }
1100
1101  /// Index report rows by their `name` so comparisons are order-independent.
1102  fn by_name(rows: Vec<HashMap<String, String>>) -> HashMap<String, HashMap<String, String>> {
1103    rows
1104      .into_iter()
1105      .map(|row| (row.get("name").cloned().unwrap_or_default(), row))
1106      .collect()
1107  }
1108
1109  fn options_paged<'a>(
1110    corpus: &'a Corpus,
1111    service: &'a Service,
1112    severity: &str,
1113    category: Option<&str>,
1114    page_size: i64,
1115    offset: i64,
1116  ) -> TaskReportOptions<'a> {
1117    TaskReportOptions {
1118      corpus,
1119      service,
1120      severity_opt: Some(severity.to_string()),
1121      category_opt: category.map(str::to_string),
1122      what_opt: None,
1123      all_messages: false,
1124      offset,
1125      page_size,
1126    }
1127  }
1128
1129  fn options<'a>(
1130    corpus: &'a Corpus,
1131    service: &'a Service,
1132    severity: &str,
1133    category: Option<&str>,
1134  ) -> TaskReportOptions<'a> {
1135    options_paged(corpus, service, severity, category, 100, 0)
1136  }
1137
1138  #[test]
1139  fn live_report_grains_are_correct() {
1140    let mut backend = backend::testdb();
1141
1142    // --- Clean slate -----------------------------------------------------------------------------
1143    if let Ok(existing) = Corpus::find_by_name(CORPUS_NAME, &mut backend.connection) {
1144      let ids: Vec<i64> = tasks::table
1145        .filter(tasks::corpus_id.eq(existing.id))
1146        .select(tasks::id)
1147        .load(&mut backend.connection)
1148        .unwrap_or_default();
1149      diesel::delete(log_warnings::table.filter(log_warnings::task_id.eq_any(&ids)))
1150        .execute(&mut backend.connection)
1151        .ok();
1152      diesel::delete(log_errors::table.filter(log_errors::task_id.eq_any(&ids)))
1153        .execute(&mut backend.connection)
1154        .ok();
1155      diesel::delete(tasks::table.filter(tasks::corpus_id.eq(existing.id)))
1156        .execute(&mut backend.connection)
1157        .ok();
1158      diesel::delete(corpora::table.filter(corpora::id.eq(existing.id)))
1159        .execute(&mut backend.connection)
1160        .ok();
1161    }
1162    diesel::delete(services::table.filter(services::name.eq(SERVICE_NAME)))
1163      .execute(&mut backend.connection)
1164      .ok();
1165
1166    // --- Seed corpus + service -------------------------------------------------------------------
1167    backend
1168      .add(&NewCorpus {
1169        name: CORPUS_NAME.to_string(),
1170        path: "/tmp/rollup-equivalence".to_string(),
1171        complex: true,
1172        description: String::new(),
1173      })
1174      .expect("add corpus");
1175    let corpus = Corpus::find_by_name(CORPUS_NAME, &mut backend.connection).expect("corpus");
1176    backend
1177      .add(&NewService {
1178        name: SERVICE_NAME.to_string(),
1179        version: 0.1,
1180        inputformat: "tex".to_string(),
1181        outputformat: "html".to_string(),
1182        inputconverter: Some("import".to_string()),
1183        complex: true,
1184        description: String::from("rollup equivalence service"),
1185      })
1186      .expect("add service");
1187    let service = Service::find_by_name(SERVICE_NAME, &mut backend.connection).expect("service");
1188
1189    let warning = TaskStatus::Warning.raw();
1190    let error = TaskStatus::Error.raw();
1191    let conn = &mut backend.connection;
1192
1193    // Warnings: math{ux,uy} + math{ux} + font{missing}, plus one silent warning task (no logs),
1194    // which must surface as a `no_messages` row of 1.
1195    let a = add_task(conn, "/eq/a", service.id, corpus.id, warning);
1196    let b = add_task(conn, "/eq/b", service.id, corpus.id, warning);
1197    let c = add_task(conn, "/eq/c", service.id, corpus.id, warning);
1198    let _silent = add_task(conn, "/eq/d", service.id, corpus.id, warning);
1199    add_warning(conn, a, "math", "undefined_x");
1200    add_warning(conn, a, "math", "undefined_y");
1201    add_warning(conn, b, "math", "undefined_x");
1202    add_warning(conn, c, "font", "missing");
1203
1204    // Errors: tex{err1} + tex{err1,err2}.
1205    let e = add_task(conn, "/eq/e", service.id, corpus.id, error);
1206    let f = add_task(conn, "/eq/f", service.id, corpus.id, error);
1207    add_error(conn, e, "tex", "err1");
1208    add_error(conn, f, "tex", "err1");
1209    add_error(conn, f, "tex", "err2");
1210
1211    // Info: the all-messages dimension — `log_infos` over ALL completed tasks regardless of status,
1212    // so attach info messages to a warning task (a), an error task (e) and a no-problem task (g).
1213    // /info was the report that scanned live instead of using the matview's `info` branch.
1214    let g = add_task(
1215      conn,
1216      "/eq/g",
1217      service.id,
1218      corpus.id,
1219      TaskStatus::NoProblem.raw(),
1220    );
1221    add_info(conn, a, "load", "package");
1222    add_info(conn, e, "load", "package");
1223    add_info(conn, g, "load", "class");
1224
1225    // The category/what/severity-total readers now compute live + per-(corpus,service)-scoped (the
1226    // global report_summary matview was retired). Pin them directly against the seeded data.
1227    let warning_cats = rollup::category_rollup(conn, corpus.id, service.id, "warning", 100, 0)
1228      .expect("category_rollup");
1229    assert_eq!(warning_cats.len(), 2, "warning categories: math, font");
1230    assert_eq!(warning_cats[0].category, "math", "busiest category first");
1231    assert_eq!(warning_cats[0].task_count, 2, "math distinct tasks A,B");
1232    assert_eq!(
1233      warning_cats[0].message_count, 3,
1234      "math messages: 2 (A) + 1 (B)"
1235    );
1236    assert_eq!(warning_cats[1].category, "font");
1237    assert_eq!(warning_cats[1].task_count, 1);
1238    let math_whats = rollup::what_rollup(conn, corpus.id, service.id, "warning", "math", 100, 0)
1239      .expect("what_rollup");
1240    assert_eq!(math_whats.len(), 2, "math whats: undefined_x, undefined_y");
1241    let warning_total = rollup::severity_total(conn, corpus.id, service.id, "warning")
1242      .expect("severity_total query")
1243      .expect("warning has messages");
1244    assert_eq!(
1245      warning_total.task_count, 3,
1246      "distinct warning-logged tasks A,B,C (silent D has no logs)"
1247    );
1248    assert_eq!(warning_total.message_count, 4, "total warning messages");
1249    assert!(
1250      rollup::severity_total(conn, corpus.id, service.id, "fatal")
1251        .expect("severity_total query")
1252        .is_none(),
1253      "a severity with no messages yields None"
1254    );
1255
1256    // --- The dispatcher routes every grain to the live path; cross-check task_report == live -----
1257    let cases = [
1258      ("warning", None),
1259      ("warning", Some("math")),
1260      ("warning", Some("font")),
1261      ("error", None),
1262      ("error", Some("tex")),
1263      // `info` — the all-messages dimension; must come from the matview's info branch, not a live
1264      // log_infos scan.
1265      ("info", None),
1266      ("info", Some("load")),
1267    ];
1268    for (severity, category) in cases {
1269      let fast = by_name(task_report(
1270        conn,
1271        options(&corpus, &service, severity, category),
1272      ));
1273      let live = by_name(task_report_live(
1274        conn,
1275        options(&corpus, &service, severity, category),
1276      ));
1277      assert_eq!(
1278        fast, live,
1279        "task_report (live) vs task_report_live mismatch for severity={severity} category={category:?}"
1280      );
1281      assert!(
1282        !fast.is_empty(),
1283        "live path produced an empty report for severity={severity} category={category:?}"
1284      );
1285    }
1286
1287    // --- Spot-check absolute values, so equivalence can't pass by both paths being wrong
1288    // ----------
1289    let warning_cat = by_name(task_report(
1290      conn,
1291      options(&corpus, &service, "warning", None),
1292    ));
1293    assert_eq!(
1294      warning_cat["math"]["tasks"], "2",
1295      "math: distinct tasks A,B"
1296    );
1297    assert_eq!(warning_cat["math"]["messages"], "3", "math: 2 (A) + 1 (B)");
1298    assert_eq!(warning_cat["font"]["tasks"], "1");
1299    assert_eq!(
1300      warning_cat["no_messages"]["tasks"], "1",
1301      "one silent task D"
1302    );
1303    assert_eq!(warning_cat["total"]["tasks"], "4", "A,B,C,D");
1304
1305    // --- Guard: a severity with no rollup rows degrades gracefully (no panic; a zeroed total row),
1306    //     and the rollup path still matches the live path on that empty case ----------------------
1307    let fatal_fast = by_name(task_report(conn, options(&corpus, &service, "fatal", None)));
1308    assert!(
1309      fatal_fast.contains_key("total"),
1310      "an empty-severity report must still carry a total row"
1311    );
1312    assert_eq!(
1313      fatal_fast["total"]["tasks"], "0",
1314      "no fatal tasks -> 0 total"
1315    );
1316    let fatal_live = by_name(task_report_live(
1317      conn,
1318      options(&corpus, &service, "fatal", None),
1319    ));
1320    assert_eq!(
1321      fatal_fast, fatal_live,
1322      "empty severity: rollup vs live mismatch"
1323    );
1324
1325    // --- Pagination: page_size 1 over the two warning categories (math=2 tasks, font=1) ----------
1326    // Each page carries its single category plus the always-present whole-severity total row.
1327    let page0 = by_name(task_report(
1328      conn,
1329      options_paged(&corpus, &service, "warning", None, 1, 0),
1330    ));
1331    let page1 = by_name(task_report(
1332      conn,
1333      options_paged(&corpus, &service, "warning", None, 1, 1),
1334    ));
1335    assert!(
1336      page0.contains_key("math") && !page0.contains_key("font"),
1337      "page 0 (busiest first) = math only"
1338    );
1339    assert!(
1340      page1.contains_key("font") && !page1.contains_key("math"),
1341      "page 1 = font only"
1342    );
1343    // Totals are whole-severity on every page, not per-page.
1344    assert_eq!(
1345      page0["total"]["tasks"], "4",
1346      "total is whole-severity on page 0"
1347    );
1348    assert_eq!(
1349      page1["total"]["tasks"], "4",
1350      "total is whole-severity on page 1"
1351    );
1352  }
1353
1354  #[test]
1355  fn report_uses_rollup_routes_aggregate_grains_to_cache() {
1356    use super::report_uses_rollup;
1357    // The aggregate grains — the category report and the `what` drill-down — are served from the
1358    // per-(corpus, service, severity) `report_grain_cache` (the cached readers). Per-task entry
1359    // lists (`no_messages`, the `what`-detail list), the all-severities view, and non-drill-down
1360    // severities have no cached grain and fall through to the live `task_report_live` path.
1361    for sev in ["warning", "error", "fatal", "invalid", "info"] {
1362      assert!(
1363        report_uses_rollup(Some(sev), None, None, false),
1364        "{sev} category report should hit the cache"
1365      );
1366      assert!(
1367        report_uses_rollup(Some(sev), Some("cat"), None, false),
1368        "{sev} what drill-down should hit the cache"
1369      );
1370      // per-task `what`-detail list → live
1371      assert!(!report_uses_rollup(
1372        Some(sev),
1373        Some("cat"),
1374        Some("w"),
1375        false
1376      ));
1377    }
1378    // `no_messages` is a per-task entry list, not an aggregate grain → live
1379    assert!(!report_uses_rollup(
1380      Some("warning"),
1381      Some("no_messages"),
1382      None,
1383      false
1384    ));
1385    // non-drill-down severities have no cached grain → live
1386    assert!(!report_uses_rollup(Some("no_problem"), None, None, false));
1387    assert!(!report_uses_rollup(Some("todo"), None, None, false));
1388    // all-severities view → live
1389    assert!(!report_uses_rollup(Some("warning"), None, None, true));
1390    assert!(!report_uses_rollup(None, None, None, false));
1391  }
1392}
1393
1394#[cfg(test)]
1395mod live_run_diff_baseline_tests {
1396  //! Regression for the "−0 regressed · +0 improved · all unchanged" idle-scope bug: the live
1397  //! run-diff baseline must be the most-recent snapshot taken BEFORE the current run started, never
1398  //! the run's OWN completion-on-drain self-snapshot. With the buggy "latest snapshot" baseline a
1399  //! completed, idle scope compared the live `tasks` against the very snapshot they were frozen
1400  //! into → a vacuous all-unchanged diff. See [`super::compute_live_run_diff`].
1401  use super::compute_live_run_diff;
1402  use crate::backend;
1403  use crate::helpers::TaskStatus;
1404  use crate::models::{Corpus, NewCorpus, NewService, Service};
1405  use crate::schema::{corpora, historical_runs, historical_tasks, services, tasks};
1406  use chrono::{NaiveDate, NaiveDateTime};
1407  use diesel::prelude::*;
1408
1409  const CORPUS_NAME: &str = "live-diff-baseline corpus";
1410  const SERVICE_NAME: &str = "live_diff_baseline_svc";
1411
1412  fn ts(year: i32, month: u32, day: u32) -> NaiveDateTime {
1413    NaiveDate::from_ymd_opt(year, month, day)
1414      .unwrap()
1415      .and_hms_opt(0, 0, 0)
1416      .unwrap()
1417  }
1418
1419  fn add_task(conn: &mut PgConnection, entry: &str, service: i32, corpus: i32, status: i32) -> i64 {
1420    diesel::insert_into(tasks::table)
1421      .values((
1422        tasks::entry.eq(entry),
1423        tasks::service_id.eq(service),
1424        tasks::corpus_id.eq(corpus),
1425        tasks::status.eq(status),
1426      ))
1427      .returning(tasks::id)
1428      .get_result(conn)
1429      .expect("insert task")
1430  }
1431
1432  fn add_snapshot(conn: &mut PgConnection, task_id: i64, status: i32, saved_at: NaiveDateTime) {
1433    diesel::insert_into(historical_tasks::table)
1434      .values((
1435        historical_tasks::task_id.eq(task_id),
1436        historical_tasks::status.eq(status),
1437        historical_tasks::saved_at.eq(saved_at),
1438      ))
1439      .execute(conn)
1440      .expect("insert historical snapshot");
1441  }
1442
1443  #[test]
1444  fn baseline_excludes_the_current_runs_own_self_snapshot() {
1445    let mut backend = backend::testdb();
1446
1447    // --- Clean slate -----------------------------------------------------------------------------
1448    if let Ok(existing) = Corpus::find_by_name(CORPUS_NAME, &mut backend.connection) {
1449      // historical_tasks cascades away with tasks (FK ON DELETE CASCADE); drop runs explicitly.
1450      diesel::delete(historical_runs::table.filter(historical_runs::corpus_id.eq(existing.id)))
1451        .execute(&mut backend.connection)
1452        .ok();
1453      diesel::delete(tasks::table.filter(tasks::corpus_id.eq(existing.id)))
1454        .execute(&mut backend.connection)
1455        .ok();
1456      diesel::delete(corpora::table.filter(corpora::id.eq(existing.id)))
1457        .execute(&mut backend.connection)
1458        .ok();
1459    }
1460    diesel::delete(services::table.filter(services::name.eq(SERVICE_NAME)))
1461      .execute(&mut backend.connection)
1462      .ok();
1463
1464    // --- Seed corpus + service -------------------------------------------------------------------
1465    backend
1466      .add(&NewCorpus {
1467        name: CORPUS_NAME.to_string(),
1468        path: "/tmp/live-diff-baseline".to_string(),
1469        complex: true,
1470        description: String::new(),
1471      })
1472      .expect("add corpus");
1473    let corpus = Corpus::find_by_name(CORPUS_NAME, &mut backend.connection).expect("corpus");
1474    backend
1475      .add(&NewService {
1476        name: SERVICE_NAME.to_string(),
1477        version: 0.1,
1478        inputformat: "tex".to_string(),
1479        outputformat: "html".to_string(),
1480        inputconverter: Some("import".to_string()),
1481        complex: true,
1482        description: String::from("live-diff baseline service"),
1483      })
1484      .expect("add service");
1485    let service = Service::find_by_name(SERVICE_NAME, &mut backend.connection).expect("service");
1486
1487    let no_problem = TaskStatus::NoProblem.raw(); // -1 (best)
1488    let warning = TaskStatus::Warning.raw(); // -2
1489    let error = TaskStatus::Error.raw(); // -3
1490    let conn = &mut backend.connection;
1491
1492    // Current settled outcomes of the just-completed run.
1493    let t1 = add_task(conn, "/d/1", service.id, corpus.id, no_problem);
1494    let t2 = add_task(conn, "/d/2", service.id, corpus.id, no_problem);
1495    let t3 = add_task(conn, "/d/3", service.id, corpus.id, error);
1496
1497    // The current (latest) run: started at T_mid. Its completion self-snapshot lands AFTER T_mid.
1498    let t_prior = ts(2025, 1, 1); // prior run's completion snapshot — the TRUE baseline
1499    let t_mid = ts(2025, 6, 1); // current run start_time
1500    let t_self = ts(2025, 12, 1); // current run's own completion-on-drain snapshot
1501    diesel::insert_into(historical_runs::table)
1502      .values((
1503        historical_runs::corpus_id.eq(corpus.id),
1504        historical_runs::service_id.eq(service.id),
1505        historical_runs::owner.eq("tester"),
1506        historical_runs::start_time.eq(t_mid),
1507        historical_runs::end_time.eq(t_self),
1508      ))
1509      .execute(conn)
1510      .expect("insert historical_run");
1511
1512    // Prior run's snapshot (BEFORE the current run started): t1 was a Warning then.
1513    add_snapshot(conn, t1, warning, t_prior);
1514    add_snapshot(conn, t2, no_problem, t_prior);
1515    add_snapshot(conn, t3, error, t_prior);
1516
1517    // The current run's OWN self-snapshot (AFTER it started) — identical to the live tasks. A naive
1518    // "latest snapshot" baseline would pick THIS and report a vacuous 0/0/all-unchanged.
1519    add_snapshot(conn, t1, no_problem, t_self);
1520    add_snapshot(conn, t2, no_problem, t_self);
1521    add_snapshot(conn, t3, error, t_self);
1522
1523    let diff = compute_live_run_diff(conn, corpus.id, service.id);
1524
1525    // Baseline must resolve to the prior (t_prior) snapshot, NOT the self (t_self) one:
1526    //   t1: Warning(-2) → NoProblem(-1)  = improved
1527    //   t2: NoProblem → NoProblem        = unchanged
1528    //   t3: Error → Error                = unchanged
1529    assert_eq!(
1530      diff.improved, 1,
1531      "t1's Warning→NoProblem must register as an improvement vs the PRIOR run's snapshot"
1532    );
1533    assert_eq!(diff.regressed, 0, "no task regressed");
1534    assert_eq!(
1535      diff.unchanged, 2,
1536      "t2 and t3 are unchanged; the count must NOT be 3 (which is the self-comparison bug)"
1537    );
1538    assert_eq!(diff.reclassified, 0, "no Invalid transitions");
1539    assert_eq!(
1540      diff.compared(),
1541      3,
1542      "all three completed tasks had a baseline"
1543    );
1544
1545    // Cleanup.
1546    diesel::delete(historical_runs::table.filter(historical_runs::corpus_id.eq(corpus.id)))
1547      .execute(conn)
1548      .ok();
1549    diesel::delete(tasks::table.filter(tasks::corpus_id.eq(corpus.id)))
1550      .execute(conn)
1551      .ok();
1552    diesel::delete(corpora::table.filter(corpora::id.eq(corpus.id)))
1553      .execute(conn)
1554      .ok();
1555    diesel::delete(services::table.filter(services::name.eq(SERVICE_NAME)))
1556      .execute(conn)
1557      .ok();
1558  }
1559}