Skip to main content

cortex/backend/
mark.rs

1use std::collections::HashMap;
2
3use crate::schema::{
4  historical_tasks, log_errors, log_fatals, log_infos, log_invalids, log_warnings, task_runtimes,
5  tasks,
6};
7use diesel::result::Error;
8use diesel::*;
9
10use super::RerunOptions;
11use crate::concerns::{CortexInsertable, MarkRerun};
12use crate::helpers::{NewTaskMessage, TaskReport, TaskStatus, rerun_mark};
13use crate::models::{
14  Corpus, HistoricalRun, LogError, LogFatal, LogInfo, LogInvalid, LogRecord, LogWarning,
15  NewHistoricalRun, NewLogError, NewLogFatal, NewLogInfo, NewLogInvalid, NewLogWarning, NewTask,
16  NewTaskRuntime, Service,
17};
18
19pub(crate) fn mark_imported(
20  connection: &mut PgConnection,
21  imported_tasks: &[NewTask],
22) -> Result<usize, Error> {
23  // Insert, but only if the task is new (allow for extension calls with the same method)
24  insert_into(tasks::table)
25    .values(imported_tasks)
26    .on_conflict_do_nothing()
27    .execute(connection)
28}
29
30/// **Pause** a `(corpus, service)` run: transition every **in-progress** task (`status >= 0` — i.e.
31/// TODO plus any leased/Queued mark) to **Blocked**, so the ventilator stops handing them out (it
32/// only fetches `status = TODO`). Completed tasks (`status < 0`) are left alone. Returns the number
33/// paused. The exact inverse of [`resume_blocked`] (Arm 7 run-lifecycle control).
34pub(crate) fn mark_blocked(
35  connection: &mut PgConnection,
36  corpus_id_val: i32,
37  service_id_val: i32,
38) -> Result<usize, Error> {
39  update(tasks::table)
40    .filter(tasks::corpus_id.eq(corpus_id_val))
41    .filter(tasks::service_id.eq(service_id_val))
42    .filter(tasks::status.ge(0))
43    .set(tasks::status.eq(TaskStatus::Blocked(-6).raw()))
44    .execute(connection)
45}
46
47/// **Resume** a paused `(corpus, service)` run: transition every **Blocked** task (`status < -5`)
48/// back to **TODO** (`0`), so the ventilator re-leases it on the next fetch. Returns the number
49/// resumed. The exact inverse of [`mark_blocked`]; completed and already-in-progress tasks are left
50/// alone (Invalid is `-5`, so the `< -5` filter never touches it).
51pub(crate) fn resume_blocked(
52  connection: &mut PgConnection,
53  corpus_id_val: i32,
54  service_id_val: i32,
55) -> Result<usize, Error> {
56  update(tasks::table)
57    .filter(tasks::corpus_id.eq(corpus_id_val))
58    .filter(tasks::service_id.eq(service_id_val))
59    .filter(tasks::status.lt(-5))
60    .set(tasks::status.eq(TaskStatus::TODO.raw()))
61    .execute(connection)
62}
63
64/// **Pause ALL conversions** (the dashboard's global control): block every in-progress task
65/// (`status >= 0`) across **every** `(corpus, service)`, fleet-wide, so the ventilator stops
66/// leasing new work everywhere. The global twin of [`mark_blocked`]; returns the number paused.
67/// Reversible with [`resume_all_blocked`]. (In-flight tasks already dispatched still land their
68/// results — pause only stops *new* leasing, exactly like the per-run pause.)
69pub(crate) fn mark_all_blocked(connection: &mut PgConnection) -> Result<usize, Error> {
70  update(tasks::table)
71    .filter(tasks::status.ge(0))
72    .set(tasks::status.eq(TaskStatus::Blocked(-6).raw()))
73    .execute(connection)
74}
75
76/// **Resume ALL conversions**: return every Blocked task (`status < -5`) across every pair to TODO
77/// — the exact inverse of [`mark_all_blocked`]. Returns the number resumed.
78pub(crate) fn resume_all_blocked(connection: &mut PgConnection) -> Result<usize, Error> {
79  update(tasks::table)
80    .filter(tasks::status.lt(-5))
81    .set(tasks::status.eq(TaskStatus::TODO.raw()))
82    .execute(connection)
83}
84
85pub(crate) fn mark_done(
86  connection: &mut PgConnection,
87  reports: &[TaskReport],
88) -> Result<(), Error> {
89  use crate::schema::tasks::{id, status};
90
91  // Collect the finalized task ids once, to clear their prior logs in a single batched statement
92  // per table instead of five deletes *per task*. The done-queue yields distinct task ids per
93  // drain, so a batched `task_id = ANY(...)` deletes exactly the same rows as the old per-task
94  // loop — far fewer round-trips on the hot finalize path (KNOWN_ISSUES D-8 write-amplification).
95  let task_ids: Vec<i64> = reports.iter().map(|report| report.task.id).collect();
96  // PostgreSQL caps a single statement at 65535 bind parameters. The finalize path
97  // batches across an entire drained burst of reports, so at fleet scale (many papers
98  // × many log messages) the per-severity INSERTs and the `eq_any` id lists overflow
99  // that cap — which made the whole statement fail (`number of parameters must be
100  // between 0 and 65535`), the finalize thread panic, and the dispatcher wedge
101  // (observed on a 64-worker run, 2026-06-17). Chunk every batched statement to stay
102  // under the cap: `eq_any` binds 1 param per id; a log row binds 4 columns
103  // (task_id, category, what, details), so 16k rows ≈ 64k params.
104  const ID_CHUNK: usize = 50_000;
105  const LOG_INSERT_CHUNK: usize = 16_000;
106  connection.transaction::<(), Error, _>(|t_connection| {
107    // Clear the prior log messages for every finalized task (one statement per severity
108    // table), chunked over the task-id list to respect the bind-parameter cap.
109    for ids in task_ids.chunks(ID_CHUNK) {
110      delete(log_infos::table.filter(log_infos::task_id.eq_any(ids))).execute(t_connection)?;
111      delete(log_warnings::table.filter(log_warnings::task_id.eq_any(ids)))
112        .execute(t_connection)?;
113      delete(log_errors::table.filter(log_errors::task_id.eq_any(ids))).execute(t_connection)?;
114      delete(log_fatals::table.filter(log_fatals::task_id.eq_any(ids))).execute(t_connection)?;
115      delete(log_invalids::table.filter(log_invalids::task_id.eq_any(ids)))
116        .execute(t_connection)?;
117      delete(task_runtimes::table.filter(task_runtimes::task_id.eq_any(ids)))
118        .execute(t_connection)?;
119    }
120    // Group the finalized task ids by their target status, and partition the new messages by
121    // severity table, in one pass — so both the status UPDATEs and the message INSERTs become a
122    // handful of batched statements below instead of two-per-task in a loop (the finalize hot
123    // path).
124    let mut ids_by_status: HashMap<i32, Vec<i64>> = HashMap::new();
125    let mut new_infos: Vec<NewLogInfo> = Vec::new();
126    let mut new_warnings: Vec<NewLogWarning> = Vec::new();
127    let mut new_errors: Vec<NewLogError> = Vec::new();
128    let mut new_fatals: Vec<NewLogFatal> = Vec::new();
129    let mut new_invalids: Vec<NewLogInvalid> = Vec::new();
130    // Denormalized per-task runtimes, mirrored from each report's `Info:runtime_ms:<N>` line so
131    // the per-service runtime report aggregates over this narrow table instead of re-scanning ~2.8M
132    // `log_infos` rows JOINed to `tasks` on every page view (see migration …_create_task_runtimes).
133    let mut new_runtimes: Vec<NewTaskRuntime> = Vec::new();
134    for report in reports.iter() {
135      ids_by_status
136        .entry(report.status.raw())
137        .or_default()
138        .push(report.task.id);
139      for message in &report.messages {
140        // The synthetic conversion-status message is not a real log entry (kept out of the tables).
141        if message.severity() == "status" {
142          continue;
143        }
144        // The runtime line is also an Info log row; capture its parsed value for `task_runtimes`.
145        // The latexml-oxide worker now emits `Info:runtime_ms:<N>` (category=`runtime_ms`, the
146        // value in `what`) so the runtime surfaces as its own report category + per-value
147        // subreport; older workers emitted `Info:cortex:runtime_ms <N>` (what=`runtime_ms`,
148        // value in `details`). We accept BOTH so a rolling/mixed fleet keeps populating the
149        // per-service runtime report. A malformed value just skips the denormalized row —
150        // the log row is still written below, and the report's other rows are unaffected.
151        if let NewTaskMessage::Info(record) = message {
152          let runtime_value = if record.category == "runtime_ms" {
153            Some(record.what.as_str())
154          } else if record.category == "cortex" && record.what == "runtime_ms" {
155            Some(record.details.as_str())
156          } else {
157            None
158          };
159          if let Some(value) = runtime_value
160            && let Ok(runtime_ms) = value.parse::<i32>()
161          {
162            new_runtimes.push(NewTaskRuntime {
163              task_id: report.task.id,
164              service_id: report.task.service_id,
165              runtime_ms,
166            });
167          }
168        }
169        match message {
170          NewTaskMessage::Info(record) => new_infos.push(record.clone()),
171          NewTaskMessage::Warning(record) => new_warnings.push(record.clone()),
172          NewTaskMessage::Error(record) => new_errors.push(record.clone()),
173          NewTaskMessage::Fatal(record) => new_fatals.push(record.clone()),
174          NewTaskMessage::Invalid(record) => new_invalids.push(record.clone()),
175        }
176      }
177      // TODO: Update dependenct services, when integrated in DB
178    }
179    // Apply the status updates: one batched UPDATE per *distinct* terminal status (a small fixed
180    // set — NoProblem/Warning/Error/Fatal/Invalid), each over the disjoint id set that resolved
181    // to it.
182    for (status_value, status_ids) in &ids_by_status {
183      for ids in status_ids.chunks(ID_CHUNK) {
184        update(tasks::table)
185          .filter(id.eq_any(ids))
186          .set(status.eq(*status_value))
187          .execute(t_connection)?;
188      }
189    }
190    // Batched INSERT per severity table, chunked to respect the bind-parameter cap.
191    // (`chunks` over an empty Vec yields nothing, so no `is_empty` guard is needed.)
192    for chunk in new_infos.chunks(LOG_INSERT_CHUNK) {
193      insert_into(log_infos::table)
194        .values(chunk)
195        .execute(t_connection)?;
196    }
197    for chunk in new_warnings.chunks(LOG_INSERT_CHUNK) {
198      insert_into(log_warnings::table)
199        .values(chunk)
200        .execute(t_connection)?;
201    }
202    for chunk in new_errors.chunks(LOG_INSERT_CHUNK) {
203      insert_into(log_errors::table)
204        .values(chunk)
205        .execute(t_connection)?;
206    }
207    for chunk in new_fatals.chunks(LOG_INSERT_CHUNK) {
208      insert_into(log_fatals::table)
209        .values(chunk)
210        .execute(t_connection)?;
211    }
212    for chunk in new_invalids.chunks(LOG_INSERT_CHUNK) {
213      insert_into(log_invalids::table)
214        .values(chunk)
215        .execute(t_connection)?;
216    }
217    // Denormalized runtimes: the prior runtime rows for these tasks were cleared in the delete loop
218    // above, so a plain insert is correct (3 columns/row → 16k rows ≈ 48k binds, under the cap).
219    for chunk in new_runtimes.chunks(LOG_INSERT_CHUNK) {
220      insert_into(task_runtimes::table)
221        .values(chunk)
222        .execute(t_connection)?;
223    }
224    Ok(())
225  })?;
226  Ok(())
227}
228
229pub(crate) fn mark_rerun<'a>(
230  connection: &'a mut PgConnection,
231  options: RerunOptions<'a>,
232) -> Result<(), Error> {
233  let RerunOptions {
234    corpus,
235    service,
236    severity_opt,
237    category_opt,
238    what_opt,
239    owner_opt,
240    description_opt,
241  } = options;
242  use crate::schema::tasks::{corpus_id, service_id, status};
243  // We are starting a new run, first catalog the current metadata in our historical records.
244  let mut description = description_opt.unwrap_or_else(|| String::from("mark for rerun "));
245  // auto-generate a report message from the selected filters
246  description.push_str("(filters:");
247  if severity_opt.is_none() && category_opt.is_none() && what_opt.is_none() {
248    description.push_str(" entire corpus");
249  } else {
250    if let Some(ref severity) = severity_opt {
251      description.push_str(" severity=");
252      description.push_str(severity);
253    }
254    if let Some(ref category) = category_opt {
255      description.push_str(" category=");
256      description.push_str(category);
257    }
258    if let Some(ref what) = what_opt {
259      description.push_str(" what=");
260      description.push_str(what);
261    }
262  }
263  description.push(')');
264
265  // Atomic rerun (R-11): the run record + the two-phase task reset (set the scope to a temporary
266  // `mark`, delete its logs, flip `mark → TODO`) must all-or-nothing together. Otherwise a frontend
267  // crash/restart mid-rerun could strand the whole scope in the `mark` value — a positive status
268  // the dispatcher won't lease (it leases `TODO=0`) — so the tasks never re-convert until a
269  // dispatcher restart's limbo recovery. One transaction makes the temporary `mark` never
270  // observable and a crash a clean rollback. `mark_new_run`'s own transaction nests here as a
271  // savepoint. (The closure param shadows `connection` so the body below uses the transaction
272  // connection.)
273  connection.transaction::<(), Error, _>(|connection| {
274    mark_new_run(
275      connection,
276      corpus,
277      service,
278      owner_opt.unwrap_or_else(|| "admin".to_string()),
279      description,
280    )?;
281    // Rerun = set status to TODO for all tasks, deleting old logs. The temporary `mark` is a
282    // positive sentinel drawn *above* the max lease (R-13): we re-select the scope below by
283    // `status = mark`, so it must not collide with a live in-flight lease (`[1, 65536]`) or `TODO`
284    // (0) — else an unrelated task would be swept into the rerun and double-dispatched.
285    let mark: i32 = rerun_mark();
286
287    // First, mark as blocked all of the tasks in the chosen scope, using a special mark
288    match severity_opt {
289      Some(severity) => match category_opt {
290        Some(category) => match what_opt {
291          // All tasks in a "what" class
292          Some(what) => match severity.to_lowercase().as_str() {
293            "warning" => LogWarning::mark_rerun_by_what(
294              mark, corpus.id, service.id, &category, &what, connection,
295            ),
296            "error" => LogError::mark_rerun_by_what(
297              mark, corpus.id, service.id, &category, &what, connection,
298            ),
299            "fatal" => LogFatal::mark_rerun_by_what(
300              mark, corpus.id, service.id, &category, &what, connection,
301            ),
302            "invalid" => LogInvalid::mark_rerun_by_what(
303              mark, corpus.id, service.id, &category, &what, connection,
304            ),
305            _ => {
306              LogInfo::mark_rerun_by_what(mark, corpus.id, service.id, &category, &what, connection)
307            },
308          }?,
309          // None: All tasks in a category
310          None => match severity.to_lowercase().as_str() {
311            "warning" => {
312              LogWarning::mark_rerun_by_category(mark, corpus.id, service.id, &category, connection)
313            },
314            "error" => {
315              LogError::mark_rerun_by_category(mark, corpus.id, service.id, &category, connection)
316            },
317            "fatal" => {
318              LogFatal::mark_rerun_by_category(mark, corpus.id, service.id, &category, connection)
319            },
320            "invalid" => {
321              LogInvalid::mark_rerun_by_category(mark, corpus.id, service.id, &category, connection)
322            },
323            _ => {
324              LogInfo::mark_rerun_by_category(mark, corpus.id, service.id, &category, connection)
325            },
326          }?,
327        },
328        None => {
329          // All tasks in a certain status/severity
330          let status_to_rerun: i32 = TaskStatus::from_key(&severity)
331            .unwrap_or(TaskStatus::NoProblem)
332            .raw();
333          update(tasks::table)
334            .filter(corpus_id.eq(corpus.id))
335            .filter(service_id.eq(service.id))
336            .filter(status.eq(status_to_rerun))
337            .set(status.eq(mark))
338            .execute(connection)?
339        },
340      },
341      None => {
342        // Entire corpus
343        update(tasks::table)
344          .filter(corpus_id.eq(corpus.id))
345          .filter(service_id.eq(service.id))
346          .filter(status.lt(0))
347          .set(status.eq(mark))
348          .execute(connection)?
349      },
350    };
351
352    // Next, delete all logs for the blocked tasks.
353    // Note that if we are using a negative blocking status, this query should get sped up via an
354    // "Index Scan using log_taskid on logs"
355    let affected_tasks = tasks::table
356      .filter(corpus_id.eq(corpus.id))
357      .filter(service_id.eq(service.id))
358      .filter(status.eq(mark));
359    let affected_tasks_ids = affected_tasks.select(tasks::id);
360
361    let affected_log_infos = log_infos::table.filter(log_infos::task_id.eq_any(affected_tasks_ids));
362    delete(affected_log_infos).execute(connection)?;
363    let affected_log_warnings =
364      log_warnings::table.filter(log_warnings::task_id.eq_any(affected_tasks_ids));
365    delete(affected_log_warnings).execute(connection)?;
366    let affected_log_errors =
367      log_errors::table.filter(log_errors::task_id.eq_any(affected_tasks_ids));
368    delete(affected_log_errors).execute(connection)?;
369    let affected_log_fatals =
370      log_fatals::table.filter(log_fatals::task_id.eq_any(affected_tasks_ids));
371    delete(affected_log_fatals).execute(connection)?;
372    let affected_log_invalids =
373      log_invalids::table.filter(log_invalids::task_id.eq_any(affected_tasks_ids));
374    delete(affected_log_invalids).execute(connection)?;
375
376    // Lastly, switch all blocked tasks to TODO, and complete the rerun mark pass.
377    update(affected_tasks)
378      .set(status.eq(TaskStatus::TODO.raw()))
379      .execute(connection)?;
380
381    // The reran scope's reports are now stale (logs deleted, statuses reset). Drop its cached
382    // report grains inside the same transaction so the next report view repopulates from the
383    // fresh data — scoped to exactly this (corpus, service), never the global cube.
384    super::rollup::invalidate_scope(connection, corpus.id, service.id)?;
385
386    Ok(())
387  })
388}
389
390pub(crate) fn mark_new_run(
391  connection: &mut PgConnection,
392  corpus: &Corpus,
393  service: &Service,
394  owner: String,
395  description: String,
396) -> Result<(), Error> {
397  // Step 1. Mark any open runs as completed.
398  mark_run_completed(connection, corpus, service)?;
399  // Step 2. Create this historical run
400  let hrun = NewHistoricalRun {
401    corpus_id: corpus.id,
402    service_id: service.id,
403    description,
404    owner,
405  };
406  hrun.create(connection)?;
407  // NB: this used to synchronously `REFRESH MATERIALIZED VIEW report_summary` here so the run
408  // boundary showed up in reports immediately — but that is a ~2 min rebuild at production scale,
409  // and `mark_new_run` runs on the rerun *request* thread, so it blocked the HTTP response for
410  // minutes (KNOWN_ISSUES R-5). The refresh is now spawned **off the request path** by the rerun
411  // entry points (`reports::rerun_report`, `concerns::serve_rerun`) via
412  // `jobs::spawn_report_refresh`, and the dispatcher refreshes on drain + the regular interval.
413  // Bookkeeping no longer triggers a refresh.
414  Ok(())
415}
416
417fn mark_run_completed(
418  connection: &mut PgConnection,
419  corpus: &Corpus,
420  service: &Service,
421) -> Result<(), Error> {
422  let to_finish: Vec<HistoricalRun> = HistoricalRun::find_by(corpus, service, connection)?
423    .into_iter()
424    .filter(|run| run.end_time.is_none())
425    .collect();
426  if !to_finish.is_empty() {
427    connection.transaction::<(), Error, _>(move |t_connection| {
428      for run in to_finish.into_iter() {
429        run.mark_completed(t_connection)?;
430      }
431      Ok(())
432    })?;
433  }
434  Ok(())
435}
436
437pub fn save_historical_tasks(
438  connection: &mut PgConnection,
439  corpus: &Corpus,
440  service: &Service,
441) -> Result<usize, Error> {
442  snapshot_tasks(connection, corpus.id, service.id)
443}
444
445/// Freeze the current per-task statuses of a `(corpus, service)` into `historical_tasks` — the
446/// id-keyed core of [`save_historical_tasks`]. Called both by the human "save snapshot" and, on
447/// **run-completion-on-drain**, to capture the just-finished run's outcomes as the **baseline** for
448/// the next run's live run-diff (see `backend::Backend::complete_run_if_drained`). One
449/// `INSERT … SELECT` of scope-size rows; retention/pruning of stale snapshots is a follow-up.
450pub fn snapshot_tasks(
451  connection: &mut PgConnection,
452  corpus_id: i32,
453  service_id: i32,
454) -> Result<usize, Error> {
455  insert_into(historical_tasks::table)
456    .values(
457      tasks::table
458        .select((tasks::id, tasks::status))
459        .filter(tasks::corpus_id.eq(corpus_id))
460        .filter(tasks::service_id.eq(service_id)),
461    )
462    .into_columns((historical_tasks::task_id, historical_tasks::status))
463    .execute(connection)
464}