Skip to main content

cortex/models/
services.rs

1#![allow(clippy::extra_unused_lifetimes)]
2use std::collections::HashMap;
3
4use diesel::result::Error;
5use diesel::*;
6
7use crate::schema::services;
8use crate::schema::worker_metadata;
9
10use super::worker_metadata::WorkerMetadata;
11use crate::concerns::CortexInsertable;
12
13// Services
14#[derive(Identifiable, Queryable, AsChangeset, Clone, Debug)]
15/// A `CorTeX` processing service
16pub struct Service {
17  /// auto-incremented postgres id
18  pub id: i32,
19  /// a human-readable name
20  pub name: String,
21  /// a floating-point number to mark the current version (e.g. 0.01)
22  pub version: f32,
23  /// the expected input format (e.g. tex)
24  pub inputformat: String,
25  /// the produced output format (e.g. html)
26  pub outputformat: String,
27  // pub xpath : String,
28  // pub resource : String,
29  /// prerequisite input conversion service, if any
30  pub inputconverter: Option<String>,
31  /// is this service requiring more than the main textual content of a document?
32  /// mark "true" if unsure
33  pub complex: bool,
34  /// a human-readable description
35  pub description: String,
36  /// Stable external handle (UUIDv7, DB-generated), independent of the mutable `name` — for
37  /// public/API references that must survive a rename. Immutable once assigned (Arm 3 / D8).
38  pub public_id: uuid::Uuid,
39  /// Per-service lease / visibility-timeout override in seconds (D-17). `None` ⇒ use the global
40  /// `dispatcher.lease_timeout_seconds`. Lets one dispatcher serve fast (latexml-oxide, short
41  /// lease) and slow (Perl LaTeXML, long lease) worker classes without false reaps or delayed
42  /// dead-worker recovery. Captured into `TaskProgress` at dispatch time, so a config change
43  /// never re-times an already-leased task.
44  pub lease_timeout_seconds: Option<i32>,
45}
46/// Insertable struct for `Service`
47#[derive(Insertable, Clone, Debug)]
48#[diesel(table_name = services)]
49pub struct NewService {
50  /// a human-readable name
51  pub name: String,
52  /// a floating-point number to mark the current version (e.g. 0.01)
53  pub version: f32,
54  /// the expected input format (e.g. tex)
55  pub inputformat: String,
56  /// the produced output format (e.g. html)
57  pub outputformat: String,
58  // pub xpath : String,
59  // pub resource : String,
60  /// prerequisite input conversion service, if any
61  pub inputconverter: Option<String>,
62  /// is this service requiring more than the main textual content of a document?
63  /// mark "true" if unsure
64  pub complex: bool,
65  /// a human-readable description
66  pub description: String,
67}
68impl CortexInsertable for NewService {
69  fn create(&self, connection: &mut PgConnection) -> Result<usize, Error> {
70    insert_into(services::table)
71      .values(self)
72      .execute(connection)
73  }
74}
75
76impl Service {
77  /// ORM-like until diesel.rs introduces finders for more fields
78  pub fn find_by_name(name_query: &str, connection: &mut PgConnection) -> Result<Service, Error> {
79    use crate::schema::services::name;
80    services::table
81      .filter(name.eq(name_query))
82      .get_result(connection)
83  }
84
85  /// Returns all registered services, ordered by name (the service registry). Includes the magic
86  /// `init` (id 1) and `import` (id 2) services alongside the real conversion services (id > 2).
87  pub fn all(connection: &mut PgConnection) -> Result<Vec<Self>, Error> {
88    services::table
89      .order(services::name.asc())
90      .get_results(connection)
91  }
92
93  /// Sets (or clears, with `None`) this service's per-service lease / visibility-timeout override
94  /// (D-17). `None` ⇒ fall back to the global `dispatcher.lease_timeout_seconds`. Captured into
95  /// `TaskProgress` only at lease time, so a change takes effect on the **next** dispatch and never
96  /// re-times an already-leased task. Returns the rows updated (1, or 0 if the service vanished).
97  pub fn set_lease_timeout(
98    &self,
99    seconds: Option<i32>,
100    connection: &mut PgConnection,
101  ) -> Result<usize, Error> {
102    update(services::table.find(self.id))
103      .set(services::lease_timeout_seconds.eq(seconds))
104      .execute(connection)
105  }
106
107  /// Returns a hash representation of the `Service`, usually for frontend reports
108  pub fn to_hash(&self) -> HashMap<String, String> {
109    let mut hm = HashMap::new();
110    hm.insert("id".to_string(), self.id.to_string());
111    hm.insert("name".to_string(), self.name.clone());
112    hm.insert("description".to_string(), self.description.clone());
113    hm.insert("version".to_string(), self.version.to_string());
114    hm.insert("inputformat".to_string(), self.inputformat.clone());
115    hm.insert("outputformat".to_string(), self.outputformat.clone());
116    hm.insert(
117      "inputconverter".to_string(),
118      match self.inputconverter.clone() {
119        Some(ic) => ic,
120        None => "None".to_string(),
121      },
122    );
123    hm.insert("complex".to_string(), self.complex.to_string());
124    // Empty string = no per-service override (the global dispatcher lease applies); the template
125    // renders that as a "default" placeholder.
126    hm.insert(
127      "lease_timeout_seconds".to_string(),
128      self
129        .lease_timeout_seconds
130        .map(|seconds| seconds.to_string())
131        .unwrap_or_default(),
132    );
133    hm
134  }
135
136  /// Return the dispatcher's registered workers for this service
137  pub fn select_workers(
138    &self,
139    connection: &mut PgConnection,
140  ) -> Result<Vec<WorkerMetadata>, Error> {
141    let workers_query = worker_metadata::table
142      .filter(worker_metadata::service_id.eq(self.id))
143      .order(worker_metadata::name.asc());
144    let workers: Vec<WorkerMetadata> = workers_query.get_results(connection)?;
145    Ok(workers)
146  }
147
148  /// Counts this service's tasks on a given corpus — the size of what
149  /// [`Self::deactivate_from_corpus`] would delete. Used to preview the blast radius of a
150  /// destructive deactivation before executing.
151  pub fn task_count_for_corpus(
152    &self,
153    corpus: &super::Corpus,
154    connection: &mut PgConnection,
155  ) -> Result<i64, Error> {
156    use crate::schema::tasks;
157    tasks::table
158      .filter(tasks::service_id.eq(self.id))
159      .filter(tasks::corpus_id.eq(corpus.id))
160      .count()
161      .get_result(connection)
162  }
163
164  /// Counts this service's tasks **across every corpus** — the size of what [`Self::destroy`] would
165  /// delete. Used to preview the blast radius of a registry-wide service deletion before executing.
166  pub fn total_task_count(&self, connection: &mut PgConnection) -> Result<i64, Error> {
167    use crate::schema::tasks;
168    tasks::table
169      .filter(tasks::service_id.eq(self.id))
170      .count()
171      .get_result(connection)
172  }
173
174  /// Deactivates (retires) this service from a single corpus: deletes the `(corpus, service)`
175  /// pair's tasks **and their `log_*` rows** in one transaction, returning the number of tasks
176  /// removed. The service *definition* and its work on other corpora are untouched. The `log_*`
177  /// tables have no FK to `tasks`, so their rows are deleted explicitly **before** the tasks or
178  /// they orphan (the same hazard closed in [`super::Corpus::destroy`]); transactional so a crash
179  /// can't half-delete. `historical_runs` tallies survive (no FK to tasks), while per-task
180  /// `historical_tasks` snapshots cascade away with the tasks — the same semantics as deleting a
181  /// corpus.
182  pub fn deactivate_from_corpus(
183    &self,
184    corpus: &super::Corpus,
185    connection: &mut PgConnection,
186  ) -> Result<usize, Error> {
187    use crate::schema::tasks;
188    use crate::schema::{log_errors, log_fatals, log_infos, log_invalids, log_warnings};
189    let service_id_val = self.id;
190    let corpus_id_val = corpus.id;
191    connection.transaction(|t_connection| {
192      let pair_task_ids = || {
193        tasks::table
194          .filter(tasks::service_id.eq(service_id_val))
195          .filter(tasks::corpus_id.eq(corpus_id_val))
196          .select(tasks::id)
197      };
198      delete(log_infos::table.filter(log_infos::task_id.eq_any(pair_task_ids())))
199        .execute(t_connection)?;
200      delete(log_warnings::table.filter(log_warnings::task_id.eq_any(pair_task_ids())))
201        .execute(t_connection)?;
202      delete(log_errors::table.filter(log_errors::task_id.eq_any(pair_task_ids())))
203        .execute(t_connection)?;
204      delete(log_fatals::table.filter(log_fatals::task_id.eq_any(pair_task_ids())))
205        .execute(t_connection)?;
206      delete(log_invalids::table.filter(log_invalids::task_id.eq_any(pair_task_ids())))
207        .execute(t_connection)?;
208      delete(
209        tasks::table
210          .filter(tasks::service_id.eq(service_id_val))
211          .filter(tasks::corpus_id.eq(corpus_id_val)),
212      )
213      .execute(t_connection)
214    })
215  }
216
217  /// Permanently deletes this service **and all of its work across every corpus** — the `log_*`
218  /// messages and tasks of every `(*, service)` pair, then the `services` row itself — consuming
219  /// the object and returning the number of rows the final `services` delete removed. One
220  /// transaction, so a crash can't leave the service half-deleted (crash-consistency,
221  /// `docs/DESIGN_PRINCIPLES.md`). The `log_*` tables have no FK to `tasks`, so their rows are
222  /// deleted explicitly **before** the tasks or they orphan — the hazard closed in
223  /// [`super::Corpus::destroy`] and the reason this is one primitive rather than a bare
224  /// `DELETE FROM services` (which is exactly the latent `delete_service_by_name` orphan bug, R-6).
225  /// `historical_runs` tallies survive (no FK to tasks), while per-task `historical_tasks`
226  /// snapshots cascade away with the tasks — the same semantics as
227  /// [`Self::deactivate_from_corpus`] and corpus deletion.
228  ///
229  /// **Caller's contract:** the magic `init` (1) and `import` (2) services are infrastructure and
230  /// must never be destroyed; this method does not itself guard them (mirroring
231  /// [`super::Corpus::destroy`], which guards nothing). [`crate::backend`]'s
232  /// `destroy_service_by_name` enforces the guard, and the frontend rejects them with `403` before
233  /// ever reaching here.
234  pub fn destroy(self, connection: &mut PgConnection) -> Result<usize, Error> {
235    use crate::schema::tasks;
236    use crate::schema::{log_errors, log_fatals, log_infos, log_invalids, log_warnings};
237    let service_id_val = self.id;
238    connection.transaction(|t_connection| {
239      // The task ids of this service across all corpora, rebuilt per delete (the subquery is
240      // consumed by `eq_any`).
241      let service_task_ids = || {
242        tasks::table
243          .filter(tasks::service_id.eq(service_id_val))
244          .select(tasks::id)
245      };
246      delete(log_infos::table.filter(log_infos::task_id.eq_any(service_task_ids())))
247        .execute(t_connection)?;
248      delete(log_warnings::table.filter(log_warnings::task_id.eq_any(service_task_ids())))
249        .execute(t_connection)?;
250      delete(log_errors::table.filter(log_errors::task_id.eq_any(service_task_ids())))
251        .execute(t_connection)?;
252      delete(log_fatals::table.filter(log_fatals::task_id.eq_any(service_task_ids())))
253        .execute(t_connection)?;
254      delete(log_invalids::table.filter(log_invalids::task_id.eq_any(service_task_ids())))
255        .execute(t_connection)?;
256      // all tasks for this service (cascades to historical_tasks via its FK)
257      delete(tasks::table.filter(tasks::service_id.eq(service_id_val))).execute(t_connection)?;
258      // the service registration itself
259      delete(services::table.filter(services::id.eq(service_id_val))).execute(t_connection)
260    })
261  }
262}