Skip to main content

cortex/backend/
rollup.rs

1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! Per-`(corpus, service, severity)`-scoped report cache for the category/what drill-down reports.
9//!
10//! These functions used to read a global `report_summary` MATERIALIZED VIEW whose `REFRESH` scanned
11//! all five `log_*` tables across **every** corpus (~99 GB / 345M rows, ~16 min under fleet load)
12//! and starved the conversion finalize path. That global cube was retired. Reports are now backed
13//! by `report_grain_cache`, a regular table populated **one `(corpus, service, severity)` slice at
14//! a time** — there is no code path that aggregates more than one corpus at once, so reporting can
15//! never again stall conversions. A slice is (re)computed:
16//!
17//! * **cold miss** — a reader for an un-cached slice runs the scoped aggregation once
18//!   ([`populate_scope`]) and stores it, then reads. This is the only place the heavy scan happens,
19//!   and it is bounded to one log table for one corpus (ms for a typical corpus, paid once until
20//!   invalidated). For the largest slice it is **minutes**: the full-arXiv `info` slice joins every
21//!   completed task (~2.8M) to ~255M `log_infos` rows (~half the 531M-row table), then sorts and
22//!   `COUNT(DISTINCT task_id)`-aggregates them — ~127 s measured (`EXPLAIN ANALYZE`, 2026-06-28),
23//!   dominated by a 17 GB on-disk sort and the distinct-count, *not* the scan (so no single-table
24//!   index makes it fast — see POSSIBLE_UPGRADES). Far too long for the request thread, so the
25//!   frontend doesn't run it there: [`crate::frontend::concerns::serve_report`] tries a
26//!   **budget-bounded** inline populate ([`populate_scope_bounded`]) and, if it overruns, hands the
27//!   aggregation to a background job ([`crate::jobs::spawn_report_populate`]) and shows a "report
28//!   computing" page that refreshes when the slice lands.
29//! * **rerun** — [`invalidate_scope`] clears the reran scope; the next view repopulates fresh.
30//! * **run-completion** — the dispatcher finalize thread invalidates only the scopes it touched.
31//! * **force refresh** — [`invalidate_all`] drops every cached slice (a cheap DELETE, not a scan);
32//!   each repopulates lazily per scope on its next view.
33//!
34//! The cache rows mirror the retired matview's `ROLLUP(category, what)` grains, so the readers
35//! `SELECT` them with the same per-grain filters:
36//!
37//! | grain              | rows (`category_is_total`, `what_is_total`) | reader                                   |
38//! |--------------------|---------------------------------------------|------------------------------------------|
39//! | per `what`         | `(0, 0)`                                    | [`what_rollup`]                          |
40//! | per category       | `(0, 1)`                                    | [`category_rollup`] / [`category_total`] |
41//! | per severity total | `(1, 1)`                                    | [`severity_total`]                       |
42
43use diesel::prelude::*;
44use diesel::sql_query;
45use diesel::sql_types::{Integer, Text};
46
47use crate::schema::report_grain_cache::dsl as rgc;
48
49/// One report row at a given grain: a `what` (the drill-down report), a category-grain rollup
50/// (`what = None`, the "category" report), or the per-severity grand total (`category = ""`,
51/// `what = None`). Distinct-task counts are computed by Postgres at each grain (they are not
52/// summable from a finer grain). Stored in `report_grain_cache`, populated per scope on demand.
53#[derive(QueryableByName, Debug, Clone)]
54pub struct ReportSummaryRow {
55  /// Log-message category (the empty string for uncategorized messages and for the grand total).
56  #[diesel(sql_type = Text)]
57  pub category: String,
58  /// The `what` class; `None` for the category-grain and grand-total rows.
59  #[diesel(sql_type = diesel::sql_types::Nullable<Text>)]
60  pub what: Option<String>,
61  /// Distinct tasks contributing to this grain — computed by Postgres (distinct counts are not
62  /// summable from a finer grain).
63  #[diesel(sql_type = diesel::sql_types::BigInt)]
64  pub task_count: i64,
65  /// Total log messages for this grain.
66  #[diesel(sql_type = diesel::sql_types::BigInt)]
67  pub message_count: i64,
68}
69
70/// Maps a drill-down severity key to its `(log table, task-status predicate)` — the scoped-query
71/// equivalent of the retired matview's per-severity UNION branch. `warning|error|fatal|invalid`
72/// key off the task's worst-message status; `info` is the all-messages dimension over `log_infos`
73/// across every completed, non-invalid task (`-5 < status < 0`). Returns `None` for any
74/// non-drill-down key, so the report yields no rows — exactly what the matview lookup did for an
75/// absent severity. Both returned strings are compile-time constants (never user input), so they
76/// are safe to interpolate into the query text below.
77fn severity_scope(severity: &str) -> Option<(&'static str, &'static str)> {
78  match severity {
79    "warning" => Some(("log_warnings", "t.status = -2")),
80    "error" => Some(("log_errors", "t.status = -3")),
81    "fatal" => Some(("log_fatals", "t.status = -4")),
82    "invalid" => Some(("log_invalids", "t.status = -5")),
83    // The all-messages dimension: every completed, non-invalid task (NoProblem..Fatal).
84    "info" => Some(("log_infos", "t.status < 0 AND t.status > -5")),
85    _ => None,
86  }
87}
88
89/// Retired no-op. The `report_summary` matview it used to rebuild is gone, and the
90/// `report_grain_cache` that replaced it is maintained per scope (see [`populate_scope`] /
91/// [`invalidate_scope`] / [`invalidate_all`]), never globally. Kept so any lingering call site
92/// stays valid and does no work — a report refresh can no longer take a lock or starve conversions.
93pub(crate) fn refresh_report_summary(_connection: &mut PgConnection) -> QueryResult<()> { Ok(()) }
94
95/// Freshness of the cached `(corpus, service, severity)` slice, for the report footer's "generated
96/// at" pill: the slice's `computed_at` (every row in a slice shares one stamp, set by
97/// [`populate_scope`]'s upsert), as `(epoch_millis, human)`. `None` when the slice isn't cached yet
98/// — the footer then shows nothing rather than a wrong time. The drill-down report path stamps the
99/// footer with this; the live-computed top-level overview stamps "just now" instead.
100pub(crate) fn report_cache_computed_at(
101  connection: &mut PgConnection,
102  corpus_id: i32,
103  service_id: i32,
104  severity: &str,
105) -> Option<(i64, String)> {
106  rgc::report_grain_cache
107    .filter(rgc::corpus_id.eq(corpus_id))
108    .filter(rgc::service_id.eq(service_id))
109    .filter(rgc::severity.eq(severity))
110    .select(rgc::computed_at)
111    .first::<chrono::DateTime<chrono::Utc>>(connection)
112    .optional()
113    .ok()
114    .flatten()
115    .map(|ts| {
116      (
117        ts.timestamp_millis(),
118        ts.with_timezone(&chrono::Local)
119          .format("%a, %d %b %Y %H:%M %Z")
120          .to_string(),
121      )
122    })
123}
124
125/// Whether `report_grain_cache` already holds the given `(corpus, service, severity)` slice. A
126/// slice with zero messages produces no `ROLLUP` rows, so this stays `false` for empty severities —
127/// their (cheap, no-row) aggregation simply re-runs on each read, which is harmless.
128pub(crate) fn scope_cached(
129  connection: &mut PgConnection,
130  corpus_id: i32,
131  service_id: i32,
132  severity: &str,
133) -> bool {
134  #[derive(QueryableByName)]
135  struct Flag {
136    #[diesel(sql_type = diesel::sql_types::Bool)]
137    hit: bool,
138  }
139  sql_query(
140    "SELECT EXISTS(SELECT 1 FROM report_grain_cache \
141       WHERE corpus_id = $1 AND service_id = $2 AND severity = $3) AS hit",
142  )
143  .bind::<Integer, _>(corpus_id)
144  .bind::<Integer, _>(service_id)
145  .bind::<Text, _>(severity)
146  .get_result::<Flag>(connection)
147  .map(|f| f.hit)
148  .unwrap_or(false)
149}
150
151/// (Re)compute and store the full `ROLLUP(category, what)` grain set for one
152/// `(corpus, service, severity)` slice — the matview's per-severity branch, scoped to one corpus.
153/// Runs `DELETE` + `INSERT` in one transaction so a concurrent reader sees the old or new slice
154/// atomically, never a half-written one. The `ON CONFLICT DO UPDATE` makes a rare cold-miss race
155/// (two requests populating the same slice at once) last-writer-wins instead of erroring. No-op for
156/// a non-drill-down severity (e.g. `no_problem`/`todo`), matching the matview's absence of those
157/// rows.
158pub(crate) fn populate_scope(
159  connection: &mut PgConnection,
160  corpus_id: i32,
161  service_id: i32,
162  severity: &str,
163) -> QueryResult<()> {
164  let Some((table, status_pred)) = severity_scope(severity) else {
165    return Ok(());
166  };
167  connection.transaction::<(), diesel::result::Error, _>(|conn| {
168    populate_scope_slice(conn, corpus_id, service_id, severity, table, status_pred)
169  })
170}
171
172/// Request-path variant of [`populate_scope`] that abandons the aggregation if it runs past
173/// `budget_ms`, leaving the slice uncached so the caller can hand off to a background job
174/// ([`crate::jobs::spawn_report_populate`]). A per-transaction `statement_timeout` bounds how long
175/// this holds its pooled connection: small and empty slices finish well inside the budget and are
176/// served inline exactly as before, but the full-arXiv `info` slice (minutes) is cancelled instead
177/// of pinning a frontend connection and blocking the viewer (the P-2 pool-saturation class). Both
178/// outcomes leave a crash-consistent state — the whole DELETE+INSERT is one transaction, and a
179/// cancel rolls it back. `Ok(())` means the slice was populated within budget; an `Err` means it
180/// was cancelled (or otherwise failed) and the slice is still uncached.
181pub(crate) fn populate_scope_bounded(
182  connection: &mut PgConnection,
183  corpus_id: i32,
184  service_id: i32,
185  severity: &str,
186  budget_ms: u32,
187) -> QueryResult<()> {
188  let Some((table, status_pred)) = severity_scope(severity) else {
189    return Ok(());
190  };
191  connection.transaction::<(), diesel::result::Error, _>(|conn| {
192    // Transaction-local cap on the heavy INSERT...SELECT below; `budget_ms` is our own integer
193    // (never user input), so the interpolation is injection-safe. On expiry Postgres cancels the
194    // statement (SQLSTATE 57014) and Diesel surfaces an `Err`, rolling back the whole slice.
195    sql_query(format!("SET LOCAL statement_timeout = {budget_ms}")).execute(conn)?;
196    populate_scope_slice(conn, corpus_id, service_id, severity, table, status_pred)
197  })
198}
199
200/// The DELETE + ROLLUP-aggregate INSERT for one slice, run inside the caller's transaction (shared
201/// by [`populate_scope`] and [`populate_scope_bounded`] so the heavy query has a single
202/// definition).
203fn populate_scope_slice(
204  conn: &mut PgConnection,
205  corpus_id: i32,
206  service_id: i32,
207  severity: &str,
208  table: &str,
209  status_pred: &str,
210) -> QueryResult<()> {
211  sql_query(
212    "DELETE FROM report_grain_cache \
213       WHERE corpus_id = $1 AND service_id = $2 AND severity = $3",
214  )
215  .bind::<Integer, _>(corpus_id)
216  .bind::<Integer, _>(service_id)
217  .bind::<Text, _>(severity)
218  .execute(conn)?;
219  sql_query(format!(
220    "INSERT INTO report_grain_cache \
221       (corpus_id, service_id, severity, category, what, category_is_total, what_is_total, \
222        task_count, message_count) \
223     SELECT $1, $2, $3, \
224       CASE WHEN GROUPING(COALESCE(l.category, '')) = 1 THEN NULL \
225            ELSE COALESCE(l.category, '') END, \
226       CASE WHEN GROUPING(COALESCE(l.what, '')) = 1 THEN NULL \
227            ELSE COALESCE(l.what, '') END, \
228       GROUPING(COALESCE(l.category, ''))::int, GROUPING(COALESCE(l.what, ''))::int, \
229       COUNT(DISTINCT l.task_id)::bigint, COUNT(*)::bigint \
230     FROM tasks t JOIN {table} l ON l.task_id = t.id \
231     WHERE t.corpus_id = $1 AND t.service_id = $2 AND {status_pred} \
232     GROUP BY ROLLUP(COALESCE(l.category, ''), COALESCE(l.what, '')) \
233     HAVING COUNT(*) > 0 \
234     ON CONFLICT (corpus_id, service_id, severity, category_is_total, what_is_total, category, what) \
235       DO UPDATE SET task_count = EXCLUDED.task_count, \
236                     message_count = EXCLUDED.message_count, \
237                     computed_at = now()"
238  ))
239  .bind::<Integer, _>(corpus_id)
240  .bind::<Integer, _>(service_id)
241  .bind::<Text, _>(severity)
242  .execute(conn)?;
243  Ok(())
244}
245
246/// Ensure the slice is cached, populating it on a cold miss. Shared by every reader.
247fn ensure_scope(
248  connection: &mut PgConnection,
249  corpus_id: i32,
250  service_id: i32,
251  severity: &str,
252) -> QueryResult<()> {
253  if !scope_cached(connection, corpus_id, service_id, severity) {
254    populate_scope(connection, corpus_id, service_id, severity)?;
255  }
256  Ok(())
257}
258
259/// Drop every cached grain for one `(corpus, service)` scope (all severities). Cheap — a keyed
260/// `DELETE`, no scan; the next report view repopulates the slice it needs. Called on the rerun path
261/// and (per touched scope) on run-completion.
262pub(crate) fn invalidate_scope(
263  connection: &mut PgConnection,
264  corpus_id: i32,
265  service_id: i32,
266) -> QueryResult<()> {
267  sql_query("DELETE FROM report_grain_cache WHERE corpus_id = $1 AND service_id = $2")
268    .bind::<Integer, _>(corpus_id)
269    .bind::<Integer, _>(service_id)
270    .execute(connection)
271    .map(|_| ())
272}
273
274/// Drop the entire cache (a cheap `DELETE`, never a scan). Each `(corpus, service, severity)` slice
275/// then repopulates lazily, per scope, on its next view. Backs the global "Force refresh" action.
276pub(crate) fn invalidate_all(connection: &mut PgConnection) -> QueryResult<()> {
277  sql_query("DELETE FROM report_grain_cache")
278    .execute(connection)
279    .map(|_| ())
280}
281
282/// Category-grain report for a `(corpus, service, severity)`: one row per category with its
283/// distinct-task and message counts, ordered by descending task count (ties broken by category name
284/// for a stable paging order), windowed to `[offset, offset + limit)`. Served from the cache,
285/// populated on a cold miss.
286pub(crate) fn category_rollup(
287  connection: &mut PgConnection,
288  corpus_id: i32,
289  service_id: i32,
290  severity: &str,
291  limit: i64,
292  offset: i64,
293) -> QueryResult<Vec<ReportSummaryRow>> {
294  if severity_scope(severity).is_none() {
295    return Ok(Vec::new());
296  }
297  ensure_scope(connection, corpus_id, service_id, severity)?;
298  let rows: Vec<(Option<String>, i64, i64)> = rgc::report_grain_cache
299    .filter(rgc::corpus_id.eq(corpus_id))
300    .filter(rgc::service_id.eq(service_id))
301    .filter(rgc::severity.eq(severity))
302    .filter(rgc::category_is_total.eq(0))
303    .filter(rgc::what_is_total.eq(1))
304    .select((rgc::category, rgc::task_count, rgc::message_count))
305    .order((rgc::task_count.desc(), rgc::category.asc()))
306    .limit(limit)
307    .offset(offset)
308    .load(connection)?;
309  Ok(
310    rows
311      .into_iter()
312      .map(|(category, task_count, message_count)| ReportSummaryRow {
313        category: category.unwrap_or_default(),
314        what: None,
315        task_count,
316        message_count,
317      })
318      .collect(),
319  )
320}
321
322/// The single category-grain row for one `(corpus, service, severity, category)`, i.e. its distinct
323/// tasks and total messages — the denominators the `what` drill-down report needs. `None` when the
324/// category has no messages of this severity.
325pub(crate) fn category_total(
326  connection: &mut PgConnection,
327  corpus_id: i32,
328  service_id: i32,
329  severity: &str,
330  category: &str,
331) -> QueryResult<Option<ReportSummaryRow>> {
332  if severity_scope(severity).is_none() {
333    return Ok(None);
334  }
335  ensure_scope(connection, corpus_id, service_id, severity)?;
336  let row: Option<(Option<String>, i64, i64)> = rgc::report_grain_cache
337    .filter(rgc::corpus_id.eq(corpus_id))
338    .filter(rgc::service_id.eq(service_id))
339    .filter(rgc::severity.eq(severity))
340    .filter(rgc::category_is_total.eq(0))
341    .filter(rgc::what_is_total.eq(1))
342    .filter(rgc::category.eq(category))
343    .select((rgc::category, rgc::task_count, rgc::message_count))
344    .first(connection)
345    .optional()?;
346  Ok(
347    row.map(|(category, task_count, message_count)| ReportSummaryRow {
348      category: category.unwrap_or_default(),
349      what: None,
350      task_count,
351      message_count,
352    }),
353  )
354}
355
356/// `what`-grain drill-down for a `(corpus, service, severity, category)`: one row per `what`,
357/// ordered by descending task count (ties broken by `what` for a stable paging order), windowed to
358/// `[offset, offset + limit)`. Served from the cache, populated on a cold miss.
359pub(crate) fn what_rollup(
360  connection: &mut PgConnection,
361  corpus_id: i32,
362  service_id: i32,
363  severity: &str,
364  category: &str,
365  limit: i64,
366  offset: i64,
367) -> QueryResult<Vec<ReportSummaryRow>> {
368  if severity_scope(severity).is_none() {
369    return Ok(Vec::new());
370  }
371  ensure_scope(connection, corpus_id, service_id, severity)?;
372  let rows: Vec<(Option<String>, i64, i64)> = rgc::report_grain_cache
373    .filter(rgc::corpus_id.eq(corpus_id))
374    .filter(rgc::service_id.eq(service_id))
375    .filter(rgc::severity.eq(severity))
376    .filter(rgc::category_is_total.eq(0))
377    .filter(rgc::what_is_total.eq(0))
378    .filter(rgc::category.eq(category))
379    .select((rgc::what, rgc::task_count, rgc::message_count))
380    .order((rgc::task_count.desc(), rgc::what.asc()))
381    .limit(limit)
382    .offset(offset)
383    .load(connection)?;
384  Ok(
385    rows
386      .into_iter()
387      .map(|(what, task_count, message_count)| ReportSummaryRow {
388        category: category.to_string(),
389        what: Some(what.unwrap_or_default()),
390        task_count,
391        message_count,
392      })
393      .collect(),
394  )
395}
396
397/// The per-severity grand total for a `(corpus, service, severity)`: distinct tasks that carry at
398/// least one message of this severity, and the total message count. `None` when the severity has no
399/// logged messages (the `ROLLUP` produces no rows for an empty slice). (`category` comes back as
400/// the empty string.)
401pub(crate) fn severity_total(
402  connection: &mut PgConnection,
403  corpus_id: i32,
404  service_id: i32,
405  severity: &str,
406) -> QueryResult<Option<ReportSummaryRow>> {
407  if severity_scope(severity).is_none() {
408    return Ok(None);
409  }
410  ensure_scope(connection, corpus_id, service_id, severity)?;
411  let row: Option<(i64, i64)> = rgc::report_grain_cache
412    .filter(rgc::corpus_id.eq(corpus_id))
413    .filter(rgc::service_id.eq(service_id))
414    .filter(rgc::severity.eq(severity))
415    .filter(rgc::category_is_total.eq(1))
416    .select((rgc::task_count, rgc::message_count))
417    .first(connection)
418    .optional()?;
419  Ok(row.map(|(task_count, message_count)| ReportSummaryRow {
420    category: String::new(),
421    what: None,
422    task_count,
423    message_count,
424  }))
425}