1#![allow(clippy::extra_unused_lifetimes)]
2use chrono::prelude::*;
3use diesel::result::Error;
4use diesel::*;
5use serde::Serialize;
6use std::collections::HashSet;
7
8use crate::backend::progress_report;
12use crate::concerns::CortexInsertable;
13use crate::helpers::TaskStatus;
14use crate::models::{Corpus, Service};
15use crate::schema::historical_runs;
16
17#[derive(Identifiable, Queryable, Associations, Clone, Debug, PartialEq, Eq, QueryableByName)]
18#[diesel(table_name = historical_runs)]
19#[diesel(belongs_to(Corpus, foreign_key = corpus_id))]
20#[diesel(belongs_to(Service, foreign_key = service_id))]
21pub struct HistoricalRun {
23 pub id: i32,
25 pub service_id: i32,
27 pub corpus_id: i32,
29 pub total: i32,
31 pub invalid: i32,
33 pub fatal: i32,
35 pub error: i32,
37 pub warning: i32,
39 pub no_problem: i32,
41 pub in_progress: i32,
43 pub start_time: NaiveDateTime,
45 pub end_time: Option<NaiveDateTime>,
47 pub owner: String,
49 pub description: String,
51 pub public_id: uuid::Uuid,
54}
55
56#[derive(Debug, Serialize, Clone, Default)]
57pub struct RunMetadata {
59 pub total: i32,
61 pub invalid: i32,
63 pub fatal: i32,
65 pub error: i32,
67 pub warning: i32,
69 pub no_problem: i32,
71 pub in_progress: i32,
73 pub start_time: String,
75 pub end_time: String,
77 pub owner: String,
79 pub description: String,
81}
82impl RunMetadata {
83 pub fn field_f32(&self, field: &str) -> f32 {
85 let field_i32 = match field {
86 "invalid" => self.invalid,
87 "total" => self.total,
88 "fatal" => self.fatal,
89 "error" => self.error,
90 "warning" => self.warning,
91 "no_problem" => self.no_problem,
92 "in_progress" => self.in_progress,
93 _ => 0,
97 };
98 field_i32 as f32
99 }
100}
101
102#[derive(Debug, Serialize, Clone)]
103pub struct RunMetadataStack {
106 pub severity: String,
108 pub severity_numeric: i32,
110 pub percent: f32,
112 pub total: i32,
114 pub start_time: String,
116 pub end_time: String,
118 pub owner: String,
120 pub description: String,
122}
123impl RunMetadataStack {
124 pub fn transform(runs_meta: &[RunMetadata]) -> Vec<RunMetadataStack> {
126 let mut start_time_guard = HashSet::new();
127 let mut runs_meta_vega = Vec::new();
128 for run in runs_meta.iter() {
129 if run.description == "extending corpus with more entries" {
132 continue;
133 }
134 if !run.start_time.is_empty() && !run.end_time.is_empty() {
137 if start_time_guard.contains(&run.start_time) {
138 continue;
139 } else {
140 start_time_guard.insert(run.start_time.clone());
141 }
142 }
143 let total = run.field_f32("total");
144 if total <= 0.0 {
150 continue;
151 }
152 for field in ["fatal", "error", "warning", "no_problem", "in_progress"].iter() {
153 runs_meta_vega.push(RunMetadataStack {
154 severity: (*field).to_string(),
155 severity_numeric: TaskStatus::from_key(field).unwrap().raw(),
156 percent: (100.0 * run.field_f32(field)) / total,
157 total: run.total,
158 start_time: run.start_time.clone(),
159 end_time: run.end_time.clone(),
160 owner: run.owner.clone(),
161 description: run.description.clone(),
162 })
163 }
164 }
165 runs_meta_vega
166 }
167}
168
169#[derive(Insertable, Debug, Clone)]
170#[diesel(table_name = historical_runs)]
171pub struct NewHistoricalRun {
173 pub service_id: i32,
175 pub corpus_id: i32,
177 pub description: String,
179 pub owner: String,
181}
182
183impl CortexInsertable for NewHistoricalRun {
184 fn create(&self, connection: &mut PgConnection) -> Result<usize, Error> {
185 insert_into(historical_runs::table)
186 .values(self)
187 .execute(connection)
188 }
189}
190
191impl HistoricalRun {
192 pub fn find_by(
194 corpus: &Corpus,
195 service: &Service,
196 connection: &mut PgConnection,
197 ) -> Result<Vec<HistoricalRun>, Error> {
198 use crate::schema::historical_runs::dsl::{corpus_id, service_id, start_time};
199 let runs: Vec<HistoricalRun> = historical_runs::table
200 .filter(corpus_id.eq(corpus.id))
201 .filter(service_id.eq(service.id))
202 .order(start_time.desc())
203 .get_results(connection)?;
204 Ok(runs)
205 }
206
207 pub fn recent_all(
210 connection: &mut PgConnection,
211 limit: i64,
212 ) -> Result<Vec<HistoricalRun>, Error> {
213 Self::recent_filtered(connection, None, None, None, limit)
214 }
215
216 pub fn recent_filtered(
220 connection: &mut PgConnection,
221 corpus: Option<i32>,
222 service: Option<i32>,
223 owner: Option<&str>,
224 limit: i64,
225 ) -> Result<Vec<HistoricalRun>, Error> {
226 use crate::schema::historical_runs::dsl;
227 let mut query = dsl::historical_runs.into_boxed();
228 if let Some(corpus) = corpus {
229 query = query.filter(dsl::corpus_id.eq(corpus));
230 }
231 if let Some(service) = service {
232 query = query.filter(dsl::service_id.eq(service));
233 }
234 if let Some(owner) = owner {
235 query = query.filter(dsl::owner.eq(owner.to_string()));
236 }
237 query
238 .order(dsl::start_time.desc())
239 .limit(limit)
240 .get_results(connection)
241 }
242
243 pub fn find_current(
245 corpus: &Corpus,
246 service: &Service,
247 connection: &mut PgConnection,
248 ) -> Result<Option<HistoricalRun>, Error> {
249 use crate::schema::historical_runs::dsl::{corpus_id, end_time, service_id};
250 historical_runs::table
251 .filter(corpus_id.eq(corpus.id))
252 .filter(service_id.eq(service.id))
253 .filter(end_time.is_null())
254 .first(connection)
255 .optional()
256 }
257
258 pub fn mark_completed(&self, connection: &mut PgConnection) -> Result<bool, Error> {
266 use diesel::dsl::now;
267 if self.end_time.is_some() {
270 return Ok(false);
271 }
272 let t = live_tally_fields(connection, self.corpus_id, self.service_id);
275 let rows = update(
276 historical_runs::table
277 .filter(historical_runs::id.eq(self.id))
278 .filter(historical_runs::end_time.is_null()),
279 )
280 .set((
281 historical_runs::end_time.eq(now),
282 historical_runs::total.eq(t.total),
283 historical_runs::in_progress.eq(t.in_progress),
284 historical_runs::invalid.eq(t.invalid),
285 historical_runs::no_problem.eq(t.no_problem),
286 historical_runs::warning.eq(t.warning),
287 historical_runs::error.eq(t.error),
288 historical_runs::fatal.eq(t.fatal),
289 ))
290 .execute(connection)?;
291 Ok(rows == 1)
292 }
293
294 pub fn complete_if_drained(
307 corpus: i32,
308 service: i32,
309 connection: &mut PgConnection,
310 ) -> Result<bool, Error> {
311 use crate::schema::historical_runs::dsl::{corpus_id, end_time, service_id};
312 use crate::schema::tasks;
313 if service <= 2 {
315 return Ok(false);
316 }
317 let unfinished: i64 = tasks::table
322 .filter(tasks::corpus_id.eq(corpus))
323 .filter(tasks::service_id.eq(service))
324 .filter(tasks::status.ge(0).or(tasks::status.lt(-5)))
325 .count()
326 .get_result(connection)?;
327 if unfinished > 0 {
328 return Ok(false);
329 }
330 let open: Option<HistoricalRun> = historical_runs::table
332 .filter(corpus_id.eq(corpus))
333 .filter(service_id.eq(service))
334 .filter(end_time.is_null())
335 .first(connection)
336 .optional()?;
337 match open {
341 Some(run) => run.mark_completed(connection),
342 None => Ok(false),
343 }
344 }
345
346 #[must_use]
355 pub fn with_live_tallies(mut self, connection: &mut PgConnection) -> HistoricalRun {
356 if self.end_time.is_none() {
357 let t = live_tally_fields(connection, self.corpus_id, self.service_id);
358 self.total = t.total;
359 self.no_problem = t.no_problem;
360 self.warning = t.warning;
361 self.error = t.error;
362 self.fatal = t.fatal;
363 self.invalid = t.invalid;
364 self.in_progress = t.in_progress;
365 }
366 self
367 }
368
369 #[must_use]
376 pub fn overlay_live_tallies(
377 mut runs: Vec<HistoricalRun>,
378 connection: &mut PgConnection,
379 ) -> Vec<HistoricalRun> {
380 let pairs: Vec<(i32, i32)> = runs
381 .iter()
382 .filter(|run| run.end_time.is_none())
383 .map(|run| (run.corpus_id, run.service_id))
384 .collect::<HashSet<_>>()
385 .into_iter()
386 .collect();
387 if pairs.is_empty() {
388 return runs; }
390 let tallies = live_tally_fields_batch(connection, &pairs);
391 for run in runs.iter_mut().filter(|run| run.end_time.is_none()) {
392 if let Some(t) = tallies.get(&(run.corpus_id, run.service_id)) {
393 run.total = t.total;
394 run.no_problem = t.no_problem;
395 run.warning = t.warning;
396 run.error = t.error;
397 run.fatal = t.fatal;
398 run.invalid = t.invalid;
399 run.in_progress = t.in_progress;
400 }
401 }
402 runs
403 }
404}
405
406#[derive(Default)]
408struct RunTallies {
409 total: i32,
410 no_problem: i32,
411 warning: i32,
412 error: i32,
413 fatal: i32,
414 invalid: i32,
415 in_progress: i32,
416}
417
418fn live_tally_fields(connection: &mut PgConnection, corpus_id: i32, service_id: i32) -> RunTallies {
425 let report = progress_report(connection, corpus_id, service_id);
426 let get = |key: &str| *report.get(key).unwrap_or(&0.0);
427 RunTallies {
428 total: get("total") as i32,
429 no_problem: get("no_problem") as i32,
430 warning: get("warning") as i32,
431 error: get("error") as i32,
432 fatal: get("fatal") as i32,
433 invalid: get("invalid") as i32,
434 in_progress: (get("queued") + get("todo")) as i32,
435 }
436}
437
438fn live_tally_fields_batch(
446 connection: &mut PgConnection,
447 pairs: &[(i32, i32)],
448) -> std::collections::HashMap<(i32, i32), RunTallies> {
449 use crate::schema::tasks::dsl::{corpus_id, service_id, status, tasks};
450 use diesel::dsl::sql;
451 use diesel::sql_types::BigInt;
452 let mut out: std::collections::HashMap<(i32, i32), RunTallies> = std::collections::HashMap::new();
453 if pairs.is_empty() {
454 return out;
455 }
456 let wanted: HashSet<(i32, i32)> = pairs.iter().copied().collect();
457 let corpus_ids: Vec<i32> = wanted.iter().map(|(corpus, _)| *corpus).collect();
458 let service_ids: Vec<i32> = wanted.iter().map(|(_, service)| *service).collect();
459 let rows: Vec<(i32, i32, i32, i64)> = tasks
462 .select((corpus_id, service_id, status, sql::<BigInt>("count(*)")))
463 .filter(corpus_id.eq_any(&corpus_ids))
464 .filter(service_id.eq_any(&service_ids))
465 .group_by((corpus_id, service_id, status))
466 .load(connection)
467 .unwrap_or_default();
468 for (corpus, service, raw_status, count) in rows {
469 if !wanted.contains(&(corpus, service)) {
470 continue; }
472 let count = count as i32;
473 let tally = out.entry((corpus, service)).or_default();
474 let task_status = TaskStatus::from_raw(raw_status);
475 if task_status != TaskStatus::Invalid {
476 tally.total += count; }
478 match task_status {
479 TaskStatus::NoProblem => tally.no_problem += count,
480 TaskStatus::Warning => tally.warning += count,
481 TaskStatus::Error => tally.error += count,
482 TaskStatus::Fatal => tally.fatal += count,
483 TaskStatus::Invalid => tally.invalid += count,
484 TaskStatus::TODO => tally.in_progress += count,
486 _ if raw_status > TaskStatus::TODO.raw() => tally.in_progress += count,
487 _ => {}, }
489 }
490 out
491}
492
493impl From<HistoricalRun> for RunMetadata {
494 fn from(run: HistoricalRun) -> RunMetadata {
495 let HistoricalRun {
496 total,
497 warning,
498 error,
499 no_problem,
500 invalid,
501 fatal,
502 start_time,
503 end_time,
504 description,
505 in_progress,
506 owner,
507 ..
508 } = run;
509 RunMetadata {
510 total,
511 invalid,
512 fatal,
513 warning,
514 error,
515 no_problem,
516 in_progress,
517 start_time: start_time
524 .and_utc()
525 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
526 end_time: match end_time {
527 Some(etime) => etime
528 .and_utc()
529 .to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
530 None => String::new(),
531 },
532 owner,
533 description,
534 }
535 }
536}
537
538#[cfg(test)]
539mod tests {
540 use super::{RunMetadata, RunMetadataStack};
541
542 #[test]
546 fn transform_skips_zero_total_runs_so_one_doesnt_blank_the_chart() {
547 let zero_total = RunMetadata {
548 invalid: 5, start_time: "2026-01-01 00:00:00".to_string(),
550 end_time: "2026-01-01 01:00:00".to_string(),
551 ..Default::default()
552 };
553 let real = RunMetadata {
554 total: 4,
555 warning: 1,
556 no_problem: 3,
557 start_time: "2026-01-02 00:00:00".to_string(),
558 end_time: "2026-01-02 01:00:00".to_string(),
559 ..Default::default()
560 };
561 let stack = RunMetadataStack::transform(&[zero_total, real]);
562 assert!(!stack.is_empty(), "the valid run still charts");
563 assert!(
564 stack.iter().all(|row| row.percent.is_finite()),
565 "no NaN/Inf percents — a zero-total run is skipped, never divided by zero"
566 );
567 assert!(
568 serde_json::to_string(&stack).is_ok(),
569 "the series serializes — the bug was serde failing on a NaN and blanking the chart"
570 );
571 }
572}