1use super::archive::ArchiveFilterParams;
15use chrono::{DateTime, Duration, NaiveDateTime, TimeZone, Utc};
16use hammerwork::archive::{ArchivalReason, ArchivedJob};
17use hammerwork::queue::DatabaseQueue;
18use hammerwork::{JobQueue, JobStatus, Result};
19use serde::Serialize;
20use sqlx::Row;
21use std::collections::BTreeMap;
22use std::future::Future;
23
24use crate::websocket::JobUpdate;
25
26#[derive(Debug, Clone, PartialEq, Serialize)]
28pub struct HourBucket {
29 pub hour: DateTime<Utc>,
31 pub completed: u64,
32 pub failed: u64,
33 pub avg_processing_time_ms: Option<f64>,
35}
36
37#[derive(Debug, Clone, PartialEq)]
39pub struct ErrorGroup {
40 pub queue_name: String,
41 pub message: String,
42 pub count: u64,
43 pub first_seen: DateTime<Utc>,
44 pub last_seen: DateTime<Utc>,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum FinishedKind {
50 Completed,
51 Dead,
52}
53
54impl FinishedKind {
55 fn status(self) -> &'static str {
56 match self {
57 FinishedKind::Completed => "Completed",
58 FinishedKind::Dead => "Dead",
59 }
60 }
61
62 fn age_column(self) -> &'static str {
64 match self {
65 FinishedKind::Completed => "completed_at",
66 FinishedKind::Dead => "COALESCE(failed_at, timed_out_at, created_at)",
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
83#[serde(rename_all = "lowercase")]
84pub enum TableMaintenance {
85 Vacuum,
86 Reindex,
87 Optimize,
88}
89
90impl TableMaintenance {
91 pub fn parse(name: &str) -> Option<Self> {
93 match name {
94 "vacuum" => Some(Self::Vacuum),
95 "reindex" => Some(Self::Reindex),
96 "optimize" => Some(Self::Optimize),
97 _ => None,
98 }
99 }
100
101 pub fn postgres_statement(self, table: &'static str) -> String {
103 match self {
104 Self::Vacuum => format!("VACUUM (ANALYZE) {table}"),
105 Self::Reindex => format!("REINDEX TABLE {table}"),
106 Self::Optimize => format!("ANALYZE {table}"),
107 }
108 }
109
110 pub fn mysql_statement(self, table: &'static str) -> String {
112 format!("OPTIMIZE TABLE {table}")
113 }
114}
115
116pub const HAMMERWORK_TABLES: &[&str] = &[
119 "hammerwork_jobs",
120 "hammerwork_jobs_archive",
121 "hammerwork_batches",
122 "hammerwork_workflows",
123 "hammerwork_queue_pause",
124 "hammerwork_encryption_keys",
125 "hammerwork_kms_data_keys",
126 "hammerwork_key_audit_log",
127 "hammerwork_migrations",
128];
129
130pub fn maintenance_tables(target: Option<&str>) -> std::result::Result<Vec<&'static str>, String> {
133 match target {
134 None => Ok(HAMMERWORK_TABLES.to_vec()),
135 Some(name) => HAMMERWORK_TABLES
136 .iter()
137 .find(|t| **t == name)
138 .map(|t| vec![*t])
139 .ok_or_else(|| format!("'{name}' is not a Hammerwork table")),
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Serialize)]
145pub struct MaintenanceStatement {
146 pub table: &'static str,
147 pub statement: String,
148 pub messages: Vec<String>,
150}
151
152fn present_tables(candidates: &[&'static str], existing: &[String]) -> Vec<&'static str> {
155 candidates
156 .iter()
157 .copied()
158 .filter(|t| existing.iter().any(|e| e == t))
159 .collect()
160}
161
162pub const MAX_BUCKETS: i64 = 24 * 31;
164
165pub fn hour_floor(t: DateTime<Utc>) -> DateTime<Utc> {
167 let secs = t.timestamp() - t.timestamp().rem_euclid(3600);
168 Utc.timestamp_opt(secs, 0).single().unwrap_or(t)
169}
170
171pub fn hour_range(start: DateTime<Utc>, end: DateTime<Utc>) -> Vec<DateTime<Utc>> {
173 let mut hours = Vec::new();
174 let mut cursor = hour_floor(start);
175 while cursor < end && (hours.len() as i64) < MAX_BUCKETS {
176 hours.push(cursor);
177 cursor += Duration::hours(1);
178 }
179 hours
180}
181
182pub fn fill_buckets(
184 hours: &[DateTime<Utc>],
185 completed: &BTreeMap<DateTime<Utc>, (u64, Option<f64>)>,
186 failed: &BTreeMap<DateTime<Utc>, u64>,
187) -> Vec<HourBucket> {
188 hours
189 .iter()
190 .map(|hour| {
191 let (completed, avg) = completed.get(hour).copied().unwrap_or((0, None));
192 HourBucket {
193 hour: *hour,
194 completed,
195 failed: failed.get(hour).copied().unwrap_or(0),
196 avg_processing_time_ms: avg,
197 }
198 })
199 .collect()
200}
201
202fn to_u64(n: i64) -> u64 {
203 u64::try_from(n).unwrap_or(0)
204}
205
206pub trait JobHistory: DatabaseQueue + Send + Sync {
208 fn hourly_activity(
210 &self,
211 queue: Option<&str>,
212 start: DateTime<Utc>,
213 end: DateTime<Utc>,
214 ) -> impl Future<Output = Result<Vec<HourBucket>>> + Send;
215
216 fn error_groups(
218 &self,
219 since: DateTime<Utc>,
220 limit: u32,
221 ) -> impl Future<Output = Result<Vec<ErrorGroup>>> + Send;
222
223 fn count_jobs(
225 &self,
226 queue: Option<&str>,
227 kind: FinishedKind,
228 older_than: Option<DateTime<Utc>>,
229 ) -> impl Future<Output = Result<u64>> + Send;
230
231 fn count_archivable(
239 &self,
240 queue: Option<&str>,
241 policy: &hammerwork::archive::ArchivalPolicy,
242 ) -> impl Future<Output = Result<u64>> + Send {
243 let _ = (queue, policy);
244 async {
245 Err(hammerwork::HammerworkError::Queue {
246 message: "archive dry runs are not supported by this queue".to_string(),
247 })
248 }
249 }
250
251 fn delete_jobs(
253 &self,
254 queue: Option<&str>,
255 kind: FinishedKind,
256 older_than: Option<DateTime<Utc>>,
257 ) -> impl Future<Output = Result<u64>> + Send;
258
259 fn archived_jobs(
262 &self,
263 filter: &ArchiveFilterParams,
264 limit: u32,
265 offset: u32,
266 ) -> impl Future<Output = Result<(Vec<ArchivedJob>, u64)>> + Send;
267
268 fn database_now(&self) -> impl Future<Output = Result<DateTime<Utc>>> + Send;
270
271 fn recent_job_changes(
282 &self,
283 since: DateTime<Utc>,
284 limit: u32,
285 ) -> impl Future<Output = Result<Vec<JobUpdate>>> + Send;
286
287 fn running_jobs(&self, limit: u32) -> impl Future<Output = Result<Vec<JobUpdate>>> + Send {
293 let _ = limit;
294 async { Ok(Vec::new()) }
295 }
296
297 fn job_states(&self, ids: &[String]) -> impl Future<Output = Result<Vec<JobUpdate>>> + Send {
305 let ids: Vec<uuid::Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
306 async move {
307 let mut states = Vec::with_capacity(ids.len());
308 for id in ids {
309 if let Some(job) = self.get_job(id).await? {
310 let changed_at = [
311 job.started_at,
312 job.completed_at,
313 job.failed_at,
314 job.timed_out_at,
315 ]
316 .into_iter()
317 .flatten()
318 .fold(job.created_at, DateTime::max);
319 states.push(job_change(
320 id.to_string(),
321 job.queue_name,
322 job.status.as_str(),
323 job.priority.as_i32(),
324 job.attempts,
325 changed_at,
326 ));
327 }
328 }
329 Ok(states)
330 }
331 }
332
333 fn table_maintenance(
337 &self,
338 operation: TableMaintenance,
339 tables: &[&'static str],
340 dry_run: bool,
341 ) -> impl Future<Output = Result<Vec<MaintenanceStatement>>> + Send;
342}
343
344const CHANGE_COLUMNS: [&str; 5] = [
347 "created_at",
348 "started_at",
349 "completed_at",
350 "failed_at",
351 "timed_out_at",
352];
353
354fn changed_since_filter(postgres: bool) -> String {
357 let placeholder = if postgres { "$1" } else { "?" };
358 CHANGE_COLUMNS
359 .iter()
360 .map(|column| format!("{column} > {placeholder}"))
361 .collect::<Vec<_>>()
362 .join(" OR ")
363}
364
365fn job_change(
367 id: String,
368 queue_name: String,
369 status: &str,
370 priority: i32,
371 attempts: i32,
372 changed_at: DateTime<Utc>,
373) -> JobUpdate {
374 JobUpdate {
375 id,
376 queue_name,
377 status: status.trim_matches('"').to_string(),
378 priority: hammerwork::JobPriority::from_i32(priority)
379 .unwrap_or_default()
380 .to_string(),
381 attempts,
382 updated_at: changed_at,
383 }
384}
385
386fn archivable_statuses(
389 policy: &hammerwork::archive::ArchivalPolicy,
390) -> [(Option<Duration>, &'static str, &'static str); 4] {
391 [
392 (policy.archive_completed_after, "Completed", "completed_at"),
393 (policy.archive_failed_after, "Failed", "failed_at"),
394 (policy.archive_dead_after, "Dead", "failed_at"),
395 (policy.archive_timed_out_after, "TimedOut", "timed_out_at"),
396 ]
397}
398
399fn archivable_count_query(
402 policy: &hammerwork::archive::ArchivalPolicy,
403 queue: bool,
404 now: DateTime<Utc>,
405 postgres: bool,
406) -> Option<(String, Vec<DateTime<Utc>>)> {
407 if !policy.enabled || policy.batch_size == 0 {
408 return None;
409 }
410 let mut param = 0;
411 let mut placeholder = || {
412 param += 1;
413 if postgres {
414 format!("${param}")
415 } else {
416 "?".to_string()
417 }
418 };
419 let queue_filter = if queue {
420 format!(" AND queue_name = {}", placeholder())
421 } else {
422 String::new()
423 };
424 let mut conditions = Vec::new();
425 let mut cutoffs = Vec::new();
426 for (after, status, column) in archivable_statuses(policy) {
427 if let Some(after) = after {
428 conditions.push(format!(
429 "(status = '{status}' AND {column} IS NOT NULL AND {column} <= {})",
430 placeholder()
431 ));
432 cutoffs.push(now - after);
433 }
434 }
435 if conditions.is_empty() {
436 return None;
437 }
438 Some((
439 format!(
440 "SELECT COUNT(*) FROM (SELECT id FROM hammerwork_jobs WHERE archived_at IS NULL{} \
441 AND ({}) LIMIT {}) candidates",
442 queue_filter,
443 conditions.join(" OR "),
444 policy.batch_size
445 ),
446 cutoffs,
447 ))
448}
449
450fn mysql_latest_change() -> String {
453 let latest = CHANGE_COLUMNS
454 .iter()
455 .map(|column| format!("COALESCE({column}, created_at)"))
456 .collect::<Vec<_>>()
457 .join(", ");
458 format!("CAST(GREATEST({latest}) AS DATETIME(6))")
459}
460
461fn pg_job_changes(rows: &[sqlx::postgres::PgRow]) -> Vec<JobUpdate> {
464 decodable(rows.iter().map(|row| {
465 let id = row.try_get::<uuid::Uuid, _>("id");
466 let label = id.as_ref().map(|id| id.to_string()).unwrap_or_default();
467 let change = id.map_err(Into::into).and_then(|id| {
468 Ok(job_change(
469 id.to_string(),
470 row.try_get("queue_name")?,
471 &row.try_get::<String, _>("status")?,
472 row.try_get("priority")?,
473 row.try_get("attempts")?,
474 row.try_get("changed_at")?,
475 ))
476 });
477 (label, change)
478 }))
479}
480
481fn mysql_job_changes(rows: &[sqlx::mysql::MySqlRow]) -> Vec<JobUpdate> {
484 decodable(rows.iter().map(|row| {
485 let label = row.try_get::<String, _>("id").unwrap_or_default();
486 let change = (|| {
487 Ok(job_change(
488 row.try_get("id")?,
489 row.try_get("queue_name")?,
490 &row.try_get::<String, _>("status")?,
491 row.try_get("priority")?,
492 row.try_get("attempts")?,
493 row.try_get("changed_at")?,
494 ))
495 })();
496 (label, change)
497 }))
498}
499
500#[derive(Debug, Clone, PartialEq)]
502enum ArchiveBind {
503 Text(String),
504 Time(DateTime<Utc>),
505 Bool(bool),
506}
507
508fn archive_conditions(filter: &ArchiveFilterParams, postgres: bool) -> (String, Vec<ArchiveBind>) {
512 let mut clauses = Vec::new();
513 let mut binds = Vec::new();
514 let mut add = |clause: &str, bind: ArchiveBind| {
515 binds.push(bind);
516 let placeholder = if postgres {
517 format!("${}", binds.len())
518 } else {
519 "?".to_string()
520 };
521 clauses.push(clause.replace('?', &placeholder));
522 };
523 if let Some(queue) = &filter.queue {
524 add("queue_name = ?", ArchiveBind::Text(queue.clone()));
525 }
526 if let Some(reason) = &filter.reason {
527 add(
528 "LOWER(TRIM(BOTH '\"' FROM archival_reason)) = ?",
529 ArchiveBind::Text(reason.to_lowercase()),
530 );
531 }
532 if let Some(after) = filter.archived_after {
533 add("archived_at >= ?", ArchiveBind::Time(after));
534 }
535 if let Some(before) = filter.archived_before {
536 add("archived_at <= ?", ArchiveBind::Time(before));
537 }
538 if let Some(by) = &filter.archived_by {
539 add("archived_by = ?", ArchiveBind::Text(by.clone()));
540 }
541 if let Some(compressed) = filter.compressed {
542 add("payload_compressed = ?", ArchiveBind::Bool(compressed));
543 }
544 if let Some(status) = &filter.original_status {
545 add(
546 "LOWER(TRIM(BOTH '\"' FROM status)) = ?",
547 ArchiveBind::Text(status.to_lowercase()),
548 );
549 }
550 if clauses.is_empty() {
551 (String::new(), binds)
552 } else {
553 (format!("WHERE {}", clauses.join(" AND ")), binds)
554 }
555}
556
557macro_rules! bind_archive_filter {
559 ($query:expr, $binds:expr) => {{
560 let mut query = $query;
561 for bind in $binds {
562 query = match bind {
563 ArchiveBind::Text(value) => query.bind(value.clone()),
564 ArchiveBind::Time(value) => query.bind(*value),
565 ArchiveBind::Bool(value) => query.bind(*value),
566 };
567 }
568 query
569 }};
570}
571
572const ARCHIVED_JOB_COLUMNS: &str = "id, queue_name, status, created_at, archived_at, \
574 archival_reason, original_payload_size, payload_compressed, archived_by";
575
576fn archived_status(value: &str) -> JobStatus {
579 let value = value.trim_matches('"');
580 [
581 JobStatus::Pending,
582 JobStatus::Running,
583 JobStatus::Completed,
584 JobStatus::Failed,
585 JobStatus::Dead,
586 JobStatus::TimedOut,
587 JobStatus::Retrying,
588 JobStatus::Archived,
589 ]
590 .into_iter()
591 .find(|status| status.as_str() == value)
592 .unwrap_or(JobStatus::Dead)
593}
594
595pub(crate) fn decodable<T>(rows: impl IntoIterator<Item = (String, Result<T>)>) -> Vec<T> {
599 rows.into_iter()
600 .filter_map(|(id, decoded)| match decoded {
601 Ok(value) => Some(value),
602 Err(error) => {
603 tracing::warn!(job_id = %id, error = %error, "Skipping an undecodable row");
604 None
605 }
606 })
607 .collect()
608}
609
610fn archived_job<R>(row: &R, id: uuid::Uuid) -> Result<ArchivedJob>
612where
613 R: Row,
614 for<'a> &'a str: sqlx::ColumnIndex<R>,
615 for<'a> String: sqlx::Decode<'a, R::Database> + sqlx::Type<R::Database>,
616 for<'a> Option<String>: sqlx::Decode<'a, R::Database> + sqlx::Type<R::Database>,
617 for<'a> Option<i32>: sqlx::Decode<'a, R::Database> + sqlx::Type<R::Database>,
618 for<'a> bool: sqlx::Decode<'a, R::Database> + sqlx::Type<R::Database>,
619 for<'a> DateTime<Utc>: sqlx::Decode<'a, R::Database> + sqlx::Type<R::Database>,
620{
621 Ok(ArchivedJob {
622 id,
623 queue_name: row.try_get("queue_name")?,
624 status: archived_status(&row.try_get::<String, _>("status")?),
625 created_at: row.try_get("created_at")?,
626 archived_at: row.try_get("archived_at")?,
627 archival_reason: ArchivalReason::parse_from_db(
628 &row.try_get::<String, _>("archival_reason")?,
629 )
630 .unwrap_or_default(),
631 original_payload_size: row
632 .try_get::<Option<i32>, _>("original_payload_size")?
633 .and_then(|size| usize::try_from(size).ok()),
634 payload_compressed: row.try_get("payload_compressed")?,
635 archived_by: row.try_get("archived_by")?,
636 })
637}
638
639fn finished_filter(kind: FinishedKind, queue: bool, cutoff: bool, postgres: bool) -> String {
641 let mut n = 0;
642 let mut ph = || {
643 n += 1;
644 if postgres {
645 format!("${n}")
646 } else {
647 "?".to_string()
648 }
649 };
650 let mut sql = format!("WHERE status = '{}'", kind.status());
651 if queue {
652 sql.push_str(&format!(" AND queue_name = {}", ph()));
653 }
654 if cutoff {
655 sql.push_str(&format!(" AND {} < {}", kind.age_column(), ph()));
656 }
657 sql
658}
659
660impl JobHistory for JobQueue<sqlx::Postgres> {
661 async fn hourly_activity(
662 &self,
663 queue: Option<&str>,
664 start: DateTime<Utc>,
665 end: DateTime<Utc>,
666 ) -> Result<Vec<HourBucket>> {
667 let hours = hour_range(start, end);
668 let (Some(first), Some(last)) = (hours.first().copied(), hours.last().copied()) else {
669 return Ok(Vec::new());
670 };
671 let upper = last + Duration::hours(1);
672 let queue_clause = if queue.is_some() {
673 " AND queue_name = $3"
674 } else {
675 ""
676 };
677
678 let completed_sql = format!(
679 "SELECT date_trunc('hour', completed_at AT TIME ZONE 'UTC') AS bucket, \
680 COUNT(*)::bigint AS n, \
681 AVG(EXTRACT(EPOCH FROM (completed_at - started_at)) * 1000)::float8 AS avg_ms \
682 FROM hammerwork_jobs WHERE status = 'Completed' \
683 AND completed_at >= $1 AND completed_at < $2{queue_clause} GROUP BY 1"
684 );
685 let mut q = sqlx::query(&completed_sql).bind(first).bind(upper);
686 if let Some(name) = queue {
687 q = q.bind(name.to_string());
688 }
689 let mut completed = BTreeMap::new();
690 for row in q.fetch_all(&self.pool).await? {
691 let bucket: NaiveDateTime = row.try_get("bucket")?;
692 let n: i64 = row.try_get("n")?;
693 let avg: Option<f64> = row.try_get("avg_ms")?;
694 completed.insert(bucket.and_utc(), (to_u64(n), avg));
695 }
696
697 let failed_sql = format!(
698 "SELECT date_trunc('hour', COALESCE(failed_at, timed_out_at) AT TIME ZONE 'UTC') AS bucket, \
699 COUNT(*)::bigint AS n FROM hammerwork_jobs \
700 WHERE status IN ('Failed', 'Dead', 'TimedOut') \
701 AND COALESCE(failed_at, timed_out_at) >= $1 AND COALESCE(failed_at, timed_out_at) < $2{queue_clause} \
702 GROUP BY 1"
703 );
704 let mut q = sqlx::query(&failed_sql).bind(first).bind(upper);
705 if let Some(name) = queue {
706 q = q.bind(name.to_string());
707 }
708 let mut failed = BTreeMap::new();
709 for row in q.fetch_all(&self.pool).await? {
710 let bucket: NaiveDateTime = row.try_get("bucket")?;
711 let n: i64 = row.try_get("n")?;
712 failed.insert(bucket.and_utc(), to_u64(n));
713 }
714
715 Ok(fill_buckets(&hours, &completed, &failed))
716 }
717
718 async fn error_groups(&self, since: DateTime<Utc>, limit: u32) -> Result<Vec<ErrorGroup>> {
719 let rows = sqlx::query(
720 "SELECT queue_name, error_message, COUNT(*)::bigint AS n, \
721 MIN(COALESCE(failed_at, timed_out_at)) AS first_seen, \
722 MAX(COALESCE(failed_at, timed_out_at)) AS last_seen \
723 FROM hammerwork_jobs \
724 WHERE status IN ('Failed', 'Dead', 'TimedOut') AND error_message IS NOT NULL \
725 AND COALESCE(failed_at, timed_out_at) >= $1 \
726 GROUP BY queue_name, error_message ORDER BY n DESC LIMIT $2",
727 )
728 .bind(since)
729 .bind(i64::from(limit))
730 .fetch_all(&self.pool)
731 .await?;
732 let mut groups = Vec::with_capacity(rows.len());
733 for row in rows {
734 groups.push(ErrorGroup {
735 queue_name: row.try_get("queue_name")?,
736 message: row.try_get("error_message")?,
737 count: to_u64(row.try_get("n")?),
738 first_seen: row.try_get("first_seen")?,
739 last_seen: row.try_get("last_seen")?,
740 });
741 }
742 Ok(groups)
743 }
744
745 async fn count_jobs(
746 &self,
747 queue: Option<&str>,
748 kind: FinishedKind,
749 older_than: Option<DateTime<Utc>>,
750 ) -> Result<u64> {
751 let sql = format!(
752 "SELECT COUNT(*)::bigint FROM hammerwork_jobs {}",
753 finished_filter(kind, queue.is_some(), older_than.is_some(), true)
754 );
755 let mut q = sqlx::query_scalar::<_, i64>(&sql);
756 if let Some(name) = queue {
757 q = q.bind(name.to_string());
758 }
759 if let Some(cutoff) = older_than {
760 q = q.bind(cutoff);
761 }
762 Ok(to_u64(q.fetch_one(&self.pool).await?))
763 }
764
765 async fn count_archivable(
766 &self,
767 queue: Option<&str>,
768 policy: &hammerwork::archive::ArchivalPolicy,
769 ) -> Result<u64> {
770 let Some((sql, cutoffs)) =
771 archivable_count_query(policy, queue.is_some(), Utc::now(), true)
772 else {
773 return Ok(0);
774 };
775 let mut q = sqlx::query_scalar::<_, i64>(&sql);
776 if let Some(name) = queue {
777 q = q.bind(name.to_string());
778 }
779 for cutoff in cutoffs {
780 q = q.bind(cutoff);
781 }
782 Ok(to_u64(q.fetch_one(&self.pool).await?))
783 }
784
785 async fn delete_jobs(
786 &self,
787 queue: Option<&str>,
788 kind: FinishedKind,
789 older_than: Option<DateTime<Utc>>,
790 ) -> Result<u64> {
791 let sql = format!(
792 "DELETE FROM hammerwork_jobs {}",
793 finished_filter(kind, queue.is_some(), older_than.is_some(), true)
794 );
795 let mut q = sqlx::query(&sql);
796 if let Some(name) = queue {
797 q = q.bind(name.to_string());
798 }
799 if let Some(cutoff) = older_than {
800 q = q.bind(cutoff);
801 }
802 Ok(q.execute(&self.pool).await?.rows_affected())
803 }
804
805 async fn archived_jobs(
806 &self,
807 filter: &ArchiveFilterParams,
808 limit: u32,
809 offset: u32,
810 ) -> Result<(Vec<ArchivedJob>, u64)> {
811 let (conditions, binds) = archive_conditions(filter, true);
812 let count_sql = format!("SELECT COUNT(*) FROM hammerwork_jobs_archive {conditions}");
813 let total = bind_archive_filter!(sqlx::query_scalar::<_, i64>(&count_sql), &binds)
814 .fetch_one(&self.pool)
815 .await?;
816 let (limit_ph, offset_ph) = (
817 format!("${}", binds.len() + 1),
818 format!("${}", binds.len() + 2),
819 );
820 let page_sql = format!(
821 "SELECT {ARCHIVED_JOB_COLUMNS} FROM hammerwork_jobs_archive {conditions} \
822 ORDER BY archived_at DESC, id DESC LIMIT {limit_ph} OFFSET {offset_ph}"
823 );
824 let rows = bind_archive_filter!(sqlx::query(&page_sql), &binds)
825 .bind(i64::from(limit))
826 .bind(i64::from(offset))
827 .fetch_all(&self.pool)
828 .await?;
829 let jobs = decodable(rows.iter().map(|row| {
830 let id = row.try_get::<uuid::Uuid, _>("id");
831 let label = id.as_ref().map(|id| id.to_string()).unwrap_or_default();
832 (
833 label,
834 id.map_err(Into::into).and_then(|id| archived_job(row, id)),
835 )
836 }));
837 Ok((jobs, to_u64(total)))
838 }
839
840 async fn database_now(&self) -> Result<DateTime<Utc>> {
841 Ok(sqlx::query_scalar("SELECT NOW()")
842 .fetch_one(&self.pool)
843 .await?)
844 }
845
846 async fn recent_job_changes(&self, since: DateTime<Utc>, limit: u32) -> Result<Vec<JobUpdate>> {
847 let sql = format!(
849 "SELECT id, queue_name, status, priority, attempts, GREATEST({}) AS changed_at \
850 FROM hammerwork_jobs WHERE {} ORDER BY changed_at DESC LIMIT $2",
851 CHANGE_COLUMNS.join(", "),
852 changed_since_filter(true)
853 );
854 let rows = sqlx::query(&sql)
855 .bind(since)
856 .bind(i64::from(limit))
857 .fetch_all(&self.pool)
858 .await?;
859 Ok(pg_job_changes(&rows))
860 }
861
862 async fn running_jobs(&self, limit: u32) -> Result<Vec<JobUpdate>> {
863 let rows = sqlx::query(
864 "SELECT id, queue_name, status, priority, attempts, \
865 COALESCE(started_at, created_at) AS changed_at FROM hammerwork_jobs \
866 WHERE status = 'Running' ORDER BY started_at DESC LIMIT $1",
867 )
868 .bind(i64::from(limit))
869 .fetch_all(&self.pool)
870 .await?;
871 Ok(pg_job_changes(&rows))
872 }
873
874 async fn job_states(&self, ids: &[String]) -> Result<Vec<JobUpdate>> {
875 let ids: Vec<uuid::Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
876 if ids.is_empty() {
877 return Ok(Vec::new());
878 }
879 let sql = format!(
880 "SELECT id, queue_name, status, priority, attempts, GREATEST({}) AS changed_at \
881 FROM hammerwork_jobs WHERE id = ANY($1)",
882 CHANGE_COLUMNS.join(", ")
883 );
884 let rows = sqlx::query(&sql).bind(&ids).fetch_all(&self.pool).await?;
885 Ok(pg_job_changes(&rows))
886 }
887
888 async fn table_maintenance(
889 &self,
890 operation: TableMaintenance,
891 tables: &[&'static str],
892 dry_run: bool,
893 ) -> Result<Vec<MaintenanceStatement>> {
894 let names: Vec<String> = tables.iter().map(|t| t.to_string()).collect();
895 let existing: Vec<String> = sqlx::query_scalar(
896 "SELECT tablename::text FROM pg_tables \
897 WHERE schemaname = current_schema() AND tablename = ANY($1)",
898 )
899 .bind(&names)
900 .fetch_all(&self.pool)
901 .await?;
902 let mut ran = Vec::new();
903 for table in present_tables(tables, &existing) {
904 let statement = operation.postgres_statement(table);
905 if !dry_run {
906 sqlx::raw_sql(&statement).execute(&self.pool).await?;
908 }
909 ran.push(MaintenanceStatement {
910 table,
911 statement,
912 messages: Vec::new(),
913 });
914 }
915 Ok(ran)
916 }
917}
918
919impl JobHistory for JobQueue<sqlx::MySql> {
920 async fn hourly_activity(
921 &self,
922 queue: Option<&str>,
923 start: DateTime<Utc>,
924 end: DateTime<Utc>,
925 ) -> Result<Vec<HourBucket>> {
926 let hours = hour_range(start, end);
927 let (Some(first), Some(last)) = (hours.first().copied(), hours.last().copied()) else {
928 return Ok(Vec::new());
929 };
930 let upper = last + Duration::hours(1);
931 let queue_clause = if queue.is_some() {
932 " AND queue_name = ?"
933 } else {
934 ""
935 };
936
937 let completed_sql = format!(
938 "SELECT DATE_FORMAT(completed_at, '%Y-%m-%d %H:00:00') AS bucket, \
939 CAST(COUNT(*) AS SIGNED) AS n, \
940 CAST(AVG(TIMESTAMPDIFF(MICROSECOND, started_at, completed_at)) / 1000 AS DOUBLE) AS avg_ms \
941 FROM hammerwork_jobs WHERE status = 'Completed' \
942 AND completed_at >= ? AND completed_at < ?{queue_clause} GROUP BY bucket"
943 );
944 let mut q = sqlx::query(&completed_sql).bind(first).bind(upper);
945 if let Some(name) = queue {
946 q = q.bind(name.to_string());
947 }
948 let mut completed = BTreeMap::new();
949 for row in q.fetch_all(&self.pool).await? {
950 let bucket: String = row.try_get("bucket")?;
951 let n: i64 = row.try_get("n")?;
952 let avg: Option<f64> = row.try_get("avg_ms")?;
953 if let Some(hour) = parse_bucket(&bucket) {
954 completed.insert(hour, (to_u64(n), avg));
955 }
956 }
957
958 let failed_sql = format!(
959 "SELECT DATE_FORMAT(COALESCE(failed_at, timed_out_at), '%Y-%m-%d %H:00:00') AS bucket, \
960 CAST(COUNT(*) AS SIGNED) AS n FROM hammerwork_jobs \
961 WHERE status IN ('Failed', 'Dead', 'TimedOut') \
962 AND COALESCE(failed_at, timed_out_at) >= ? AND COALESCE(failed_at, timed_out_at) < ?{queue_clause} \
963 GROUP BY bucket"
964 );
965 let mut q = sqlx::query(&failed_sql).bind(first).bind(upper);
966 if let Some(name) = queue {
967 q = q.bind(name.to_string());
968 }
969 let mut failed = BTreeMap::new();
970 for row in q.fetch_all(&self.pool).await? {
971 let bucket: String = row.try_get("bucket")?;
972 let n: i64 = row.try_get("n")?;
973 if let Some(hour) = parse_bucket(&bucket) {
974 failed.insert(hour, to_u64(n));
975 }
976 }
977
978 Ok(fill_buckets(&hours, &completed, &failed))
979 }
980
981 async fn error_groups(&self, since: DateTime<Utc>, limit: u32) -> Result<Vec<ErrorGroup>> {
982 let rows = sqlx::query(
983 "SELECT queue_name, error_message, CAST(COUNT(*) AS SIGNED) AS n, \
984 MIN(COALESCE(failed_at, timed_out_at)) AS first_seen, \
985 MAX(COALESCE(failed_at, timed_out_at)) AS last_seen \
986 FROM hammerwork_jobs \
987 WHERE status IN ('Failed', 'Dead', 'TimedOut') AND error_message IS NOT NULL \
988 AND COALESCE(failed_at, timed_out_at) >= ? \
989 GROUP BY queue_name, error_message ORDER BY n DESC LIMIT ?",
990 )
991 .bind(since)
992 .bind(i64::from(limit))
993 .fetch_all(&self.pool)
994 .await?;
995 let mut groups = Vec::with_capacity(rows.len());
996 for row in rows {
997 groups.push(ErrorGroup {
998 queue_name: row.try_get("queue_name")?,
999 message: row.try_get("error_message")?,
1000 count: to_u64(row.try_get("n")?),
1001 first_seen: row.try_get("first_seen")?,
1002 last_seen: row.try_get("last_seen")?,
1003 });
1004 }
1005 Ok(groups)
1006 }
1007
1008 async fn count_jobs(
1009 &self,
1010 queue: Option<&str>,
1011 kind: FinishedKind,
1012 older_than: Option<DateTime<Utc>>,
1013 ) -> Result<u64> {
1014 let sql = format!(
1015 "SELECT CAST(COUNT(*) AS SIGNED) FROM hammerwork_jobs {}",
1016 finished_filter(kind, queue.is_some(), older_than.is_some(), false)
1017 );
1018 let mut q = sqlx::query_scalar::<_, i64>(&sql);
1019 if let Some(name) = queue {
1020 q = q.bind(name.to_string());
1021 }
1022 if let Some(cutoff) = older_than {
1023 q = q.bind(cutoff);
1024 }
1025 Ok(to_u64(q.fetch_one(&self.pool).await?))
1026 }
1027
1028 async fn count_archivable(
1029 &self,
1030 queue: Option<&str>,
1031 policy: &hammerwork::archive::ArchivalPolicy,
1032 ) -> Result<u64> {
1033 let Some((sql, cutoffs)) =
1034 archivable_count_query(policy, queue.is_some(), Utc::now(), false)
1035 else {
1036 return Ok(0);
1037 };
1038 let mut q = sqlx::query_scalar::<_, i64>(&sql);
1039 if let Some(name) = queue {
1040 q = q.bind(name.to_string());
1041 }
1042 for cutoff in cutoffs {
1043 q = q.bind(cutoff);
1044 }
1045 Ok(to_u64(q.fetch_one(&self.pool).await?))
1046 }
1047
1048 async fn delete_jobs(
1049 &self,
1050 queue: Option<&str>,
1051 kind: FinishedKind,
1052 older_than: Option<DateTime<Utc>>,
1053 ) -> Result<u64> {
1054 let sql = format!(
1055 "DELETE FROM hammerwork_jobs {}",
1056 finished_filter(kind, queue.is_some(), older_than.is_some(), false)
1057 );
1058 let mut q = sqlx::query(&sql);
1059 if let Some(name) = queue {
1060 q = q.bind(name.to_string());
1061 }
1062 if let Some(cutoff) = older_than {
1063 q = q.bind(cutoff);
1064 }
1065 Ok(q.execute(&self.pool).await?.rows_affected())
1066 }
1067
1068 async fn archived_jobs(
1069 &self,
1070 filter: &ArchiveFilterParams,
1071 limit: u32,
1072 offset: u32,
1073 ) -> Result<(Vec<ArchivedJob>, u64)> {
1074 let (conditions, binds) = archive_conditions(filter, false);
1075 let count_sql = format!("SELECT COUNT(*) FROM hammerwork_jobs_archive {conditions}");
1076 let total = bind_archive_filter!(sqlx::query_scalar::<_, i64>(&count_sql), &binds)
1077 .fetch_one(&self.pool)
1078 .await?;
1079 let page_sql = format!(
1080 "SELECT {ARCHIVED_JOB_COLUMNS} FROM hammerwork_jobs_archive {conditions} \
1081 ORDER BY archived_at DESC, id DESC LIMIT ? OFFSET ?"
1082 );
1083 let rows = bind_archive_filter!(sqlx::query(&page_sql), &binds)
1084 .bind(i64::from(limit))
1085 .bind(i64::from(offset))
1086 .fetch_all(&self.pool)
1087 .await?;
1088 let jobs = decodable(rows.iter().map(|row| {
1089 let label = row.try_get::<String, _>("id").unwrap_or_default();
1090 let job = uuid::Uuid::parse_str(&label)
1091 .map_err(Into::into)
1092 .and_then(|id| archived_job(row, id));
1093 (label, job)
1094 }));
1095 Ok((jobs, to_u64(total)))
1096 }
1097
1098 async fn database_now(&self) -> Result<DateTime<Utc>> {
1099 Ok(sqlx::query_scalar("SELECT UTC_TIMESTAMP(6)")
1100 .fetch_one(&self.pool)
1101 .await?)
1102 }
1103
1104 async fn recent_job_changes(&self, since: DateTime<Utc>, limit: u32) -> Result<Vec<JobUpdate>> {
1105 let sql = format!(
1106 "SELECT id, queue_name, status, priority, attempts, {} AS changed_at \
1107 FROM hammerwork_jobs WHERE {} ORDER BY changed_at DESC LIMIT ?",
1108 mysql_latest_change(),
1109 changed_since_filter(false)
1110 );
1111 let mut query = sqlx::query(&sql);
1112 for _ in CHANGE_COLUMNS {
1113 query = query.bind(since);
1114 }
1115 let rows = query.bind(i64::from(limit)).fetch_all(&self.pool).await?;
1116 Ok(mysql_job_changes(&rows))
1117 }
1118
1119 async fn running_jobs(&self, limit: u32) -> Result<Vec<JobUpdate>> {
1120 let rows = sqlx::query(
1121 "SELECT id, queue_name, status, priority, attempts, \
1122 CAST(COALESCE(started_at, created_at) AS DATETIME(6)) AS changed_at \
1123 FROM hammerwork_jobs WHERE status = 'Running' ORDER BY started_at DESC LIMIT ?",
1124 )
1125 .bind(i64::from(limit))
1126 .fetch_all(&self.pool)
1127 .await?;
1128 Ok(mysql_job_changes(&rows))
1129 }
1130
1131 async fn job_states(&self, ids: &[String]) -> Result<Vec<JobUpdate>> {
1132 let ids: Vec<String> = ids
1133 .iter()
1134 .filter_map(|id| id.parse::<uuid::Uuid>().ok())
1135 .map(|id| id.to_string())
1136 .collect();
1137 if ids.is_empty() {
1138 return Ok(Vec::new());
1139 }
1140 let sql = format!(
1141 "SELECT id, queue_name, status, priority, attempts, {} AS changed_at \
1142 FROM hammerwork_jobs WHERE id IN ({})",
1143 mysql_latest_change(),
1144 vec!["?"; ids.len()].join(", ")
1145 );
1146 let mut query = sqlx::query(&sql);
1147 for id in &ids {
1148 query = query.bind(id);
1149 }
1150 let rows = query.fetch_all(&self.pool).await?;
1151 Ok(mysql_job_changes(&rows))
1152 }
1153 async fn table_maintenance(
1154 &self,
1155 operation: TableMaintenance,
1156 tables: &[&'static str],
1157 dry_run: bool,
1158 ) -> Result<Vec<MaintenanceStatement>> {
1159 if tables.is_empty() {
1160 return Ok(Vec::new());
1161 }
1162 let placeholders = vec!["?"; tables.len()].join(", ");
1163 let sql = format!(
1164 "SELECT CAST(TABLE_NAME AS CHAR) FROM information_schema.TABLES \
1165 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({placeholders})"
1166 );
1167 let mut q = sqlx::query_scalar::<_, String>(&sql);
1168 for table in tables {
1169 q = q.bind(*table);
1170 }
1171 let existing = q.fetch_all(&self.pool).await?;
1172 let mut ran = Vec::new();
1173 for table in present_tables(tables, &existing) {
1174 let statement = operation.mysql_statement(table);
1175 let mut messages = Vec::new();
1176 if !dry_run {
1177 for row in sqlx::raw_sql(&statement).fetch_all(&self.pool).await? {
1179 let kind = text_column(&row, "Msg_type");
1180 let text = text_column(&row, "Msg_text");
1181 if kind.eq_ignore_ascii_case("error") {
1182 return Err(hammerwork::HammerworkError::Queue {
1183 message: format!("{statement} failed: {text}"),
1184 });
1185 }
1186 messages.push(format!("{kind}: {text}"));
1187 }
1188 }
1189 ran.push(MaintenanceStatement {
1190 table,
1191 statement,
1192 messages,
1193 });
1194 }
1195 Ok(ran)
1196 }
1197}
1198
1199fn text_column(row: &sqlx::mysql::MySqlRow, name: &str) -> String {
1201 row.try_get::<String, _>(name)
1202 .or_else(|_| {
1203 row.try_get::<Vec<u8>, _>(name)
1204 .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
1205 })
1206 .unwrap_or_default()
1207}
1208
1209fn parse_bucket(bucket: &str) -> Option<DateTime<Utc>> {
1210 NaiveDateTime::parse_from_str(bucket, "%Y-%m-%d %H:%M:%S")
1211 .ok()
1212 .map(|t| t.and_utc())
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217 use super::*;
1218
1219 fn at(h: u32, m: u32) -> DateTime<Utc> {
1220 Utc.with_ymd_and_hms(2026, 1, 2, h, m, 0).unwrap()
1221 }
1222
1223 #[test]
1225 fn undecodable_rows_are_skipped() {
1226 let bad: Result<u8> = Err(hammerwork::HammerworkError::Processing("bad".into()));
1227 let rows = vec![("a".to_string(), Ok(1u8)), ("b".to_string(), bad)];
1228 assert_eq!(decodable(rows), [1]);
1229 }
1230
1231 #[test]
1232 fn job_changes_normalise_status_and_priority() {
1233 let at = Utc::now();
1234 let change = job_change("id".into(), "q".into(), "\"Running\"", 3, 2, at);
1235 assert_eq!(change.status, "Running");
1236 assert_eq!(change.priority, "high");
1237 assert_eq!(change.attempts, 2);
1238 assert_eq!(change.updated_at, at);
1239 assert_eq!(
1240 job_change("id".into(), "q".into(), "Pending", 99, 0, at).priority,
1241 "normal"
1242 );
1243 assert_eq!(
1244 changed_since_filter(true),
1245 "created_at > $1 OR started_at > $1 OR completed_at > $1 OR failed_at > $1 \
1246 OR timed_out_at > $1"
1247 );
1248 assert!(changed_since_filter(false).starts_with("created_at > ? OR started_at > ?"));
1249 }
1250
1251 #[test]
1253 fn archivable_count_query_mirrors_the_archiver() {
1254 use hammerwork::archive::ArchivalPolicy;
1255 let now = Utc::now();
1256 let policy = ArchivalPolicy::new()
1257 .archive_completed_after(Duration::days(1))
1258 .with_batch_size(50);
1259 let (sql, cutoffs) = archivable_count_query(&policy, true, now, true).unwrap();
1260 assert!(
1261 sql.contains("archived_at IS NULL AND queue_name = $1"),
1262 "{sql}"
1263 );
1264 assert!(
1265 sql.contains(
1266 "(status = 'Completed' AND completed_at IS NOT NULL AND completed_at <= $2)"
1267 ),
1268 "{sql}"
1269 );
1270 assert!(sql.ends_with("LIMIT 50) candidates"), "{sql}");
1271 assert_eq!(cutoffs.len(), 4, "every status of the default policy");
1272 assert_eq!(cutoffs[0], now - Duration::days(1));
1273
1274 let (sql, _) = archivable_count_query(&policy, false, now, false).unwrap();
1275 assert!(!sql.contains('$') && !sql.contains("queue_name"), "{sql}");
1276
1277 let mut disabled = policy.clone();
1278 disabled.enabled = false;
1279 assert!(archivable_count_query(&disabled, false, now, true).is_none());
1280 let mut nothing = ArchivalPolicy::new();
1281 nothing.archive_completed_after = None;
1282 nothing.archive_failed_after = None;
1283 nothing.archive_dead_after = None;
1284 nothing.archive_timed_out_after = None;
1285 assert!(archivable_count_query(¬hing, false, now, true).is_none());
1286 }
1287
1288 #[test]
1289 fn test_hour_floor_and_range() {
1290 assert_eq!(
1291 hour_floor(at(5, 59)),
1292 Utc.with_ymd_and_hms(2026, 1, 2, 5, 0, 0).unwrap()
1293 );
1294 let hours = hour_range(at(5, 10), at(8, 0));
1295 assert_eq!(hours.len(), 3);
1296 assert_eq!(hours[2], Utc.with_ymd_and_hms(2026, 1, 2, 7, 0, 0).unwrap());
1297 assert!(hour_range(at(8, 0), at(5, 0)).is_empty());
1298 }
1299
1300 #[test]
1301 fn test_hour_range_is_capped() {
1302 let end = at(0, 0);
1303 let hours = hour_range(end - Duration::days(400), end);
1304 assert_eq!(hours.len() as i64, MAX_BUCKETS);
1305 }
1306
1307 #[test]
1308 fn test_fill_buckets_zero_fills() {
1309 let hours = hour_range(at(5, 0), at(8, 0));
1310 let mut completed = BTreeMap::new();
1311 completed.insert(hours[0], (4, Some(12.5)));
1312 let mut failed = BTreeMap::new();
1313 failed.insert(hours[2], 2);
1314 let buckets = fill_buckets(&hours, &completed, &failed);
1315 assert_eq!(buckets.len(), 3);
1316 assert_eq!((buckets[0].completed, buckets[0].failed), (4, 0));
1317 assert_eq!(buckets[0].avg_processing_time_ms, Some(12.5));
1318 assert_eq!((buckets[1].completed, buckets[1].failed), (0, 0));
1319 assert_eq!(buckets[1].avg_processing_time_ms, None);
1320 assert_eq!((buckets[2].completed, buckets[2].failed), (0, 2));
1321 }
1322
1323 #[test]
1325 fn archive_filters_become_sql_conditions() {
1326 let (sql, binds) = archive_conditions(&ArchiveFilterParams::default(), true);
1327 assert_eq!(sql, "");
1328 assert!(binds.is_empty());
1329
1330 let at = Utc.with_ymd_and_hms(2024, 5, 1, 0, 0, 0).unwrap();
1331 let filter = ArchiveFilterParams {
1332 queue: Some("emails".into()),
1333 reason: Some("Manual".into()),
1334 archived_after: Some(at),
1335 archived_before: Some(at),
1336 archived_by: Some("ops".into()),
1337 compressed: Some(true),
1338 original_status: Some("COMPLETED".into()),
1339 };
1340 let (pg, binds) = archive_conditions(&filter, true);
1341 assert_eq!(
1342 pg,
1343 "WHERE queue_name = $1 AND LOWER(TRIM(BOTH '\"' FROM archival_reason)) = $2 \
1344 AND archived_at >= $3 AND archived_at <= $4 AND archived_by = $5 \
1345 AND payload_compressed = $6 AND LOWER(TRIM(BOTH '\"' FROM status)) = $7"
1346 );
1347 assert_eq!(
1348 binds,
1349 vec![
1350 ArchiveBind::Text("emails".into()),
1351 ArchiveBind::Text("manual".into()),
1352 ArchiveBind::Time(at),
1353 ArchiveBind::Time(at),
1354 ArchiveBind::Text("ops".into()),
1355 ArchiveBind::Bool(true),
1356 ArchiveBind::Text("completed".into()),
1357 ]
1358 );
1359 let (mysql, _) = archive_conditions(&filter, false);
1360 assert_eq!(mysql.matches('?').count(), 7);
1361 assert!(!mysql.contains('$'));
1362 }
1363
1364 #[test]
1365 fn archived_statuses_parse_like_the_core_library() {
1366 assert_eq!(archived_status("Completed"), JobStatus::Completed);
1367 assert_eq!(archived_status("\"TimedOut\""), JobStatus::TimedOut);
1368 assert_eq!(archived_status("Archived"), JobStatus::Archived);
1369 assert_eq!(archived_status("whatever"), JobStatus::Dead);
1370 }
1371
1372 #[test]
1373 fn test_finished_filter_placeholders() {
1374 let pg = finished_filter(FinishedKind::Completed, true, true, true);
1375 assert_eq!(
1376 pg,
1377 "WHERE status = 'Completed' AND queue_name = $1 AND completed_at < $2"
1378 );
1379 let my = finished_filter(FinishedKind::Dead, false, true, false);
1380 assert!(my.contains("status = 'Dead'"));
1381 assert!(my.ends_with("< ?"));
1382 assert!(!my.contains("queue_name"));
1383 }
1384
1385 #[test]
1386 fn test_parse_bucket() {
1387 assert_eq!(
1388 parse_bucket("2026-01-02 05:00:00"),
1389 Some(Utc.with_ymd_and_hms(2026, 1, 2, 5, 0, 0).unwrap())
1390 );
1391 assert_eq!(parse_bucket("garbage"), None);
1392 }
1393
1394 #[test]
1395 fn test_table_maintenance_statements() {
1396 assert_eq!(
1397 TableMaintenance::parse("vacuum"),
1398 Some(TableMaintenance::Vacuum)
1399 );
1400 assert_eq!(
1401 TableMaintenance::parse("reindex"),
1402 Some(TableMaintenance::Reindex)
1403 );
1404 assert_eq!(
1405 TableMaintenance::parse("optimize"),
1406 Some(TableMaintenance::Optimize)
1407 );
1408 assert_eq!(TableMaintenance::parse("VACUUM"), None);
1409 assert_eq!(TableMaintenance::parse("cleanup"), None);
1410
1411 let t = "hammerwork_jobs";
1412 assert_eq!(
1413 TableMaintenance::Vacuum.postgres_statement(t),
1414 "VACUUM (ANALYZE) hammerwork_jobs"
1415 );
1416 assert_eq!(
1417 TableMaintenance::Reindex.postgres_statement(t),
1418 "REINDEX TABLE hammerwork_jobs"
1419 );
1420 assert_eq!(
1421 TableMaintenance::Optimize.postgres_statement(t),
1422 "ANALYZE hammerwork_jobs"
1423 );
1424 for op in [
1425 TableMaintenance::Vacuum,
1426 TableMaintenance::Reindex,
1427 TableMaintenance::Optimize,
1428 ] {
1429 assert_eq!(op.mysql_statement(t), "OPTIMIZE TABLE hammerwork_jobs");
1430 }
1431 assert_eq!(
1432 serde_json::to_value(TableMaintenance::Reindex).unwrap(),
1433 "reindex"
1434 );
1435 }
1436
1437 #[test]
1438 fn test_maintenance_tables_only_hammerwork_tables() {
1439 assert_eq!(maintenance_tables(None).unwrap(), HAMMERWORK_TABLES);
1440 assert_eq!(
1441 maintenance_tables(Some("hammerwork_jobs_archive")).unwrap(),
1442 vec!["hammerwork_jobs_archive"]
1443 );
1444 for bad in [
1445 "users",
1446 "hammerwork_jobs; DROP TABLE x",
1447 "HAMMERWORK_JOBS",
1448 "",
1449 ] {
1450 assert!(maintenance_tables(Some(bad)).is_err(), "{bad}");
1451 }
1452 assert!(
1453 HAMMERWORK_TABLES
1454 .iter()
1455 .all(|t| t.starts_with("hammerwork_")
1456 && t.chars().all(|c| c.is_ascii_lowercase() || c == '_'))
1457 );
1458 }
1459
1460 #[test]
1461 fn test_present_tables_keeps_fixed_names_in_order() {
1462 let existing = vec!["hammerwork_batches".to_string(), "other".to_string()];
1463 assert_eq!(
1464 present_tables(&["hammerwork_jobs", "hammerwork_batches"], &existing),
1465 vec!["hammerwork_batches"]
1466 );
1467 assert!(present_tables(&["hammerwork_jobs"], &[]).is_empty());
1468 }
1469}
1470
1471#[cfg(test)]
1472mod db_tests {
1473 use super::*;
1474 use hammerwork::JobQueue;
1475
1476 struct Seed {
1477 queue: String,
1478 status: &'static str,
1479 started: Option<DateTime<Utc>>,
1480 completed: Option<DateTime<Utc>>,
1481 failed: Option<DateTime<Utc>>,
1482 error: Option<&'static str>,
1483 }
1484
1485 fn seeds(queue: &str, other: &str, now: DateTime<Utc>) -> Vec<Seed> {
1486 let base = hour_floor(now);
1487 let done = |q: &str, at: DateTime<Utc>| Seed {
1488 queue: q.to_string(),
1489 status: "Completed",
1490 started: Some(at - Duration::milliseconds(100)),
1491 completed: Some(at),
1492 failed: None,
1493 error: None,
1494 };
1495 vec![
1496 done(queue, base - Duration::hours(2) + Duration::minutes(5)),
1497 done(queue, base - Duration::hours(2) + Duration::minutes(30)),
1498 done(other, base - Duration::hours(2) + Duration::minutes(10)),
1499 Seed {
1500 queue: queue.to_string(),
1501 status: "Dead",
1502 started: None,
1503 completed: None,
1504 failed: Some(base - Duration::hours(1) + Duration::minutes(15)),
1505 error: Some("connection refused"),
1506 },
1507 ]
1508 }
1509
1510 async fn assert_history<Q: JobHistory>(q: &Q, queue: &str, other: &str, now: DateTime<Utc>) {
1511 let base = hour_floor(now);
1512 let buckets = q
1513 .hourly_activity(Some(queue), base - Duration::hours(3), now)
1514 .await
1515 .unwrap();
1516 assert_eq!(buckets.len(), 4, "3h ago .. current hour, zero filled");
1517 assert_eq!((buckets[0].completed, buckets[0].failed), (0, 0));
1518 assert_eq!((buckets[1].completed, buckets[1].failed), (2, 0));
1519 let avg = buckets[1].avg_processing_time_ms.expect("avg");
1520 assert!((avg - 100.0).abs() < 5.0, "avg was {avg}");
1521 assert_eq!((buckets[2].completed, buckets[2].failed), (0, 1));
1522 assert_eq!(buckets[2].avg_processing_time_ms, None);
1523 assert_eq!(buckets[1].hour, base - Duration::hours(2));
1524
1525 let all = q
1527 .hourly_activity(None, base - Duration::hours(3), now)
1528 .await
1529 .unwrap();
1530 assert!(all[1].completed >= 3);
1531
1532 let groups = q.error_groups(now - Duration::hours(5), 500).await.unwrap();
1533 let ours: Vec<_> = groups.iter().filter(|g| g.queue_name == queue).collect();
1534 assert_eq!(ours.len(), 1);
1535 assert_eq!(ours[0].message, "connection refused");
1536 assert_eq!(ours[0].count, 1);
1537 assert_eq!(
1538 ours[0].first_seen.timestamp(),
1539 (base - Duration::hours(1) + Duration::minutes(15)).timestamp()
1540 );
1541 assert_eq!(ours[0].last_seen, ours[0].first_seen);
1542
1543 let completed = FinishedKind::Completed;
1545 let dead = FinishedKind::Dead;
1546 assert_eq!(q.count_jobs(Some(queue), completed, None).await.unwrap(), 2);
1547 assert_eq!(q.count_jobs(Some(queue), dead, None).await.unwrap(), 1);
1548 let week_ago = Some(now - Duration::days(7));
1549 assert_eq!(q.count_jobs(Some(queue), dead, week_ago).await.unwrap(), 0);
1550 assert_eq!(
1551 q.delete_jobs(Some(queue), completed, None).await.unwrap(),
1552 2
1553 );
1554 assert_eq!(q.count_jobs(Some(queue), completed, None).await.unwrap(), 0);
1555 assert_eq!(q.count_jobs(Some(queue), dead, None).await.unwrap(), 1);
1556 assert_eq!(q.count_jobs(Some(other), completed, None).await.unwrap(), 1);
1557 }
1558
1559 #[tokio::test]
1560 #[ignore = "requires DATABASE_URL (PostgreSQL)"]
1561 async fn test_job_history_queries_postgres() {
1562 let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
1563 let pool = sqlx::PgPool::connect(&url).await.unwrap();
1564 let tag = uuid::Uuid::new_v4().simple().to_string();
1565 let (queue, other) = (format!("hist_a_{tag}"), format!("hist_b_{tag}"));
1566 let now = Utc::now();
1567 for s in seeds(&queue, &other, now) {
1568 sqlx::query(
1569 "INSERT INTO hammerwork_jobs (id, queue_name, payload, status, priority, attempts, \
1570 max_attempts, created_at, scheduled_at, started_at, completed_at, failed_at, error_message) \
1571 VALUES ($1, $2, '{}'::jsonb, $3, 2, 1, 3, NOW(), NOW(), $4, $5, $6, $7)",
1572 )
1573 .bind(uuid::Uuid::new_v4())
1574 .bind(&s.queue)
1575 .bind(s.status)
1576 .bind(s.started)
1577 .bind(s.completed)
1578 .bind(s.failed)
1579 .bind(s.error)
1580 .execute(&pool)
1581 .await
1582 .unwrap();
1583 }
1584 let jq = JobQueue::<sqlx::Postgres>::new(pool.clone());
1585 assert_history(&jq, &queue, &other, now).await;
1586 for name in [&queue, &other] {
1587 sqlx::query("DELETE FROM hammerwork_jobs WHERE queue_name = $1")
1588 .bind(name)
1589 .execute(&pool)
1590 .await
1591 .unwrap();
1592 }
1593 }
1594
1595 #[tokio::test]
1596 #[ignore = "requires MYSQL_DATABASE_URL"]
1597 async fn test_job_history_queries_mysql() {
1598 let url = std::env::var("MYSQL_DATABASE_URL").expect("MYSQL_DATABASE_URL");
1599 let pool = sqlx::MySqlPool::connect(&url).await.unwrap();
1600 let tag = uuid::Uuid::new_v4().simple().to_string();
1601 let (queue, other) = (format!("hist_a_{tag}"), format!("hist_b_{tag}"));
1602 let now = Utc::now();
1603 for s in seeds(&queue, &other, now) {
1604 sqlx::query(
1605 "INSERT INTO hammerwork_jobs (id, queue_name, payload, status, priority, attempts, \
1606 max_attempts, created_at, scheduled_at, started_at, completed_at, failed_at, error_message) \
1607 VALUES (?, ?, '{}', ?, 2, 1, 3, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6), ?, ?, ?, ?)",
1608 )
1609 .bind(uuid::Uuid::new_v4().to_string())
1610 .bind(&s.queue)
1611 .bind(s.status)
1612 .bind(s.started)
1613 .bind(s.completed)
1614 .bind(s.failed)
1615 .bind(s.error)
1616 .execute(&pool)
1617 .await
1618 .unwrap();
1619 }
1620 let jq = JobQueue::<sqlx::MySql>::new(pool.clone());
1621 assert_history(&jq, &queue, &other, now).await;
1622 for name in [&queue, &other] {
1623 sqlx::query("DELETE FROM hammerwork_jobs WHERE queue_name = ?")
1624 .bind(name)
1625 .execute(&pool)
1626 .await
1627 .unwrap();
1628 }
1629 }
1630}