1use std::thread;
13
14use chrono::NaiveDateTime;
15use diesel::dsl::now;
16use diesel::pg::PgConnection;
17use diesel::prelude::*;
18use serde_json::Value;
19use uuid::Uuid;
20
21use crate::backend::DbPool;
22use crate::config::config;
23use crate::schema::jobs;
24
25#[derive(Queryable, Identifiable, Clone, Debug)]
27#[diesel(table_name = jobs)]
28pub struct Job {
29 pub id: i64,
31 pub uuid: Uuid,
33 pub kind: String,
35 pub status: String,
37 pub progress_current: i32,
39 pub progress_total: Option<i32>,
41 pub message: String,
43 pub actor: String,
45 pub params: Value,
47 pub result: Option<Value>,
49 pub created_at: NaiveDateTime,
51 pub updated_at: NaiveDateTime,
53}
54
55#[derive(Insertable)]
56#[diesel(table_name = jobs)]
57struct NewJob {
58 kind: String,
59 actor: String,
60 params: Value,
61}
62
63pub struct JobProgress {
65 pool: DbPool,
66 job_id: i64,
67}
68impl JobProgress {
69 pub fn step(&self, current: i32, total: Option<i32>, message: &str) {
71 if let Ok(mut connection) = self.pool.get() {
72 let _ = diesel::update(jobs::table.filter(jobs::id.eq(self.job_id)))
73 .set((
74 jobs::progress_current.eq(current),
75 jobs::progress_total.eq(total),
76 jobs::message.eq(message),
77 jobs::updated_at.eq(now),
78 ))
79 .execute(&mut connection);
80 }
81 }
82}
83
84pub fn spawn_job<F>(
87 pool: DbPool,
88 kind: &str,
89 actor: &str,
90 params: Value,
91 body: F,
92) -> Result<Uuid, String>
93where
94 F: FnOnce(&JobProgress) -> Result<Value, String> + Send + 'static,
95{
96 let mut connection = pool.get().map_err(|e| e.to_string())?;
97 let (job_id, job_uuid): (i64, Uuid) = diesel::insert_into(jobs::table)
98 .values(NewJob {
99 kind: kind.to_string(),
100 actor: actor.to_string(),
101 params,
102 })
103 .returning((jobs::id, jobs::uuid))
104 .get_result(&mut connection)
105 .map_err(|e| e.to_string())?;
106 drop(connection);
107
108 let log_kind = kind.to_string();
113 let log_actor = actor.to_string();
114 tracing::info!(kind = %log_kind, actor = %log_actor, job = %job_uuid, "background job spawned");
115
116 let worker_pool = pool.clone();
117 thread::spawn(move || {
118 let started = std::time::Instant::now();
119 set_running(&worker_pool, job_id);
120 let progress = JobProgress {
121 pool: worker_pool.clone(),
122 job_id,
123 };
124 let (status, message) =
128 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(&progress))) {
129 Ok(Ok(result)) => {
130 finish(&worker_pool, job_id, "succeeded", "", Some(result));
131 ("succeeded", String::new())
132 },
133 Ok(Err(message)) => {
134 finish(&worker_pool, job_id, "failed", &message, None);
135 ("failed", message)
136 },
137 Err(panic) => {
138 let message = format!("job panicked: {}", panic_message(&*panic));
139 finish(&worker_pool, job_id, "failed", &message, None);
140 ("failed", message)
141 },
142 };
143 let elapsed_ms = started.elapsed().as_millis();
144 if status == "succeeded" {
145 tracing::info!(kind = %log_kind, actor = %log_actor, job = %job_uuid, elapsed_ms, "background job succeeded");
146 } else {
147 tracing::warn!(kind = %log_kind, actor = %log_actor, job = %job_uuid, elapsed_ms, error = %message, "background job failed");
148 }
149 });
150 Ok(job_uuid)
151}
152
153pub const REFRESH_REPORTS_KIND: &str = "refresh_reports";
155
156pub fn spawn_report_refresh(pool: DbPool, actor: &str) -> Result<Uuid, String> {
166 {
168 let mut connection = pool.get().map_err(|e| e.to_string())?;
169 if let Some(existing) = list_recent(&mut connection, true, 200)
170 .into_iter()
171 .find(|job| job.kind == REFRESH_REPORTS_KIND)
172 {
173 return Ok(existing.uuid);
174 }
175 }
176 spawn_job(pool, REFRESH_REPORTS_KIND, actor, Value::Null, |progress| {
177 let mut connection = progress.pool.get().map_err(|e| e.to_string())?;
178 crate::backend::invalidate_all(&mut connection).map_err(|e| e.to_string())?;
179 Ok(serde_json::json!({ "status": "cache-invalidated" }))
180 })
181}
182
183pub const POPULATE_REPORT_KIND: &str = "populate_report";
185
186pub fn report_populate_active(
191 connection: &mut PgConnection,
192 corpus_id: i32,
193 service_id: i32,
194 severity: &str,
195) -> bool {
196 list_recent(connection, true, 200)
197 .into_iter()
198 .any(|job| populate_job_matches(&job, corpus_id, service_id, severity))
199}
200
201fn populate_job_matches(job: &Job, corpus_id: i32, service_id: i32, severity: &str) -> bool {
202 job.kind == POPULATE_REPORT_KIND
203 && job.params.get("corpus_id").and_then(Value::as_i64) == Some(corpus_id as i64)
204 && job.params.get("service_id").and_then(Value::as_i64) == Some(service_id as i64)
205 && job.params.get("severity").and_then(Value::as_str) == Some(severity)
206}
207
208pub fn spawn_report_populate(
216 pool: DbPool,
217 corpus_id: i32,
218 service_id: i32,
219 severity: &str,
220 actor: &str,
221) -> Result<Uuid, String> {
222 {
223 let mut connection = pool.get().map_err(|e| e.to_string())?;
224 if let Some(existing) = list_recent(&mut connection, true, 200)
225 .into_iter()
226 .find(|job| populate_job_matches(job, corpus_id, service_id, severity))
227 {
228 return Ok(existing.uuid);
229 }
230 }
231 let severity = severity.to_string();
232 let params =
233 serde_json::json!({ "corpus_id": corpus_id, "service_id": service_id, "severity": severity });
234 spawn_job(pool, POPULATE_REPORT_KIND, actor, params, move |progress| {
235 let mut connection = progress.pool.get().map_err(|e| e.to_string())?;
236 progress.step(0, Some(1), &format!("aggregating the {severity} report"));
237 crate::backend::populate_scope(&mut connection, corpus_id, service_id, &severity)
238 .map_err(|e| e.to_string())?;
239 progress.step(1, Some(1), "report ready");
240 Ok(serde_json::json!({ "populated": severity }))
241 })
242}
243
244pub const REINDEX_KIND: &str = "reindex";
246
247const MAINTENANCE_TABLES: [&str; 7] = [
252 "tasks",
253 "log_infos",
254 "log_warnings",
255 "log_errors",
256 "log_fatals",
257 "log_invalids",
258 "historical_tasks",
259];
260
261pub fn spawn_reindex(pool: DbPool, actor: &str) -> Result<Uuid, String> {
269 {
270 let mut connection = pool.get().map_err(|e| e.to_string())?;
271 if let Some(existing) = list_recent(&mut connection, true, 200)
272 .into_iter()
273 .find(|job| job.kind == REINDEX_KIND)
274 {
275 return Ok(existing.uuid);
276 }
277 }
278 spawn_job(
279 pool,
280 REINDEX_KIND,
281 actor,
282 serde_json::json!({ "tables": MAINTENANCE_TABLES }),
283 |progress| {
284 let mut connection = progress.pool.get().map_err(|e| e.to_string())?;
285 let total = MAINTENANCE_TABLES.len() as i32;
286 for (index, table) in MAINTENANCE_TABLES.iter().enumerate() {
287 progress.step(index as i32, Some(total), &format!("reindexing {table}"));
288 diesel::sql_query(format!("REINDEX (CONCURRENTLY) TABLE {table}"))
290 .execute(&mut connection)
291 .map_err(|e| format!("reindex {table} failed: {e}"))?;
292 }
293 progress.step(total, Some(total), "reindex complete");
294 Ok(serde_json::json!({ "reindexed": MAINTENANCE_TABLES }))
295 },
296 )
297}
298
299pub const ANALYZE_KIND: &str = "analyze";
301
302pub fn spawn_analyze(pool: DbPool, actor: &str) -> Result<Uuid, String> {
310 {
311 let mut connection = pool.get().map_err(|e| e.to_string())?;
312 if let Some(existing) = list_recent(&mut connection, true, 200)
313 .into_iter()
314 .find(|job| job.kind == ANALYZE_KIND)
315 {
316 return Ok(existing.uuid);
317 }
318 }
319 spawn_job(
320 pool,
321 ANALYZE_KIND,
322 actor,
323 serde_json::json!({ "tables": MAINTENANCE_TABLES }),
324 |progress| {
325 let mut connection = progress.pool.get().map_err(|e| e.to_string())?;
326 let total = MAINTENANCE_TABLES.len() as i32;
327 for (index, table) in MAINTENANCE_TABLES.iter().enumerate() {
328 progress.step(index as i32, Some(total), &format!("analyzing {table}"));
329 diesel::sql_query(format!("ANALYZE {table}"))
331 .execute(&mut connection)
332 .map_err(|e| format!("analyze {table} failed: {e}"))?;
333 }
334 progress.step(total, Some(total), "analyze complete");
335 Ok(serde_json::json!({ "analyzed": MAINTENANCE_TABLES }))
336 },
337 )
338}
339
340fn panic_message(panic: &(dyn std::any::Any + Send)) -> String {
342 if let Some(s) = panic.downcast_ref::<&str>() {
343 (*s).to_string()
344 } else if let Some(s) = panic.downcast_ref::<String>() {
345 s.clone()
346 } else {
347 "unknown panic".to_string()
348 }
349}
350
351fn set_running(pool: &DbPool, job_id: i64) {
352 if let Ok(mut connection) = pool.get() {
353 let _ = diesel::update(jobs::table.filter(jobs::id.eq(job_id)))
354 .set((jobs::status.eq("running"), jobs::updated_at.eq(now)))
355 .execute(&mut connection);
356 }
357}
358
359fn finish(pool: &DbPool, job_id: i64, status: &str, message: &str, result: Option<Value>) {
360 if let Ok(mut connection) = pool.get() {
361 let _ = diesel::update(jobs::table.filter(jobs::id.eq(job_id)))
362 .set((
363 jobs::status.eq(status),
364 jobs::message.eq(message),
365 jobs::result.eq(result),
366 jobs::updated_at.eq(now),
367 ))
368 .execute(&mut connection);
369 }
370}
371
372pub fn db_now(connection: &mut PgConnection) -> Option<NaiveDateTime> {
380 use diesel::dsl::sql;
381 use diesel::sql_types::Timestamp;
382 diesel::select(sql::<Timestamp>("LOCALTIMESTAMP"))
383 .get_result(connection)
384 .ok()
385}
386
387pub fn find_job(connection: &mut PgConnection, job_uuid: Uuid) -> Option<Job> {
389 jobs::table
390 .filter(jobs::uuid.eq(job_uuid))
391 .first(connection)
392 .optional()
393 .ok()
394 .flatten()
395}
396
397pub fn reap_stale(connection: &mut PgConnection) -> usize {
414 let timeout_secs = config().jobs.stale_timeout_seconds;
415 let Some(clock) = db_now(connection) else {
416 return 0; };
418 let cutoff = clock - chrono::Duration::seconds(timeout_secs);
419 diesel::update(
420 jobs::table
421 .filter(jobs::status.eq("queued").or(jobs::status.eq("running")))
422 .filter(jobs::updated_at.lt(cutoff)),
423 )
424 .set((
425 jobs::status.eq("interrupted"),
426 jobs::message.eq(format!(
427 "no progress heartbeat for over {} minutes; presumed stalled (W-4)",
428 timeout_secs / 60
429 )),
430 ))
431 .execute(connection)
432 .unwrap_or(0)
433}
434
435pub fn count_recent_with_status(connection: &mut PgConnection, status: &str, hours: i64) -> usize {
440 let Some(clock) = db_now(connection) else {
441 return 0;
442 };
443 let cutoff = clock - chrono::Duration::hours(hours);
444 jobs::table
445 .filter(jobs::status.eq(status))
446 .filter(jobs::created_at.gt(cutoff))
447 .count()
448 .get_result::<i64>(connection)
449 .map(|count| count as usize)
450 .unwrap_or(0)
451}
452
453pub fn list_recent(connection: &mut PgConnection, active_only: bool, limit: i64) -> Vec<Job> {
457 reap_stale(connection);
460 let mut query = jobs::table
461 .order(jobs::created_at.desc())
462 .limit(limit)
463 .into_boxed();
464 if active_only {
465 query = query.filter(jobs::status.eq("queued").or(jobs::status.eq("running")));
466 }
467 query.load(connection).unwrap_or_default()
468}
469
470pub fn interrupt_orphans(connection: &mut PgConnection) -> usize {
473 diesel::update(jobs::table.filter(jobs::status.eq("queued").or(jobs::status.eq("running"))))
474 .set((
475 jobs::status.eq("interrupted"),
476 jobs::message.eq("interrupted by a restart"),
477 ))
478 .execute(connection)
479 .unwrap_or(0)
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485
486 fn job(kind: &str, params: Value) -> Job {
487 let t = chrono::NaiveDate::from_ymd_opt(2026, 6, 28)
488 .unwrap()
489 .and_hms_opt(0, 0, 0)
490 .unwrap();
491 Job {
492 id: 1,
493 uuid: Uuid::nil(),
494 kind: kind.to_string(),
495 status: "running".to_string(),
496 progress_current: 0,
497 progress_total: None,
498 message: String::new(),
499 actor: "test".to_string(),
500 params,
501 result: None,
502 created_at: t,
503 updated_at: t,
504 }
505 }
506
507 #[test]
510 fn populate_job_matches_only_its_own_scope() {
511 let p = serde_json::json!({ "corpus_id": 7, "service_id": 3, "severity": "info" });
512 let j = job(POPULATE_REPORT_KIND, p);
513 assert!(populate_job_matches(&j, 7, 3, "info"));
514 assert!(!populate_job_matches(&j, 7, 3, "warning")); assert!(!populate_job_matches(&j, 8, 3, "info")); assert!(!populate_job_matches(&j, 7, 4, "info")); let other = job(
519 REFRESH_REPORTS_KIND,
520 serde_json::json!({ "corpus_id": 7, "service_id": 3, "severity": "info" }),
521 );
522 assert!(!populate_job_matches(&other, 7, 3, "info"));
523 }
524}