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}