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}