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 jobs_with_status(
305 &self,
306 queue: Option<&str>,
307 status: &str,
308 limit: u32,
309 ) -> impl Future<Output = Result<Vec<hammerwork::Job>>> + Send {
310 let _ = (queue, status, limit);
311 async {
312 Err(hammerwork::HammerworkError::Queue {
313 message: "listing jobs by status is not supported by this queue".to_string(),
314 })
315 }
316 }
317
318 fn job_states(&self, ids: &[String]) -> impl Future<Output = Result<Vec<JobUpdate>>> + Send {
326 let ids: Vec<uuid::Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
327 async move {
328 let mut states = Vec::with_capacity(ids.len());
329 for id in ids {
330 if let Some(job) = self.get_job(id).await? {
331 let changed_at = [
332 job.started_at,
333 job.completed_at,
334 job.failed_at,
335 job.timed_out_at,
336 ]
337 .into_iter()
338 .flatten()
339 .fold(job.created_at, DateTime::max);
340 states.push(job_change(
341 id.to_string(),
342 job.queue_name,
343 job.status.as_str(),
344 job.priority.as_i32(),
345 job.attempts,
346 changed_at,
347 ));
348 }
349 }
350 Ok(states)
351 }
352 }
353
354 fn table_maintenance(
358 &self,
359 operation: TableMaintenance,
360 tables: &[&'static str],
361 dry_run: bool,
362 ) -> impl Future<Output = Result<Vec<MaintenanceStatement>>> + Send;
363}
364
365const CHANGE_COLUMNS: [&str; 5] = [
368 "created_at",
369 "started_at",
370 "completed_at",
371 "failed_at",
372 "timed_out_at",
373];
374
375fn changed_since_filter(postgres: bool) -> String {
378 let placeholder = if postgres { "$1" } else { "?" };
379 CHANGE_COLUMNS
380 .iter()
381 .map(|column| format!("{column} > {placeholder}"))
382 .collect::<Vec<_>>()
383 .join(" OR ")
384}
385
386fn job_change(
388 id: String,
389 queue_name: String,
390 status: &str,
391 priority: i32,
392 attempts: i32,
393 changed_at: DateTime<Utc>,
394) -> JobUpdate {
395 JobUpdate {
396 id,
397 queue_name,
398 status: status.trim_matches('"').to_string(),
399 priority: hammerwork::JobPriority::from_i32(priority)
400 .unwrap_or_default()
401 .to_string(),
402 attempts,
403 updated_at: changed_at,
404 }
405}
406
407fn archivable_statuses(
410 policy: &hammerwork::archive::ArchivalPolicy,
411) -> [(Option<Duration>, &'static str, &'static str); 4] {
412 [
413 (policy.archive_completed_after, "Completed", "completed_at"),
414 (policy.archive_failed_after, "Failed", "failed_at"),
415 (policy.archive_dead_after, "Dead", "failed_at"),
416 (policy.archive_timed_out_after, "TimedOut", "timed_out_at"),
417 ]
418}
419
420fn archivable_count_query(
423 policy: &hammerwork::archive::ArchivalPolicy,
424 queue: bool,
425 now: DateTime<Utc>,
426 postgres: bool,
427) -> Option<(String, Vec<DateTime<Utc>>)> {
428 if !policy.enabled || policy.batch_size == 0 {
429 return None;
430 }
431 let mut param = 0;
432 let mut placeholder = || {
433 param += 1;
434 if postgres {
435 format!("${param}")
436 } else {
437 "?".to_string()
438 }
439 };
440 let queue_filter = if queue {
441 format!(" AND queue_name = {}", placeholder())
442 } else {
443 String::new()
444 };
445 let mut conditions = Vec::new();
446 let mut cutoffs = Vec::new();
447 for (after, status, column) in archivable_statuses(policy) {
448 if let Some(after) = after {
449 conditions.push(format!(
450 "(status = '{status}' AND {column} IS NOT NULL AND {column} <= {})",
451 placeholder()
452 ));
453 cutoffs.push(now - after);
454 }
455 }
456 if conditions.is_empty() {
457 return None;
458 }
459 Some((
460 format!(
461 "SELECT COUNT(*) FROM (SELECT id FROM hammerwork_jobs WHERE archived_at IS NULL{} \
462 AND ({}) LIMIT {}) candidates",
463 queue_filter,
464 conditions.join(" OR "),
465 policy.batch_size
466 ),
467 cutoffs,
468 ))
469}
470
471fn mysql_latest_change() -> String {
474 let latest = CHANGE_COLUMNS
475 .iter()
476 .map(|column| format!("COALESCE({column}, created_at)"))
477 .collect::<Vec<_>>()
478 .join(", ");
479 format!("CAST(GREATEST({latest}) AS DATETIME(6))")
480}
481
482fn pg_job_changes(rows: &[sqlx::postgres::PgRow]) -> Vec<JobUpdate> {
485 decodable(rows.iter().map(|row| {
486 let id = row.try_get::<uuid::Uuid, _>("id");
487 let label = id.as_ref().map(|id| id.to_string()).unwrap_or_default();
488 let change = id.map_err(Into::into).and_then(|id| {
489 Ok(job_change(
490 id.to_string(),
491 row.try_get("queue_name")?,
492 &row.try_get::<String, _>("status")?,
493 row.try_get("priority")?,
494 row.try_get("attempts")?,
495 row.try_get("changed_at")?,
496 ))
497 });
498 (label, change)
499 }))
500}
501
502fn mysql_job_changes(rows: &[sqlx::mysql::MySqlRow]) -> Vec<JobUpdate> {
505 decodable(rows.iter().map(|row| {
506 let label = row.try_get::<String, _>("id").unwrap_or_default();
507 let change = (|| {
508 Ok(job_change(
509 row.try_get("id")?,
510 row.try_get("queue_name")?,
511 &row.try_get::<String, _>("status")?,
512 row.try_get("priority")?,
513 row.try_get("attempts")?,
514 row.try_get("changed_at")?,
515 ))
516 })();
517 (label, change)
518 }))
519}
520
521#[derive(Debug, Clone, PartialEq)]
523enum ArchiveBind {
524 Text(String),
525 Time(DateTime<Utc>),
526 Bool(bool),
527}
528
529fn archive_conditions(filter: &ArchiveFilterParams, postgres: bool) -> (String, Vec<ArchiveBind>) {
533 let mut clauses = Vec::new();
534 let mut binds = Vec::new();
535 let mut add = |clause: &str, bind: ArchiveBind| {
536 binds.push(bind);
537 let placeholder = if postgres {
538 format!("${}", binds.len())
539 } else {
540 "?".to_string()
541 };
542 clauses.push(clause.replace('?', &placeholder));
543 };
544 if let Some(queue) = &filter.queue {
545 add("queue_name = ?", ArchiveBind::Text(queue.clone()));
546 }
547 if let Some(reason) = &filter.reason {
548 add(
549 "LOWER(TRIM(BOTH '\"' FROM archival_reason)) = ?",
550 ArchiveBind::Text(reason.to_lowercase()),
551 );
552 }
553 if let Some(after) = filter.archived_after {
554 add("archived_at >= ?", ArchiveBind::Time(after));
555 }
556 if let Some(before) = filter.archived_before {
557 add("archived_at <= ?", ArchiveBind::Time(before));
558 }
559 if let Some(by) = &filter.archived_by {
560 add("archived_by = ?", ArchiveBind::Text(by.clone()));
561 }
562 if let Some(compressed) = filter.compressed {
563 add("payload_compressed = ?", ArchiveBind::Bool(compressed));
564 }
565 if let Some(status) = &filter.original_status {
566 add(
567 "LOWER(TRIM(BOTH '\"' FROM status)) = ?",
568 ArchiveBind::Text(status.to_lowercase()),
569 );
570 }
571 if clauses.is_empty() {
572 (String::new(), binds)
573 } else {
574 (format!("WHERE {}", clauses.join(" AND ")), binds)
575 }
576}
577
578macro_rules! bind_archive_filter {
580 ($query:expr, $binds:expr) => {{
581 let mut query = $query;
582 for bind in $binds {
583 query = match bind {
584 ArchiveBind::Text(value) => query.bind(value.clone()),
585 ArchiveBind::Time(value) => query.bind(*value),
586 ArchiveBind::Bool(value) => query.bind(*value),
587 };
588 }
589 query
590 }};
591}
592
593const ARCHIVED_JOB_COLUMNS: &str = "id, queue_name, status, created_at, archived_at, \
595 archival_reason, original_payload_size, payload_compressed, archived_by";
596
597fn archived_status(value: &str) -> JobStatus {
600 let value = value.trim_matches('"');
601 [
602 JobStatus::Pending,
603 JobStatus::Running,
604 JobStatus::Completed,
605 JobStatus::Failed,
606 JobStatus::Dead,
607 JobStatus::TimedOut,
608 JobStatus::Retrying,
609 JobStatus::Archived,
610 ]
611 .into_iter()
612 .find(|status| status.as_str() == value)
613 .unwrap_or(JobStatus::Dead)
614}
615
616pub(crate) fn decodable<T>(rows: impl IntoIterator<Item = (String, Result<T>)>) -> Vec<T> {
620 rows.into_iter()
621 .filter_map(|(id, decoded)| match decoded {
622 Ok(value) => Some(value),
623 Err(error) => {
624 tracing::warn!(job_id = %id, error = %error, "Skipping an undecodable row");
625 None
626 }
627 })
628 .collect()
629}
630
631fn archived_job<R>(row: &R, id: uuid::Uuid) -> Result<ArchivedJob>
633where
634 R: Row,
635 for<'a> &'a str: sqlx::ColumnIndex<R>,
636 for<'a> String: sqlx::Decode<'a, R::Database> + sqlx::Type<R::Database>,
637 for<'a> Option<String>: sqlx::Decode<'a, R::Database> + sqlx::Type<R::Database>,
638 for<'a> Option<i32>: sqlx::Decode<'a, R::Database> + sqlx::Type<R::Database>,
639 for<'a> bool: sqlx::Decode<'a, R::Database> + sqlx::Type<R::Database>,
640 for<'a> DateTime<Utc>: sqlx::Decode<'a, R::Database> + sqlx::Type<R::Database>,
641{
642 Ok(ArchivedJob {
643 id,
644 queue_name: row.try_get("queue_name")?,
645 status: archived_status(&row.try_get::<String, _>("status")?),
646 created_at: row.try_get("created_at")?,
647 archived_at: row.try_get("archived_at")?,
648 archival_reason: ArchivalReason::parse_from_db(
649 &row.try_get::<String, _>("archival_reason")?,
650 )
651 .unwrap_or_default(),
652 original_payload_size: row
653 .try_get::<Option<i32>, _>("original_payload_size")?
654 .and_then(|size| usize::try_from(size).ok()),
655 payload_compressed: row.try_get("payload_compressed")?,
656 archived_by: row.try_get("archived_by")?,
657 })
658}
659
660fn finished_filter(kind: FinishedKind, queue: bool, cutoff: bool, postgres: bool) -> String {
662 let mut n = 0;
663 let mut ph = || {
664 n += 1;
665 if postgres {
666 format!("${n}")
667 } else {
668 "?".to_string()
669 }
670 };
671 let mut sql = format!("WHERE status = '{}'", kind.status());
672 if queue {
673 sql.push_str(&format!(" AND queue_name = {}", ph()));
674 }
675 if cutoff {
676 sql.push_str(&format!(" AND {} < {}", kind.age_column(), ph()));
677 }
678 sql
679}
680
681fn jobs_with_status_sql(queue: bool, postgres: bool) -> String {
684 let (status, name, limit) = if postgres {
685 ("$1", "$2", if queue { "$3" } else { "$2" })
686 } else {
687 ("?", "?", "?")
688 };
689 let queue_filter = if queue {
690 format!(" AND queue_name = {name}")
691 } else {
692 String::new()
693 };
694 format!(
695 "SELECT id FROM hammerwork_jobs WHERE status = {status}{queue_filter} \
696 ORDER BY created_at DESC LIMIT {limit}"
697 )
698}
699
700async fn load_jobs<Q: DatabaseQueue>(
703 queue: &Q,
704 ids: impl IntoIterator<Item = String>,
705) -> Vec<hammerwork::Job> {
706 let mut jobs = Vec::new();
707 for id in ids {
708 let Ok(id) = id.parse::<uuid::Uuid>() else {
709 continue;
710 };
711 match queue.get_job(id).await {
712 Ok(Some(job)) => jobs.push(job),
713 Ok(None) => {}
714 Err(e) => tracing::warn!(job_id = %id, error = %e, "skipping unreadable job"),
715 }
716 }
717 jobs
718}
719
720impl JobHistory for JobQueue<sqlx::Postgres> {
721 async fn hourly_activity(
722 &self,
723 queue: Option<&str>,
724 start: DateTime<Utc>,
725 end: DateTime<Utc>,
726 ) -> Result<Vec<HourBucket>> {
727 let hours = hour_range(start, end);
728 let (Some(first), Some(last)) = (hours.first().copied(), hours.last().copied()) else {
729 return Ok(Vec::new());
730 };
731 let upper = last + Duration::hours(1);
732 let queue_clause = if queue.is_some() {
733 " AND queue_name = $3"
734 } else {
735 ""
736 };
737
738 let completed_sql = format!(
739 "SELECT date_trunc('hour', completed_at AT TIME ZONE 'UTC') AS bucket, \
740 COUNT(*)::bigint AS n, \
741 AVG(EXTRACT(EPOCH FROM (completed_at - started_at)) * 1000)::float8 AS avg_ms \
742 FROM hammerwork_jobs WHERE status = 'Completed' \
743 AND completed_at >= $1 AND completed_at < $2{queue_clause} GROUP BY 1"
744 );
745 let mut q = sqlx::query(&completed_sql).bind(first).bind(upper);
746 if let Some(name) = queue {
747 q = q.bind(name.to_string());
748 }
749 let mut completed = BTreeMap::new();
750 for row in q.fetch_all(&self.pool).await? {
751 let bucket: NaiveDateTime = row.try_get("bucket")?;
752 let n: i64 = row.try_get("n")?;
753 let avg: Option<f64> = row.try_get("avg_ms")?;
754 completed.insert(bucket.and_utc(), (to_u64(n), avg));
755 }
756
757 let failed_sql = format!(
758 "SELECT date_trunc('hour', COALESCE(failed_at, timed_out_at) AT TIME ZONE 'UTC') AS bucket, \
759 COUNT(*)::bigint AS n FROM hammerwork_jobs \
760 WHERE status IN ('Failed', 'Dead', 'TimedOut') \
761 AND COALESCE(failed_at, timed_out_at) >= $1 AND COALESCE(failed_at, timed_out_at) < $2{queue_clause} \
762 GROUP BY 1"
763 );
764 let mut q = sqlx::query(&failed_sql).bind(first).bind(upper);
765 if let Some(name) = queue {
766 q = q.bind(name.to_string());
767 }
768 let mut failed = BTreeMap::new();
769 for row in q.fetch_all(&self.pool).await? {
770 let bucket: NaiveDateTime = row.try_get("bucket")?;
771 let n: i64 = row.try_get("n")?;
772 failed.insert(bucket.and_utc(), to_u64(n));
773 }
774
775 Ok(fill_buckets(&hours, &completed, &failed))
776 }
777
778 async fn error_groups(&self, since: DateTime<Utc>, limit: u32) -> Result<Vec<ErrorGroup>> {
779 let rows = sqlx::query(
780 "SELECT queue_name, error_message, COUNT(*)::bigint AS n, \
781 MIN(COALESCE(failed_at, timed_out_at)) AS first_seen, \
782 MAX(COALESCE(failed_at, timed_out_at)) AS last_seen \
783 FROM hammerwork_jobs \
784 WHERE status IN ('Failed', 'Dead', 'TimedOut') AND error_message IS NOT NULL \
785 AND COALESCE(failed_at, timed_out_at) >= $1 \
786 GROUP BY queue_name, error_message ORDER BY n DESC LIMIT $2",
787 )
788 .bind(since)
789 .bind(i64::from(limit))
790 .fetch_all(&self.pool)
791 .await?;
792 let mut groups = Vec::with_capacity(rows.len());
793 for row in rows {
794 groups.push(ErrorGroup {
795 queue_name: row.try_get("queue_name")?,
796 message: row.try_get("error_message")?,
797 count: to_u64(row.try_get("n")?),
798 first_seen: row.try_get("first_seen")?,
799 last_seen: row.try_get("last_seen")?,
800 });
801 }
802 Ok(groups)
803 }
804
805 async fn count_jobs(
806 &self,
807 queue: Option<&str>,
808 kind: FinishedKind,
809 older_than: Option<DateTime<Utc>>,
810 ) -> Result<u64> {
811 let sql = format!(
812 "SELECT COUNT(*)::bigint FROM hammerwork_jobs {}",
813 finished_filter(kind, queue.is_some(), older_than.is_some(), true)
814 );
815 let mut q = sqlx::query_scalar::<_, i64>(&sql);
816 if let Some(name) = queue {
817 q = q.bind(name.to_string());
818 }
819 if let Some(cutoff) = older_than {
820 q = q.bind(cutoff);
821 }
822 Ok(to_u64(q.fetch_one(&self.pool).await?))
823 }
824
825 async fn count_archivable(
826 &self,
827 queue: Option<&str>,
828 policy: &hammerwork::archive::ArchivalPolicy,
829 ) -> Result<u64> {
830 let Some((sql, cutoffs)) =
831 archivable_count_query(policy, queue.is_some(), Utc::now(), true)
832 else {
833 return Ok(0);
834 };
835 let mut q = sqlx::query_scalar::<_, i64>(&sql);
836 if let Some(name) = queue {
837 q = q.bind(name.to_string());
838 }
839 for cutoff in cutoffs {
840 q = q.bind(cutoff);
841 }
842 Ok(to_u64(q.fetch_one(&self.pool).await?))
843 }
844
845 async fn delete_jobs(
846 &self,
847 queue: Option<&str>,
848 kind: FinishedKind,
849 older_than: Option<DateTime<Utc>>,
850 ) -> Result<u64> {
851 let sql = format!(
852 "DELETE FROM hammerwork_jobs {}",
853 finished_filter(kind, queue.is_some(), older_than.is_some(), true)
854 );
855 let mut q = sqlx::query(&sql);
856 if let Some(name) = queue {
857 q = q.bind(name.to_string());
858 }
859 if let Some(cutoff) = older_than {
860 q = q.bind(cutoff);
861 }
862 Ok(q.execute(&self.pool).await?.rows_affected())
863 }
864
865 async fn archived_jobs(
866 &self,
867 filter: &ArchiveFilterParams,
868 limit: u32,
869 offset: u32,
870 ) -> Result<(Vec<ArchivedJob>, u64)> {
871 let (conditions, binds) = archive_conditions(filter, true);
872 let count_sql = format!("SELECT COUNT(*) FROM hammerwork_jobs_archive {conditions}");
873 let total = bind_archive_filter!(sqlx::query_scalar::<_, i64>(&count_sql), &binds)
874 .fetch_one(&self.pool)
875 .await?;
876 let (limit_ph, offset_ph) = (
877 format!("${}", binds.len() + 1),
878 format!("${}", binds.len() + 2),
879 );
880 let page_sql = format!(
881 "SELECT {ARCHIVED_JOB_COLUMNS} FROM hammerwork_jobs_archive {conditions} \
882 ORDER BY archived_at DESC, id DESC LIMIT {limit_ph} OFFSET {offset_ph}"
883 );
884 let rows = bind_archive_filter!(sqlx::query(&page_sql), &binds)
885 .bind(i64::from(limit))
886 .bind(i64::from(offset))
887 .fetch_all(&self.pool)
888 .await?;
889 let jobs = decodable(rows.iter().map(|row| {
890 let id = row.try_get::<uuid::Uuid, _>("id");
891 let label = id.as_ref().map(|id| id.to_string()).unwrap_or_default();
892 (
893 label,
894 id.map_err(Into::into).and_then(|id| archived_job(row, id)),
895 )
896 }));
897 Ok((jobs, to_u64(total)))
898 }
899
900 async fn database_now(&self) -> Result<DateTime<Utc>> {
901 Ok(sqlx::query_scalar("SELECT NOW()")
902 .fetch_one(&self.pool)
903 .await?)
904 }
905
906 async fn recent_job_changes(&self, since: DateTime<Utc>, limit: u32) -> Result<Vec<JobUpdate>> {
907 let sql = format!(
909 "SELECT id, queue_name, status, priority, attempts, GREATEST({}) AS changed_at \
910 FROM hammerwork_jobs WHERE {} ORDER BY changed_at DESC LIMIT $2",
911 CHANGE_COLUMNS.join(", "),
912 changed_since_filter(true)
913 );
914 let rows = sqlx::query(&sql)
915 .bind(since)
916 .bind(i64::from(limit))
917 .fetch_all(&self.pool)
918 .await?;
919 Ok(pg_job_changes(&rows))
920 }
921
922 async fn running_jobs(&self, limit: u32) -> Result<Vec<JobUpdate>> {
923 let rows = sqlx::query(
924 "SELECT id, queue_name, status, priority, attempts, \
925 COALESCE(started_at, created_at) AS changed_at FROM hammerwork_jobs \
926 WHERE status = 'Running' ORDER BY started_at DESC LIMIT $1",
927 )
928 .bind(i64::from(limit))
929 .fetch_all(&self.pool)
930 .await?;
931 Ok(pg_job_changes(&rows))
932 }
933
934 async fn jobs_with_status(
935 &self,
936 queue_name: Option<&str>,
937 status: &str,
938 limit: u32,
939 ) -> Result<Vec<hammerwork::Job>> {
940 let sql = jobs_with_status_sql(queue_name.is_some(), true);
941 let mut query = sqlx::query(&sql).bind(status.to_string());
942 if let Some(name) = queue_name {
943 query = query.bind(name.to_string());
944 }
945 let rows = query.bind(i64::from(limit)).fetch_all(&self.pool).await?;
946 let ids: Vec<String> = rows
947 .iter()
948 .filter_map(|row| row.try_get::<uuid::Uuid, _>("id").ok())
949 .map(|id| id.to_string())
950 .collect();
951 Ok(load_jobs(self, ids).await)
952 }
953
954 async fn job_states(&self, ids: &[String]) -> Result<Vec<JobUpdate>> {
955 let ids: Vec<uuid::Uuid> = ids.iter().filter_map(|id| id.parse().ok()).collect();
956 if ids.is_empty() {
957 return Ok(Vec::new());
958 }
959 let sql = format!(
960 "SELECT id, queue_name, status, priority, attempts, GREATEST({}) AS changed_at \
961 FROM hammerwork_jobs WHERE id = ANY($1)",
962 CHANGE_COLUMNS.join(", ")
963 );
964 let rows = sqlx::query(&sql).bind(&ids).fetch_all(&self.pool).await?;
965 Ok(pg_job_changes(&rows))
966 }
967
968 async fn table_maintenance(
969 &self,
970 operation: TableMaintenance,
971 tables: &[&'static str],
972 dry_run: bool,
973 ) -> Result<Vec<MaintenanceStatement>> {
974 let names: Vec<String> = tables.iter().map(|t| t.to_string()).collect();
975 let existing: Vec<String> = sqlx::query_scalar(
976 "SELECT tablename::text FROM pg_tables \
977 WHERE schemaname = current_schema() AND tablename = ANY($1)",
978 )
979 .bind(&names)
980 .fetch_all(&self.pool)
981 .await?;
982 let mut ran = Vec::new();
983 for table in present_tables(tables, &existing) {
984 let statement = operation.postgres_statement(table);
985 if !dry_run {
986 sqlx::raw_sql(&statement).execute(&self.pool).await?;
988 }
989 ran.push(MaintenanceStatement {
990 table,
991 statement,
992 messages: Vec::new(),
993 });
994 }
995 Ok(ran)
996 }
997}
998
999impl JobHistory for JobQueue<sqlx::MySql> {
1000 async fn hourly_activity(
1001 &self,
1002 queue: Option<&str>,
1003 start: DateTime<Utc>,
1004 end: DateTime<Utc>,
1005 ) -> Result<Vec<HourBucket>> {
1006 let hours = hour_range(start, end);
1007 let (Some(first), Some(last)) = (hours.first().copied(), hours.last().copied()) else {
1008 return Ok(Vec::new());
1009 };
1010 let upper = last + Duration::hours(1);
1011 let queue_clause = if queue.is_some() {
1012 " AND queue_name = ?"
1013 } else {
1014 ""
1015 };
1016
1017 let completed_sql = format!(
1018 "SELECT DATE_FORMAT(completed_at, '%Y-%m-%d %H:00:00') AS bucket, \
1019 CAST(COUNT(*) AS SIGNED) AS n, \
1020 CAST(AVG(TIMESTAMPDIFF(MICROSECOND, started_at, completed_at)) / 1000 AS DOUBLE) AS avg_ms \
1021 FROM hammerwork_jobs WHERE status = 'Completed' \
1022 AND completed_at >= ? AND completed_at < ?{queue_clause} GROUP BY bucket"
1023 );
1024 let mut q = sqlx::query(&completed_sql).bind(first).bind(upper);
1025 if let Some(name) = queue {
1026 q = q.bind(name.to_string());
1027 }
1028 let mut completed = BTreeMap::new();
1029 for row in q.fetch_all(&self.pool).await? {
1030 let bucket: String = row.try_get("bucket")?;
1031 let n: i64 = row.try_get("n")?;
1032 let avg: Option<f64> = row.try_get("avg_ms")?;
1033 if let Some(hour) = parse_bucket(&bucket) {
1034 completed.insert(hour, (to_u64(n), avg));
1035 }
1036 }
1037
1038 let failed_sql = format!(
1039 "SELECT DATE_FORMAT(COALESCE(failed_at, timed_out_at), '%Y-%m-%d %H:00:00') AS bucket, \
1040 CAST(COUNT(*) AS SIGNED) AS n FROM hammerwork_jobs \
1041 WHERE status IN ('Failed', 'Dead', 'TimedOut') \
1042 AND COALESCE(failed_at, timed_out_at) >= ? AND COALESCE(failed_at, timed_out_at) < ?{queue_clause} \
1043 GROUP BY bucket"
1044 );
1045 let mut q = sqlx::query(&failed_sql).bind(first).bind(upper);
1046 if let Some(name) = queue {
1047 q = q.bind(name.to_string());
1048 }
1049 let mut failed = BTreeMap::new();
1050 for row in q.fetch_all(&self.pool).await? {
1051 let bucket: String = row.try_get("bucket")?;
1052 let n: i64 = row.try_get("n")?;
1053 if let Some(hour) = parse_bucket(&bucket) {
1054 failed.insert(hour, to_u64(n));
1055 }
1056 }
1057
1058 Ok(fill_buckets(&hours, &completed, &failed))
1059 }
1060
1061 async fn error_groups(&self, since: DateTime<Utc>, limit: u32) -> Result<Vec<ErrorGroup>> {
1062 let rows = sqlx::query(
1063 "SELECT queue_name, error_message, CAST(COUNT(*) AS SIGNED) AS n, \
1064 MIN(COALESCE(failed_at, timed_out_at)) AS first_seen, \
1065 MAX(COALESCE(failed_at, timed_out_at)) AS last_seen \
1066 FROM hammerwork_jobs \
1067 WHERE status IN ('Failed', 'Dead', 'TimedOut') AND error_message IS NOT NULL \
1068 AND COALESCE(failed_at, timed_out_at) >= ? \
1069 GROUP BY queue_name, error_message ORDER BY n DESC LIMIT ?",
1070 )
1071 .bind(since)
1072 .bind(i64::from(limit))
1073 .fetch_all(&self.pool)
1074 .await?;
1075 let mut groups = Vec::with_capacity(rows.len());
1076 for row in rows {
1077 groups.push(ErrorGroup {
1078 queue_name: row.try_get("queue_name")?,
1079 message: row.try_get("error_message")?,
1080 count: to_u64(row.try_get("n")?),
1081 first_seen: row.try_get("first_seen")?,
1082 last_seen: row.try_get("last_seen")?,
1083 });
1084 }
1085 Ok(groups)
1086 }
1087
1088 async fn count_jobs(
1089 &self,
1090 queue: Option<&str>,
1091 kind: FinishedKind,
1092 older_than: Option<DateTime<Utc>>,
1093 ) -> Result<u64> {
1094 let sql = format!(
1095 "SELECT CAST(COUNT(*) AS SIGNED) FROM hammerwork_jobs {}",
1096 finished_filter(kind, queue.is_some(), older_than.is_some(), false)
1097 );
1098 let mut q = sqlx::query_scalar::<_, i64>(&sql);
1099 if let Some(name) = queue {
1100 q = q.bind(name.to_string());
1101 }
1102 if let Some(cutoff) = older_than {
1103 q = q.bind(cutoff);
1104 }
1105 Ok(to_u64(q.fetch_one(&self.pool).await?))
1106 }
1107
1108 async fn count_archivable(
1109 &self,
1110 queue: Option<&str>,
1111 policy: &hammerwork::archive::ArchivalPolicy,
1112 ) -> Result<u64> {
1113 let Some((sql, cutoffs)) =
1114 archivable_count_query(policy, queue.is_some(), Utc::now(), false)
1115 else {
1116 return Ok(0);
1117 };
1118 let mut q = sqlx::query_scalar::<_, i64>(&sql);
1119 if let Some(name) = queue {
1120 q = q.bind(name.to_string());
1121 }
1122 for cutoff in cutoffs {
1123 q = q.bind(cutoff);
1124 }
1125 Ok(to_u64(q.fetch_one(&self.pool).await?))
1126 }
1127
1128 async fn delete_jobs(
1129 &self,
1130 queue: Option<&str>,
1131 kind: FinishedKind,
1132 older_than: Option<DateTime<Utc>>,
1133 ) -> Result<u64> {
1134 let sql = format!(
1135 "DELETE FROM hammerwork_jobs {}",
1136 finished_filter(kind, queue.is_some(), older_than.is_some(), false)
1137 );
1138 let mut q = sqlx::query(&sql);
1139 if let Some(name) = queue {
1140 q = q.bind(name.to_string());
1141 }
1142 if let Some(cutoff) = older_than {
1143 q = q.bind(cutoff);
1144 }
1145 Ok(q.execute(&self.pool).await?.rows_affected())
1146 }
1147
1148 async fn archived_jobs(
1149 &self,
1150 filter: &ArchiveFilterParams,
1151 limit: u32,
1152 offset: u32,
1153 ) -> Result<(Vec<ArchivedJob>, u64)> {
1154 let (conditions, binds) = archive_conditions(filter, false);
1155 let count_sql = format!("SELECT COUNT(*) FROM hammerwork_jobs_archive {conditions}");
1156 let total = bind_archive_filter!(sqlx::query_scalar::<_, i64>(&count_sql), &binds)
1157 .fetch_one(&self.pool)
1158 .await?;
1159 let page_sql = format!(
1160 "SELECT {ARCHIVED_JOB_COLUMNS} FROM hammerwork_jobs_archive {conditions} \
1161 ORDER BY archived_at DESC, id DESC LIMIT ? OFFSET ?"
1162 );
1163 let rows = bind_archive_filter!(sqlx::query(&page_sql), &binds)
1164 .bind(i64::from(limit))
1165 .bind(i64::from(offset))
1166 .fetch_all(&self.pool)
1167 .await?;
1168 let jobs = decodable(rows.iter().map(|row| {
1169 let label = row.try_get::<String, _>("id").unwrap_or_default();
1170 let job = uuid::Uuid::parse_str(&label)
1171 .map_err(Into::into)
1172 .and_then(|id| archived_job(row, id));
1173 (label, job)
1174 }));
1175 Ok((jobs, to_u64(total)))
1176 }
1177
1178 async fn database_now(&self) -> Result<DateTime<Utc>> {
1179 Ok(sqlx::query_scalar("SELECT UTC_TIMESTAMP(6)")
1180 .fetch_one(&self.pool)
1181 .await?)
1182 }
1183
1184 async fn recent_job_changes(&self, since: DateTime<Utc>, limit: u32) -> Result<Vec<JobUpdate>> {
1185 let sql = format!(
1186 "SELECT id, queue_name, status, priority, attempts, {} AS changed_at \
1187 FROM hammerwork_jobs WHERE {} ORDER BY changed_at DESC LIMIT ?",
1188 mysql_latest_change(),
1189 changed_since_filter(false)
1190 );
1191 let mut query = sqlx::query(&sql);
1192 for _ in CHANGE_COLUMNS {
1193 query = query.bind(since);
1194 }
1195 let rows = query.bind(i64::from(limit)).fetch_all(&self.pool).await?;
1196 Ok(mysql_job_changes(&rows))
1197 }
1198
1199 async fn running_jobs(&self, limit: u32) -> Result<Vec<JobUpdate>> {
1200 let rows = sqlx::query(
1201 "SELECT id, queue_name, status, priority, attempts, \
1202 CAST(COALESCE(started_at, created_at) AS DATETIME(6)) AS changed_at \
1203 FROM hammerwork_jobs WHERE status = 'Running' ORDER BY started_at DESC LIMIT ?",
1204 )
1205 .bind(i64::from(limit))
1206 .fetch_all(&self.pool)
1207 .await?;
1208 Ok(mysql_job_changes(&rows))
1209 }
1210
1211 async fn jobs_with_status(
1212 &self,
1213 queue_name: Option<&str>,
1214 status: &str,
1215 limit: u32,
1216 ) -> Result<Vec<hammerwork::Job>> {
1217 let sql = jobs_with_status_sql(queue_name.is_some(), false);
1218 let mut query = sqlx::query(&sql).bind(status.to_string());
1219 if let Some(name) = queue_name {
1220 query = query.bind(name.to_string());
1221 }
1222 let rows = query.bind(i64::from(limit)).fetch_all(&self.pool).await?;
1223 let ids: Vec<String> = rows
1224 .iter()
1225 .filter_map(|row| row.try_get::<String, _>("id").ok())
1226 .collect();
1227 Ok(load_jobs(self, ids).await)
1228 }
1229
1230 async fn job_states(&self, ids: &[String]) -> Result<Vec<JobUpdate>> {
1231 let ids: Vec<String> = ids
1232 .iter()
1233 .filter_map(|id| id.parse::<uuid::Uuid>().ok())
1234 .map(|id| id.to_string())
1235 .collect();
1236 if ids.is_empty() {
1237 return Ok(Vec::new());
1238 }
1239 let sql = format!(
1240 "SELECT id, queue_name, status, priority, attempts, {} AS changed_at \
1241 FROM hammerwork_jobs WHERE id IN ({})",
1242 mysql_latest_change(),
1243 vec!["?"; ids.len()].join(", ")
1244 );
1245 let mut query = sqlx::query(&sql);
1246 for id in &ids {
1247 query = query.bind(id);
1248 }
1249 let rows = query.fetch_all(&self.pool).await?;
1250 Ok(mysql_job_changes(&rows))
1251 }
1252 async fn table_maintenance(
1253 &self,
1254 operation: TableMaintenance,
1255 tables: &[&'static str],
1256 dry_run: bool,
1257 ) -> Result<Vec<MaintenanceStatement>> {
1258 if tables.is_empty() {
1259 return Ok(Vec::new());
1260 }
1261 let placeholders = vec!["?"; tables.len()].join(", ");
1262 let sql = format!(
1263 "SELECT CAST(TABLE_NAME AS CHAR) FROM information_schema.TABLES \
1264 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN ({placeholders})"
1265 );
1266 let mut q = sqlx::query_scalar::<_, String>(&sql);
1267 for table in tables {
1268 q = q.bind(*table);
1269 }
1270 let existing = q.fetch_all(&self.pool).await?;
1271 let mut ran = Vec::new();
1272 for table in present_tables(tables, &existing) {
1273 let statement = operation.mysql_statement(table);
1274 let mut messages = Vec::new();
1275 if !dry_run {
1276 for row in sqlx::raw_sql(&statement).fetch_all(&self.pool).await? {
1278 let kind = text_column(&row, "Msg_type");
1279 let text = text_column(&row, "Msg_text");
1280 if kind.eq_ignore_ascii_case("error") {
1281 return Err(hammerwork::HammerworkError::Queue {
1282 message: format!("{statement} failed: {text}"),
1283 });
1284 }
1285 messages.push(format!("{kind}: {text}"));
1286 }
1287 }
1288 ran.push(MaintenanceStatement {
1289 table,
1290 statement,
1291 messages,
1292 });
1293 }
1294 Ok(ran)
1295 }
1296}
1297
1298fn text_column(row: &sqlx::mysql::MySqlRow, name: &str) -> String {
1300 row.try_get::<String, _>(name)
1301 .or_else(|_| {
1302 row.try_get::<Vec<u8>, _>(name)
1303 .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
1304 })
1305 .unwrap_or_default()
1306}
1307
1308fn parse_bucket(bucket: &str) -> Option<DateTime<Utc>> {
1309 NaiveDateTime::parse_from_str(bucket, "%Y-%m-%d %H:%M:%S")
1310 .ok()
1311 .map(|t| t.and_utc())
1312}
1313
1314#[cfg(test)]
1315mod tests {
1316 use super::*;
1317
1318 fn at(h: u32, m: u32) -> DateTime<Utc> {
1319 Utc.with_ymd_and_hms(2026, 1, 2, h, m, 0).unwrap()
1320 }
1321
1322 #[test]
1324 fn undecodable_rows_are_skipped() {
1325 let bad: Result<u8> = Err(hammerwork::HammerworkError::Processing("bad".into()));
1326 let rows = vec![("a".to_string(), Ok(1u8)), ("b".to_string(), bad)];
1327 assert_eq!(decodable(rows), [1]);
1328 }
1329
1330 #[test]
1331 fn job_changes_normalise_status_and_priority() {
1332 let at = Utc::now();
1333 let change = job_change("id".into(), "q".into(), "\"Running\"", 3, 2, at);
1334 assert_eq!(change.status, "Running");
1335 assert_eq!(change.priority, "high");
1336 assert_eq!(change.attempts, 2);
1337 assert_eq!(change.updated_at, at);
1338 assert_eq!(
1339 job_change("id".into(), "q".into(), "Pending", 99, 0, at).priority,
1340 "normal"
1341 );
1342 assert_eq!(
1343 changed_since_filter(true),
1344 "created_at > $1 OR started_at > $1 OR completed_at > $1 OR failed_at > $1 \
1345 OR timed_out_at > $1"
1346 );
1347 assert!(changed_since_filter(false).starts_with("created_at > ? OR started_at > ?"));
1348 }
1349
1350 #[test]
1352 fn archivable_count_query_mirrors_the_archiver() {
1353 use hammerwork::archive::ArchivalPolicy;
1354 let now = Utc::now();
1355 let policy = ArchivalPolicy::new()
1356 .archive_completed_after(Duration::days(1))
1357 .with_batch_size(50);
1358 let (sql, cutoffs) = archivable_count_query(&policy, true, now, true).unwrap();
1359 assert!(
1360 sql.contains("archived_at IS NULL AND queue_name = $1"),
1361 "{sql}"
1362 );
1363 assert!(
1364 sql.contains(
1365 "(status = 'Completed' AND completed_at IS NOT NULL AND completed_at <= $2)"
1366 ),
1367 "{sql}"
1368 );
1369 assert!(sql.ends_with("LIMIT 50) candidates"), "{sql}");
1370 assert_eq!(cutoffs.len(), 4, "every status of the default policy");
1371 assert_eq!(cutoffs[0], now - Duration::days(1));
1372
1373 let (sql, _) = archivable_count_query(&policy, false, now, false).unwrap();
1374 assert!(!sql.contains('$') && !sql.contains("queue_name"), "{sql}");
1375
1376 let mut disabled = policy.clone();
1377 disabled.enabled = false;
1378 assert!(archivable_count_query(&disabled, false, now, true).is_none());
1379 let mut nothing = ArchivalPolicy::new();
1380 nothing.archive_completed_after = None;
1381 nothing.archive_failed_after = None;
1382 nothing.archive_dead_after = None;
1383 nothing.archive_timed_out_after = None;
1384 assert!(archivable_count_query(¬hing, false, now, true).is_none());
1385 }
1386
1387 #[test]
1388 fn test_hour_floor_and_range() {
1389 assert_eq!(
1390 hour_floor(at(5, 59)),
1391 Utc.with_ymd_and_hms(2026, 1, 2, 5, 0, 0).unwrap()
1392 );
1393 let hours = hour_range(at(5, 10), at(8, 0));
1394 assert_eq!(hours.len(), 3);
1395 assert_eq!(hours[2], Utc.with_ymd_and_hms(2026, 1, 2, 7, 0, 0).unwrap());
1396 assert!(hour_range(at(8, 0), at(5, 0)).is_empty());
1397 }
1398
1399 #[test]
1400 fn test_hour_range_is_capped() {
1401 let end = at(0, 0);
1402 let hours = hour_range(end - Duration::days(400), end);
1403 assert_eq!(hours.len() as i64, MAX_BUCKETS);
1404 }
1405
1406 #[test]
1407 fn test_fill_buckets_zero_fills() {
1408 let hours = hour_range(at(5, 0), at(8, 0));
1409 let mut completed = BTreeMap::new();
1410 completed.insert(hours[0], (4, Some(12.5)));
1411 let mut failed = BTreeMap::new();
1412 failed.insert(hours[2], 2);
1413 let buckets = fill_buckets(&hours, &completed, &failed);
1414 assert_eq!(buckets.len(), 3);
1415 assert_eq!((buckets[0].completed, buckets[0].failed), (4, 0));
1416 assert_eq!(buckets[0].avg_processing_time_ms, Some(12.5));
1417 assert_eq!((buckets[1].completed, buckets[1].failed), (0, 0));
1418 assert_eq!(buckets[1].avg_processing_time_ms, None);
1419 assert_eq!((buckets[2].completed, buckets[2].failed), (0, 2));
1420 }
1421
1422 #[test]
1424 fn archive_filters_become_sql_conditions() {
1425 let (sql, binds) = archive_conditions(&ArchiveFilterParams::default(), true);
1426 assert_eq!(sql, "");
1427 assert!(binds.is_empty());
1428
1429 let at = Utc.with_ymd_and_hms(2024, 5, 1, 0, 0, 0).unwrap();
1430 let filter = ArchiveFilterParams {
1431 queue: Some("emails".into()),
1432 reason: Some("Manual".into()),
1433 archived_after: Some(at),
1434 archived_before: Some(at),
1435 archived_by: Some("ops".into()),
1436 compressed: Some(true),
1437 original_status: Some("COMPLETED".into()),
1438 };
1439 let (pg, binds) = archive_conditions(&filter, true);
1440 assert_eq!(
1441 pg,
1442 "WHERE queue_name = $1 AND LOWER(TRIM(BOTH '\"' FROM archival_reason)) = $2 \
1443 AND archived_at >= $3 AND archived_at <= $4 AND archived_by = $5 \
1444 AND payload_compressed = $6 AND LOWER(TRIM(BOTH '\"' FROM status)) = $7"
1445 );
1446 assert_eq!(
1447 binds,
1448 vec![
1449 ArchiveBind::Text("emails".into()),
1450 ArchiveBind::Text("manual".into()),
1451 ArchiveBind::Time(at),
1452 ArchiveBind::Time(at),
1453 ArchiveBind::Text("ops".into()),
1454 ArchiveBind::Bool(true),
1455 ArchiveBind::Text("completed".into()),
1456 ]
1457 );
1458 let (mysql, _) = archive_conditions(&filter, false);
1459 assert_eq!(mysql.matches('?').count(), 7);
1460 assert!(!mysql.contains('$'));
1461 }
1462
1463 #[test]
1464 fn archived_statuses_parse_like_the_core_library() {
1465 assert_eq!(archived_status("Completed"), JobStatus::Completed);
1466 assert_eq!(archived_status("\"TimedOut\""), JobStatus::TimedOut);
1467 assert_eq!(archived_status("Archived"), JobStatus::Archived);
1468 assert_eq!(archived_status("whatever"), JobStatus::Dead);
1469 }
1470
1471 #[test]
1472 fn test_finished_filter_placeholders() {
1473 let pg = finished_filter(FinishedKind::Completed, true, true, true);
1474 assert_eq!(
1475 pg,
1476 "WHERE status = 'Completed' AND queue_name = $1 AND completed_at < $2"
1477 );
1478 let my = finished_filter(FinishedKind::Dead, false, true, false);
1479 assert!(my.contains("status = 'Dead'"));
1480 assert!(my.ends_with("< ?"));
1481 assert!(!my.contains("queue_name"));
1482 }
1483
1484 #[test]
1485 fn test_parse_bucket() {
1486 assert_eq!(
1487 parse_bucket("2026-01-02 05:00:00"),
1488 Some(Utc.with_ymd_and_hms(2026, 1, 2, 5, 0, 0).unwrap())
1489 );
1490 assert_eq!(parse_bucket("garbage"), None);
1491 }
1492
1493 #[test]
1494 fn test_table_maintenance_statements() {
1495 assert_eq!(
1496 TableMaintenance::parse("vacuum"),
1497 Some(TableMaintenance::Vacuum)
1498 );
1499 assert_eq!(
1500 TableMaintenance::parse("reindex"),
1501 Some(TableMaintenance::Reindex)
1502 );
1503 assert_eq!(
1504 TableMaintenance::parse("optimize"),
1505 Some(TableMaintenance::Optimize)
1506 );
1507 assert_eq!(TableMaintenance::parse("VACUUM"), None);
1508 assert_eq!(TableMaintenance::parse("cleanup"), None);
1509
1510 let t = "hammerwork_jobs";
1511 assert_eq!(
1512 TableMaintenance::Vacuum.postgres_statement(t),
1513 "VACUUM (ANALYZE) hammerwork_jobs"
1514 );
1515 assert_eq!(
1516 TableMaintenance::Reindex.postgres_statement(t),
1517 "REINDEX TABLE hammerwork_jobs"
1518 );
1519 assert_eq!(
1520 TableMaintenance::Optimize.postgres_statement(t),
1521 "ANALYZE hammerwork_jobs"
1522 );
1523 for op in [
1524 TableMaintenance::Vacuum,
1525 TableMaintenance::Reindex,
1526 TableMaintenance::Optimize,
1527 ] {
1528 assert_eq!(op.mysql_statement(t), "OPTIMIZE TABLE hammerwork_jobs");
1529 }
1530 assert_eq!(
1531 serde_json::to_value(TableMaintenance::Reindex).unwrap(),
1532 "reindex"
1533 );
1534 }
1535
1536 #[test]
1537 fn test_maintenance_tables_only_hammerwork_tables() {
1538 assert_eq!(maintenance_tables(None).unwrap(), HAMMERWORK_TABLES);
1539 assert_eq!(
1540 maintenance_tables(Some("hammerwork_jobs_archive")).unwrap(),
1541 vec!["hammerwork_jobs_archive"]
1542 );
1543 for bad in [
1544 "users",
1545 "hammerwork_jobs; DROP TABLE x",
1546 "HAMMERWORK_JOBS",
1547 "",
1548 ] {
1549 assert!(maintenance_tables(Some(bad)).is_err(), "{bad}");
1550 }
1551 assert!(
1552 HAMMERWORK_TABLES
1553 .iter()
1554 .all(|t| t.starts_with("hammerwork_")
1555 && t.chars().all(|c| c.is_ascii_lowercase() || c == '_'))
1556 );
1557 }
1558
1559 #[test]
1560 fn test_present_tables_keeps_fixed_names_in_order() {
1561 let existing = vec!["hammerwork_batches".to_string(), "other".to_string()];
1562 assert_eq!(
1563 present_tables(&["hammerwork_jobs", "hammerwork_batches"], &existing),
1564 vec!["hammerwork_batches"]
1565 );
1566 assert!(present_tables(&["hammerwork_jobs"], &[]).is_empty());
1567 }
1568}
1569
1570#[cfg(test)]
1571mod db_tests {
1572 use super::*;
1573 use hammerwork::JobQueue;
1574
1575 struct Seed {
1576 queue: String,
1577 status: &'static str,
1578 started: Option<DateTime<Utc>>,
1579 completed: Option<DateTime<Utc>>,
1580 failed: Option<DateTime<Utc>>,
1581 error: Option<&'static str>,
1582 }
1583
1584 fn seeds(queue: &str, other: &str, now: DateTime<Utc>) -> Vec<Seed> {
1585 let base = hour_floor(now);
1586 let done = |q: &str, at: DateTime<Utc>| Seed {
1587 queue: q.to_string(),
1588 status: "Completed",
1589 started: Some(at - Duration::milliseconds(100)),
1590 completed: Some(at),
1591 failed: None,
1592 error: None,
1593 };
1594 vec![
1595 done(queue, base - Duration::hours(2) + Duration::minutes(5)),
1596 done(queue, base - Duration::hours(2) + Duration::minutes(30)),
1597 done(other, base - Duration::hours(2) + Duration::minutes(10)),
1598 Seed {
1599 queue: queue.to_string(),
1600 status: "Dead",
1601 started: None,
1602 completed: None,
1603 failed: Some(base - Duration::hours(1) + Duration::minutes(15)),
1604 error: Some("connection refused"),
1605 },
1606 ]
1607 }
1608
1609 async fn assert_history<Q: JobHistory>(q: &Q, queue: &str, other: &str, now: DateTime<Utc>) {
1610 let base = hour_floor(now);
1611 let buckets = q
1612 .hourly_activity(Some(queue), base - Duration::hours(3), now)
1613 .await
1614 .unwrap();
1615 assert_eq!(buckets.len(), 4, "3h ago .. current hour, zero filled");
1616 assert_eq!((buckets[0].completed, buckets[0].failed), (0, 0));
1617 assert_eq!((buckets[1].completed, buckets[1].failed), (2, 0));
1618 let avg = buckets[1].avg_processing_time_ms.expect("avg");
1619 assert!((avg - 100.0).abs() < 5.0, "avg was {avg}");
1620 assert_eq!((buckets[2].completed, buckets[2].failed), (0, 1));
1621 assert_eq!(buckets[2].avg_processing_time_ms, None);
1622 assert_eq!(buckets[1].hour, base - Duration::hours(2));
1623
1624 let all = q
1626 .hourly_activity(None, base - Duration::hours(3), now)
1627 .await
1628 .unwrap();
1629 assert!(all[1].completed >= 3);
1630
1631 let groups = q.error_groups(now - Duration::hours(5), 500).await.unwrap();
1632 let ours: Vec<_> = groups.iter().filter(|g| g.queue_name == queue).collect();
1633 assert_eq!(ours.len(), 1);
1634 assert_eq!(ours[0].message, "connection refused");
1635 assert_eq!(ours[0].count, 1);
1636 assert_eq!(
1637 ours[0].first_seen.timestamp(),
1638 (base - Duration::hours(1) + Duration::minutes(15)).timestamp()
1639 );
1640 assert_eq!(ours[0].last_seen, ours[0].first_seen);
1641
1642 let listed = q
1644 .jobs_with_status(Some(queue), "Completed", 100)
1645 .await
1646 .unwrap();
1647 assert_eq!(listed.len(), 2);
1648 assert!(listed.iter().all(|j| j.status == JobStatus::Completed));
1649 assert_eq!(
1650 q.jobs_with_status(Some(queue), "Completed", 1)
1651 .await
1652 .unwrap()
1653 .len(),
1654 1
1655 );
1656 let dead_jobs = q.jobs_with_status(Some(queue), "Dead", 100).await.unwrap();
1657 assert_eq!(dead_jobs.len(), 1);
1658 assert_eq!(dead_jobs[0].status, JobStatus::Dead);
1659 assert!(
1660 q.jobs_with_status(Some(queue), "Running", 100)
1661 .await
1662 .unwrap()
1663 .is_empty()
1664 );
1665 let everywhere = q.jobs_with_status(None, "Completed", 1000).await.unwrap();
1666 assert!(everywhere.iter().any(|j| j.queue_name == other));
1667
1668 let completed = FinishedKind::Completed;
1670 let dead = FinishedKind::Dead;
1671 assert_eq!(q.count_jobs(Some(queue), completed, None).await.unwrap(), 2);
1672 assert_eq!(q.count_jobs(Some(queue), dead, None).await.unwrap(), 1);
1673 let week_ago = Some(now - Duration::days(7));
1674 assert_eq!(q.count_jobs(Some(queue), dead, week_ago).await.unwrap(), 0);
1675 assert_eq!(
1676 q.delete_jobs(Some(queue), completed, None).await.unwrap(),
1677 2
1678 );
1679 assert_eq!(q.count_jobs(Some(queue), completed, None).await.unwrap(), 0);
1680 assert_eq!(q.count_jobs(Some(queue), dead, None).await.unwrap(), 1);
1681 assert_eq!(q.count_jobs(Some(other), completed, None).await.unwrap(), 1);
1682 }
1683
1684 async fn assert_dead_count_matches_delete<Q: JobHistory>(q: &Q, queue: &str) {
1686 let cutoff = Some(Utc::now() + Duration::hours(1));
1687 let counted = q
1688 .count_jobs(Some(queue), FinishedKind::Dead, cutoff)
1689 .await
1690 .unwrap();
1691 assert_eq!(counted, 1, "the dead row without failed_at is counted");
1692 let deleted = q
1693 .delete_jobs(Some(queue), FinishedKind::Dead, cutoff)
1694 .await
1695 .unwrap();
1696 assert_eq!(deleted, counted);
1697 }
1698
1699 #[tokio::test]
1700 #[ignore = "requires DATABASE_URL (PostgreSQL)"]
1701 async fn test_dead_job_without_failed_at_postgres() {
1702 let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
1703 let pool = sqlx::PgPool::connect(&url).await.unwrap();
1704 let queue = format!("dead_null_{}", uuid::Uuid::new_v4().simple());
1705 sqlx::query(
1706 "INSERT INTO hammerwork_jobs (id, queue_name, payload, status, priority, attempts, \
1707 max_attempts, created_at, scheduled_at) \
1708 VALUES ($1, $2, '{}'::jsonb, 'Dead', 2, 1, 3, NOW(), NOW())",
1709 )
1710 .bind(uuid::Uuid::new_v4())
1711 .bind(&queue)
1712 .execute(&pool)
1713 .await
1714 .unwrap();
1715 assert_dead_count_matches_delete(&JobQueue::<sqlx::Postgres>::new(pool.clone()), &queue)
1716 .await;
1717 sqlx::query("DELETE FROM hammerwork_jobs WHERE queue_name = $1")
1718 .bind(&queue)
1719 .execute(&pool)
1720 .await
1721 .unwrap();
1722 }
1723
1724 #[tokio::test]
1725 #[ignore = "requires MYSQL_DATABASE_URL"]
1726 async fn test_dead_job_without_failed_at_mysql() {
1727 let url = std::env::var("MYSQL_DATABASE_URL").expect("MYSQL_DATABASE_URL");
1728 let pool = sqlx::MySqlPool::connect(&url).await.unwrap();
1729 let queue = format!("dead_null_{}", uuid::Uuid::new_v4().simple());
1730 sqlx::query(
1731 "INSERT INTO hammerwork_jobs (id, queue_name, payload, status, priority, attempts, \
1732 max_attempts, created_at, scheduled_at) \
1733 VALUES (?, ?, '{}', 'Dead', 2, 1, 3, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))",
1734 )
1735 .bind(uuid::Uuid::new_v4().to_string())
1736 .bind(&queue)
1737 .execute(&pool)
1738 .await
1739 .unwrap();
1740 assert_dead_count_matches_delete(&JobQueue::<sqlx::MySql>::new(pool.clone()), &queue).await;
1741 sqlx::query("DELETE FROM hammerwork_jobs WHERE queue_name = ?")
1742 .bind(&queue)
1743 .execute(&pool)
1744 .await
1745 .unwrap();
1746 }
1747
1748 #[tokio::test]
1749 #[ignore = "requires DATABASE_URL (PostgreSQL)"]
1750 async fn test_job_history_queries_postgres() {
1751 let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
1752 let pool = sqlx::PgPool::connect(&url).await.unwrap();
1753 let tag = uuid::Uuid::new_v4().simple().to_string();
1754 let (queue, other) = (format!("hist_a_{tag}"), format!("hist_b_{tag}"));
1755 let now = Utc::now();
1756 for s in seeds(&queue, &other, now) {
1757 sqlx::query(
1758 "INSERT INTO hammerwork_jobs (id, queue_name, payload, status, priority, attempts, \
1759 max_attempts, created_at, scheduled_at, started_at, completed_at, failed_at, error_message) \
1760 VALUES ($1, $2, '{}'::jsonb, $3, 2, 1, 3, NOW(), NOW(), $4, $5, $6, $7)",
1761 )
1762 .bind(uuid::Uuid::new_v4())
1763 .bind(&s.queue)
1764 .bind(s.status)
1765 .bind(s.started)
1766 .bind(s.completed)
1767 .bind(s.failed)
1768 .bind(s.error)
1769 .execute(&pool)
1770 .await
1771 .unwrap();
1772 }
1773 let jq = JobQueue::<sqlx::Postgres>::new(pool.clone());
1774 assert_history(&jq, &queue, &other, now).await;
1775 for name in [&queue, &other] {
1776 sqlx::query("DELETE FROM hammerwork_jobs WHERE queue_name = $1")
1777 .bind(name)
1778 .execute(&pool)
1779 .await
1780 .unwrap();
1781 }
1782 }
1783
1784 #[tokio::test]
1785 #[ignore = "requires MYSQL_DATABASE_URL"]
1786 async fn test_job_history_queries_mysql() {
1787 let url = std::env::var("MYSQL_DATABASE_URL").expect("MYSQL_DATABASE_URL");
1788 let pool = sqlx::MySqlPool::connect(&url).await.unwrap();
1789 let tag = uuid::Uuid::new_v4().simple().to_string();
1790 let (queue, other) = (format!("hist_a_{tag}"), format!("hist_b_{tag}"));
1791 let now = Utc::now();
1792 for s in seeds(&queue, &other, now) {
1793 sqlx::query(
1794 "INSERT INTO hammerwork_jobs (id, queue_name, payload, status, priority, attempts, \
1795 max_attempts, created_at, scheduled_at, started_at, completed_at, failed_at, error_message) \
1796 VALUES (?, ?, '{}', ?, 2, 1, 3, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6), ?, ?, ?, ?)",
1797 )
1798 .bind(uuid::Uuid::new_v4().to_string())
1799 .bind(&s.queue)
1800 .bind(s.status)
1801 .bind(s.started)
1802 .bind(s.completed)
1803 .bind(s.failed)
1804 .bind(s.error)
1805 .execute(&pool)
1806 .await
1807 .unwrap();
1808 }
1809 let jq = JobQueue::<sqlx::MySql>::new(pool.clone());
1810 assert_history(&jq, &queue, &other, now).await;
1811 for name in [&queue, &other] {
1812 sqlx::query("DELETE FROM hammerwork_jobs WHERE queue_name = ?")
1813 .bind(name)
1814 .execute(&pool)
1815 .await
1816 .unwrap();
1817 }
1818 }
1819}