Skip to main content

cortex/
backend.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//! All aggregate operations over the CorTeX PostgresQL store are accessed through the connection of
9//! a `Backend` object.
10
11mod corpora_aggregate;
12mod export;
13mod mark;
14mod reports;
15mod rollup;
16mod sandbox;
17mod services_aggregate;
18mod tasks_aggregate;
19pub use export::{DatasetExportOutcome, GroupBy, export_html_dataset};
20pub(crate) use mark::{
21  mark_all_blocked, mark_blocked, mark_rerun, resume_all_blocked, resume_blocked,
22  save_historical_tasks,
23};
24// `pub` (not `pub(crate)`): the `cortex diff --tasks` subcommand calls it directly, giving the
25// per-task changed-tasks drill a third (CLI) surface alongside the agent `/api/runs/<c>/<s>/tasks`
26// and the web screen.
27pub use reports::list_task_diffs;
28pub(crate) use reports::live_run_diff;
29pub(crate) use reports::progress_report;
30pub(crate) use reports::report_uses_rollup;
31// `pub` (not `pub(crate)`): the `cortex` CLI's `diff` subcommand calls it directly, so the run-diff
32// summary has a third (CLI) surface alongside the agent `/api/runs/<c>/<s>/diff` and the web
33// screen.
34pub use reports::TaskReportOptions;
35pub use reports::summary_task_diffs;
36pub(crate) use reports::task_report;
37pub use reports::{DOCUMENT_MESSAGE_CAP, MessageCounts, task_messages};
38pub use rollup::ReportSummaryRow;
39pub(crate) use rollup::{
40  category_rollup, category_total, invalidate_all, invalidate_scope, populate_scope,
41  populate_scope_bounded, report_cache_computed_at, scope_cached, severity_total, what_rollup,
42};
43pub use sandbox::{SandboxOutcome, SandboxSelection, create_sandbox};
44
45use diesel::r2d2::{ConnectionManager, Pool, PooledConnection};
46use diesel::result::Error;
47use diesel::*;
48use std::collections::HashMap;
49use std::fmt;
50
51use crate::concerns::{CortexDeletable, CortexInsertable};
52use crate::config::config;
53use crate::helpers::{TaskReport, TaskStatus};
54use crate::models::{Corpus, NewTask, Service, Task};
55
56/// The production database postgresql address, from the runtime [`crate::config`] configuration
57pub fn default_db_address() -> &'static str { &config().database.url }
58/// The test database postgresql address, from the runtime [`crate::config`] configuration
59pub fn test_db_address() -> &'static str { &config().database.test_url }
60
61/// Provides an interface to the Postgres task store
62pub struct Backend {
63  /// The Diesel PgConnection object
64  pub connection: PgConnection,
65}
66impl fmt::Debug for Backend {
67  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str("<Backend omitted>") }
68}
69impl Default for Backend {
70  fn default() -> Self {
71    let connection = connection_at(default_db_address());
72    Backend { connection }
73  }
74}
75
76/// Constructs a new Task store representation from a Postgres DB address.
77///
78/// A transient connect failure — a momentary `too many clients` / a DB restarting — is retried a
79/// few times with linear backoff before giving up, so a blip doesn't become an instant fatal panic
80/// (the robustness mandate: degrade through transients, fail fast only when the DB is truly down).
81pub fn connection_at(address: &str) -> PgConnection {
82  const MAX_ATTEMPTS: u32 = 5;
83  let mut attempt = 1;
84  loop {
85    match PgConnection::establish(address) {
86      Ok(connection) => return connection,
87      Err(e) if attempt < MAX_ATTEMPTS => {
88        eprintln!(
89          "-- connect to {address} failed (attempt {attempt}/{MAX_ATTEMPTS}): {e}; retrying"
90        );
91        std::thread::sleep(std::time::Duration::from_millis(100 * u64::from(attempt)));
92        attempt += 1;
93      },
94      Err(e) => panic!("Error connecting to {address} after {MAX_ATTEMPTS} attempts: {e}"),
95    }
96  }
97}
98
99/// A pool of PostgreSQL connections (Diesel + r2d2).
100pub type DbPool = Pool<ConnectionManager<PgConnection>>;
101/// A connection checked out from a [`DbPool`]; dereferences to a `PgConnection`.
102pub type PooledConn = PooledConnection<ConnectionManager<PgConnection>>;
103
104/// Builds a lazily-initialized connection pool: connections are established on first checkout, so
105/// this never blocks or fails at startup even if the database is momentarily unavailable.
106pub fn build_pool(database_url: &str, max_size: u32) -> DbPool {
107  let manager = ConnectionManager::<PgConnection>::new(database_url);
108  Pool::builder().max_size(max_size).build_unchecked(manager)
109}
110
111/// The configured database URL, managed as Rocket state so background jobs open their own
112/// connection against the same database as the request pool (notably the test database in
113/// integration tests).
114pub struct DatabaseUrl(pub String);
115/// Constructs the default Backend struct for testing, with the test database's schema brought
116/// current.
117///
118/// The test DB self-migrates from the **embedded** migrations (once per test process) — the same
119/// source `cortex init` applies to production — so keeping it current needs no `diesel_cli`. A
120/// stale test DB otherwise surfaces as `relation "..." does not exist` deep inside an unrelated
121/// assertion, which reads like a code bug rather than an un-migrated database. Only
122/// `database.test_url` is ever reached, never `database.url`.
123pub fn testdb() -> Backend {
124  static MIGRATED: std::sync::Once = std::sync::Once::new();
125  let mut backend = Backend {
126    connection: connection_at(test_db_address()),
127  };
128  // `Once` parks sibling test threads until the schema is current, so none observes a half-applied
129  // migration. Panicking (rather than returning a Result) is deliberate: this is test setup, not a
130  // request/dispatch path — a half-migrated schema would make every downstream failure a lie.
131  MIGRATED.call_once(|| {
132    if let Err(e) = crate::migrations::run_pending_migrations(&mut backend.connection) {
133      panic!(
134        "test DB at {} could not be migrated: {e}",
135        test_db_address()
136      );
137    }
138  });
139  backend
140}
141/// Constructs a Backend at a given address
142pub fn from_address(address: &str) -> Backend {
143  Backend {
144    connection: connection_at(address),
145  }
146}
147
148/// Options container for relevant fields in requesting a `(corpus, service)` rerun
149pub struct RerunOptions<'a> {
150  /// corpus to rerun
151  pub corpus: &'a Corpus,
152  /// service to rerun
153  pub service: &'a Service,
154  /// optionally, severity level filter
155  pub severity_opt: Option<String>,
156  /// optionally, category level filter
157  pub category_opt: Option<String>,
158  /// optionally, what level filter
159  pub what_opt: Option<String>,
160  /// optionally, owner of the rerun (default is "admin")
161  pub owner_opt: Option<String>,
162  /// optionally, description of the rerun (default is "rerun")
163  pub description_opt: Option<String>,
164}
165
166/// Instance methods
167impl Backend {
168  /// Insert a vector of new `NewTask` tasks into the Task store
169  /// For example, on import, or when a new service is activated on a corpus
170  pub fn mark_imported(&mut self, imported_tasks: &[NewTask]) -> Result<usize, Error> {
171    mark::mark_imported(&mut self.connection, imported_tasks)
172  }
173  /// Insert a vector of `TaskReport` reports into the Task store, also marking their tasks as
174  /// completed with the correct status code.
175  pub fn mark_done(&mut self, reports: &[TaskReport]) -> Result<(), Error> {
176    mark::mark_done(&mut self.connection, reports)
177  }
178  /// Given a complex selector, of a `Corpus`, `Service`, and the optional `severity`, `category`
179  /// and `what` mark all matching tasks to be rerun
180  pub fn mark_rerun(&mut self, options: RerunOptions) -> Result<(), Error> {
181    mark::mark_rerun(&mut self.connection, options)
182  }
183
184  /// **Pause** a `(corpus, service)` run: block every in-progress task (`status >= 0`) so the
185  /// dispatcher stops leasing them. Returns the number paused. The CLI/agent twin of the report
186  /// screen's "Pause run". Reversible with [`Backend::resume_run`].
187  pub fn pause_run(&mut self, corpus_id: i32, service_id: i32) -> Result<usize, Error> {
188    mark::mark_blocked(&mut self.connection, corpus_id, service_id)
189  }
190
191  /// **Resume** a paused `(corpus, service)` run: return every Blocked task (`status < -5`) to TODO
192  /// so the dispatcher picks them up. Returns the number resumed. The inverse of
193  /// [`Backend::pause_run`].
194  pub fn resume_run(&mut self, corpus_id: i32, service_id: i32) -> Result<usize, Error> {
195    mark::resume_blocked(&mut self.connection, corpus_id, service_id)
196  }
197
198  /// While not changing any status information for Tasks, add a new historical run bookmark
199  pub fn mark_new_run(
200    &mut self,
201    corpus: &Corpus,
202    service: &Service,
203    owner: String,
204    description: String,
205  ) -> Result<(), Error> {
206    mark::mark_new_run(&mut self.connection, corpus, service, owner, description)
207  }
208
209  /// Save the current historical tasks for reference
210  pub fn save_historical_tasks(
211    &mut self,
212    corpus: &Corpus,
213    service: &Service,
214  ) -> Result<usize, Error> {
215    mark::save_historical_tasks(&mut self.connection, corpus, service)
216  }
217
218  /// Generic delete method, uses primary "id" field
219  pub fn delete<Model: CortexDeletable>(&mut self, object: &Model) -> Result<usize, Error> {
220    object.delete_by(&mut self.connection, "id")
221  }
222  /// Delete all entries matching the "field" value of a given object
223  pub fn delete_by<Model: CortexDeletable>(
224    &mut self,
225    object: &Model,
226    field: &str,
227  ) -> Result<usize, Error> {
228    object.delete_by(&mut self.connection, field)
229  }
230  /// Generic addition method, attempting to insert in the DB a Task store datum
231  /// applicable for any struct implementing the `CortexORM` trait
232  /// (for example `Corpus`, `Service`, `Task`)
233  pub fn add<Model: CortexInsertable>(&mut self, object: &Model) -> Result<usize, Error> {
234    object.create(&mut self.connection)
235  }
236
237  /// Fetches no more than `limit` queued tasks for a given `Service`
238  pub fn fetch_tasks(&mut self, service: &Service, limit: usize) -> Result<Vec<Task>, Error> {
239    tasks_aggregate::fetch_tasks(&mut self.connection, service, limit)
240  }
241  /// Globally resets any "in progress" tasks back to "queued".
242  /// Particularly useful for dispatcher restarts, when all "in progress" tasks need to be
243  /// invalidated
244  pub fn clear_limbo_tasks(&mut self) -> Result<usize, Error> {
245    tasks_aggregate::clear_limbo_tasks(&mut self.connection)
246  }
247
248  /// Like [`Self::clear_limbo_tasks`], but preserves the given in-flight task ids (the live
249  /// `progress_queue` on a ventilator restart) so tasks a worker is actively processing are not
250  /// reset to `TODO` and double-dispatched. See `tasks_aggregate::clear_limbo_tasks_except`.
251  pub fn clear_limbo_tasks_except(&mut self, in_flight: &[i64]) -> Result<usize, Error> {
252    tasks_aggregate::clear_limbo_tasks_except(&mut self.connection, in_flight)
253  }
254
255  /// Activates an existing service on the given corpus.
256  /// if the service has previously been registered, this call will `RESET` the service into a mint
257  /// state also removing any related log messages. The new run is attributed to `owner` with
258  /// `description` (the UI/API thread the actor; the CLI passes a default). Takes the resolved
259  /// `Corpus` (not a path): a sandbox shares its parent's path, so a path lookup would hit the
260  /// parent.
261  pub fn register_service(
262    &mut self,
263    service: &Service,
264    corpus: &Corpus,
265    owner: String,
266    description: String,
267  ) -> Result<(), Error> {
268    services_aggregate::register_service(&mut self.connection, service, corpus, owner, description)
269  }
270
271  /// Extends an existing service on the given corpus.
272  /// if the service has previously been registered, this call will ignore existing entries and
273  /// simply add newly encountered ones. Takes the resolved `Corpus` (not a path) for the same
274  /// sandbox-shares-parent-path reason as `register_service`.
275  pub fn extend_service(&mut self, service: &Service, corpus: &Corpus) -> Result<(), Error> {
276    services_aggregate::extend_service(&mut self.connection, service, corpus)
277  }
278
279  /// Permanently destroys a service by name — its definition plus all of its tasks + `log_*` rows
280  /// across every corpus, in one transaction (orphan-free + crash-consistent; closes R-6). Refuses
281  /// the magic `init`/`import` services. See [`services_aggregate::destroy_service_by_name`].
282  pub fn destroy_service_by_name(&mut self, name: &str) -> Result<usize, Error> {
283    services_aggregate::destroy_service_by_name(&mut self.connection, name)
284  }
285
286  /// Returns a vector of currently available corpora in the Task store
287  pub fn corpora(&mut self) -> Vec<Corpus> { corpora_aggregate::list_corpora(&mut self.connection) }
288
289  /// Returns a vector of tasks for a given Corpus, Service and status
290  pub fn tasks(
291    &mut self,
292    corpus: &Corpus,
293    service: &Service,
294    task_status: &TaskStatus,
295  ) -> Vec<Task> {
296    reports::list_tasks(&mut self.connection, corpus, service, task_status)
297  }
298  /// Returns a vector of task entry paths for a given Corpus, Service and status
299  pub fn entries(
300    &mut self,
301    corpus: &Corpus,
302    service: &Service,
303    task_status: &TaskStatus,
304  ) -> Vec<String> {
305    reports::list_entries(&mut self.connection, corpus, service, task_status)
306  }
307
308  /// Given a complex selector, of a `Corpus`, `Service`, and the optional `severity`, `category`
309  /// and `what`, Provide a progress report at the chosen granularity
310  pub fn task_report(&mut self, options: TaskReportOptions) -> Vec<HashMap<String, String>> {
311    reports::task_report(&mut self.connection, options)
312  }
313  /// Provides a progress report, grouped by severity, for a given `Corpus` and `Service` pair
314  pub fn progress_report(&mut self, corpus: &Corpus, service: &Service) -> HashMap<String, f64> {
315    reports::progress_report(&mut self.connection, corpus.id, service.id)
316  }
317
318  /// Recomputes the `report_summary` rollup (Arm 14 #6); call on the run-completion path so the
319  /// cheap category/what report reads stay fresh.
320  pub fn refresh_report_summary(&mut self) -> Result<(), Error> {
321    rollup::refresh_report_summary(&mut self.connection)
322  }
323  /// Drop the cached report grains for one `(corpus, service)` scope so the next report view
324  /// repopulates from current data. Called per touched scope on the run-completion path — scoped, a
325  /// cheap keyed `DELETE`, never the global all-corpora scan that used to stall conversions.
326  pub fn invalidate_report_cache(&mut self, corpus_id: i32, service_id: i32) -> Result<(), Error> {
327    rollup::invalidate_scope(&mut self.connection, corpus_id, service_id)
328  }
329  /// Run-completion-on-drain: close the `(corpus, service)` pair's open historical run iff its work
330  /// is exhausted (every task terminal). Delegates to
331  /// [`HistoricalRun::complete_if_drained`](crate::models::HistoricalRun::complete_if_drained);
332  /// returns `true` iff a run was closed. Called per touched scope from the dispatcher's finalize
333  /// loop when the queue idles, so a finished run is closed at once rather than at the next rerun.
334  pub fn complete_run_if_drained(
335    &mut self,
336    corpus_id: i32,
337    service_id: i32,
338  ) -> Result<bool, Error> {
339    let closed = crate::models::HistoricalRun::complete_if_drained(
340      corpus_id,
341      service_id,
342      &mut self.connection,
343    )?;
344    if closed {
345      // The run just closed on drain — its per-task outcomes are settled. Snapshot them into
346      // `historical_tasks` as the BASELINE for the NEXT run's live run-diff: captured once, here,
347      // at the natural completion point (in the finalize thread, off the request path). Best-effort
348      // — a snapshot failure must never undo the (critical) run close, so log and carry on.
349      if let Err(e) = mark::snapshot_tasks(&mut self.connection, corpus_id, service_id) {
350        tracing::warn!(
351          corpus_id,
352          service_id,
353          error = ?e,
354          "run-completion-on-drain: baseline snapshot failed (non-fatal)"
355        );
356      }
357    }
358    Ok(closed)
359  }
360  /// Category-grain report for `(corpus, service, severity)`, read from the `report_summary`
361  /// rollup, windowed to `[offset, offset + limit)` (ordered by descending task count).
362  pub fn category_rollup(
363    &mut self,
364    corpus: &Corpus,
365    service: &Service,
366    severity: &str,
367    limit: i64,
368    offset: i64,
369  ) -> Vec<ReportSummaryRow> {
370    rollup::category_rollup(
371      &mut self.connection,
372      corpus.id,
373      service.id,
374      severity,
375      limit,
376      offset,
377    )
378    .unwrap_or_default()
379  }
380  /// `what`-grain drill-down for `(corpus, service, severity, category)`, read from the rollup,
381  /// windowed to `[offset, offset + limit)` (ordered by descending task count).
382  pub fn what_rollup(
383    &mut self,
384    corpus: &Corpus,
385    service: &Service,
386    severity: &str,
387    category: &str,
388    limit: i64,
389    offset: i64,
390  ) -> Vec<ReportSummaryRow> {
391    rollup::what_rollup(
392      &mut self.connection,
393      corpus.id,
394      service.id,
395      severity,
396      category,
397      limit,
398      offset,
399    )
400    .unwrap_or_default()
401  }
402  /// The per-severity grand totals `(distinct tasks, total messages)` for `(corpus, service,
403  /// severity)`, read from the rollup — the denominators the category-grain report shows. `(0, 0)`
404  /// when the severity has no logged messages.
405  pub fn severity_totals(
406    &mut self,
407    corpus: &Corpus,
408    service: &Service,
409    severity: &str,
410  ) -> (i64, i64) {
411    rollup::severity_total(&mut self.connection, corpus.id, service.id, severity)
412      .unwrap_or_default()
413      .map_or((0, 0), |row| (row.task_count, row.message_count))
414  }
415  /// The category-grain totals `(distinct tasks, total messages)` for `(corpus, service, severity,
416  /// category)` — the denominators the `what` drill-down shows. `(0, 0)` when the category is
417  /// empty.
418  pub fn category_totals(
419    &mut self,
420    corpus: &Corpus,
421    service: &Service,
422    severity: &str,
423    category: &str,
424  ) -> (i64, i64) {
425    rollup::category_total(
426      &mut self.connection,
427      corpus.id,
428      service.id,
429      severity,
430      category,
431    )
432    .unwrap_or_default()
433    .map_or((0, 0), |row| (row.task_count, row.message_count))
434  }
435}