Skip to main content

hammerwork_web/api/
history.rs

1//! Historical job queries backing the dashboard's trend, error-pattern and clear
2//! endpoints.
3//!
4//! [`DatabaseQueue`] has no way to ask "how many jobs completed or failed in each
5//! hour", "which errors occurred where" or "delete this queue's completed jobs". The
6//! dashboard talks to a concrete [`JobQueue`], so [`JobHistory`] answers those with
7//! parameterized SQL against `hammerwork_jobs` for both backends.
8//!
9//! "Failed" here means jobs *currently* in `Failed`, `Dead` or `TimedOut`, bucketed by
10//! when they failed (`failed_at`, falling back to `timed_out_at`). A job that failed and
11//! was then retried to success is counted only as completed, because retrying clears its
12//! failure timestamps.
13
14use 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/// Completed/failed activity in one UTC hour.
27#[derive(Debug, Clone, PartialEq, Serialize)]
28pub struct HourBucket {
29    /// Start of the hour (UTC).
30    pub hour: DateTime<Utc>,
31    pub completed: u64,
32    pub failed: u64,
33    /// Mean run time of the jobs completed in the hour, if any recorded one.
34    pub avg_processing_time_ms: Option<f64>,
35}
36
37/// One distinct error message with how often and where it happened.
38#[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/// Which finished jobs [`JobHistory::count_jobs`] / [`JobHistory::delete_jobs`] target.
48#[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    /// Column the `older_than` cutoff applies to.
63    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/// A table maintenance operation run from the dashboard.
72///
73/// | Operation  | PostgreSQL               | MySQL            |
74/// |------------|--------------------------|------------------|
75/// | `vacuum`   | `VACUUM (ANALYZE) <t>`   | `OPTIMIZE TABLE` |
76/// | `reindex`  | `REINDEX TABLE <t>`      | `OPTIMIZE TABLE` |
77/// | `optimize` | `ANALYZE <t>`            | `OPTIMIZE TABLE` |
78///
79/// MySQL has neither `VACUUM` nor `REINDEX`. For InnoDB, `OPTIMIZE TABLE` rebuilds the
80/// table and its indexes, reclaims free space and refreshes the statistics, which covers
81/// all three operations.
82#[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    /// Parses an operation name (`vacuum`, `reindex`, `optimize`).
92    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    /// The PostgreSQL statement for `table`, one of [`HAMMERWORK_TABLES`].
102    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    /// The MySQL statement for `table`, one of [`HAMMERWORK_TABLES`].
111    pub fn mysql_statement(self, table: &'static str) -> String {
112        format!("OPTIMIZE TABLE {table}")
113    }
114}
115
116/// Every table Hammerwork owns. Table maintenance only ever names these fixed identifiers;
117/// the ones absent from the database (e.g. before a migration created them) are skipped.
118pub 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
130/// Resolves an optional table name to the matching entries of [`HAMMERWORK_TABLES`].
131/// `None` selects every table; a name that is not a Hammerwork table is an error.
132pub 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/// One statement a table maintenance operation ran (or, on a dry run, would run).
144#[derive(Debug, Clone, PartialEq, Serialize)]
145pub struct MaintenanceStatement {
146    pub table: &'static str,
147    pub statement: String,
148    /// What the database reported (MySQL `OPTIMIZE TABLE` result rows); empty otherwise.
149    pub messages: Vec<String>,
150}
151
152/// The entries of `candidates` whose names appear in `existing` (names from the catalog).
153/// Statements are built from the fixed `candidates`, never from catalog strings.
154fn 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
162/// Maximum number of hourly buckets a single request may span.
163pub const MAX_BUCKETS: i64 = 24 * 31;
164
165/// Truncates to the start of the UTC hour.
166pub 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
171/// Dense list of hour starts covering `[start, end)`, oldest first.
172pub 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
182/// Merges sparse per-hour counts into a zero-filled series over `hours`.
183pub 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
206/// SQL-backed history queries. Implemented for PostgreSQL and MySQL job queues.
207pub trait JobHistory: DatabaseQueue + Send + Sync {
208    /// Zero-filled hourly activity over `[start, end)`, optionally for one queue.
209    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    /// Distinct error messages of failed jobs since `since`, most frequent first.
217    fn error_groups(
218        &self,
219        since: DateTime<Utc>,
220        limit: u32,
221    ) -> impl Future<Output = Result<Vec<ErrorGroup>>> + Send;
222
223    /// Number of finished jobs of `kind`, optionally per queue and older than a cutoff.
224    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    /// How many jobs [`archive_jobs`](DatabaseQueue::archive_jobs) with `policy` would
232    /// archive now, optionally for one queue: the jobs of each status the policy archives
233    /// that finished longer ago than the policy keeps that status, at most
234    /// `policy.batch_size`, and none when the policy is disabled. Used for dry runs.
235    ///
236    /// The default implementation returns an error, for implementations that predate
237    /// this method.
238    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    /// Deletes the jobs [`count_jobs`](Self::count_jobs) counts; returns how many.
252    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    /// One page of the archived jobs matching `filter`, newest first, and the number of
260    /// matching archived jobs. Filtering, counting and paging all happen in the database.
261    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    /// The database's current time, the clock the job state timestamps are written with.
269    fn database_now(&self) -> impl Future<Output = Result<DateTime<Utc>>> + Send;
270
271    /// Jobs whose state changed after `since`, newest first, at most `limit`: jobs that
272    /// were created, started, completed, failed or timed out since then. Each is reported
273    /// with the time of its latest such change. Rows that cannot be decoded are skipped.
274    ///
275    /// A job that goes from `Running` back to `Pending` (a retry, a stale job requeued by
276    /// the reaper, a claimed job released by a batch-mode worker) changes none of those
277    /// timestamps, so it is not reported here. The live-update poller
278    /// ([`LiveUpdates`](crate::live::LiveUpdates)) reports those transitions by tracking
279    /// the [running jobs](Self::running_jobs) and reading the [state](Self::job_states)
280    /// of the ones that stopped running.
281    fn recent_job_changes(
282        &self,
283        since: DateTime<Utc>,
284        limit: u32,
285    ) -> impl Future<Output = Result<Vec<JobUpdate>>> + Send;
286
287    /// The jobs that are `Running` now, most recently started first, at most `limit`,
288    /// each with its start time. Rows that cannot be decoded are skipped.
289    ///
290    /// The default implementation returns none, for implementations that predate this
291    /// method; live updates then miss `Running` to `Pending` transitions.
292    fn running_jobs(&self, limit: u32) -> impl Future<Output = Result<Vec<JobUpdate>>> + Send {
293        let _ = limit;
294        async { Ok(Vec::new()) }
295    }
296
297    /// Jobs with exactly `status` (a status as stored: `Pending`, `Running`, `Completed`,
298    /// `Failed`, `Dead`, `TimedOut`, `Retrying`), optionally for one queue, newest first,
299    /// at most `limit`. Pending jobs scheduled for later are included. Rows that cannot
300    /// be decoded are skipped.
301    ///
302    /// The default implementation returns an error, for implementations that predate
303    /// this method.
304    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    /// The current state of the jobs `ids` (UUIDs) that still exist, each with the time
319    /// of its latest change as in [`recent_job_changes`](Self::recent_job_changes). Ids
320    /// that are not UUIDs, jobs that no longer exist and rows that cannot be decoded are
321    /// left out.
322    ///
323    /// The default implementation reads the jobs one at a time with
324    /// [`DatabaseQueue::get_job`].
325    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    /// Runs `operation` on each of `tables` (entries of [`HAMMERWORK_TABLES`]) that
355    /// exists, one statement per table, and returns what ran. With `dry_run` nothing is
356    /// executed and the statements that would run are returned.
357    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
365/// The job timestamps that mark a change of state, newest first in
366/// [`JobHistory::recent_job_changes`].
367const CHANGE_COLUMNS: [&str; 5] = [
368    "created_at",
369    "started_at",
370    "completed_at",
371    "failed_at",
372    "timed_out_at",
373];
374
375/// `WHERE` clause of [`JobHistory::recent_job_changes`]: any change column after the
376/// cutoff. Each comparison can use an index on its column.
377fn 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
386/// A changed job as sent to dashboard clients.
387fn 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
407/// The statuses an archival policy archives, each with the timestamp its age is measured
408/// from and the policy's age for it (as `archive_jobs` selects them).
409fn 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
420/// The count query of [`JobHistory::count_archivable`] and the cutoffs to bind after the
421/// queue name (when `queue`), or `None` when the policy archives nothing.
422fn 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
471/// The time of a job's latest change on MySQL, where `GREATEST` is NULL if any argument
472/// is, so missing timestamps fall back to `created_at`.
473fn 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
482/// Job changes from PostgreSQL rows of `id, queue_name, status, priority, attempts,
483/// changed_at`, skipping rows that cannot be decoded.
484fn 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
502/// Job changes from MySQL rows of `id, queue_name, status, priority, attempts,
503/// changed_at`, skipping rows that cannot be decoded.
504fn 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/// A value bound to an archive filter placeholder.
522#[derive(Debug, Clone, PartialEq)]
523enum ArchiveBind {
524    Text(String),
525    Time(DateTime<Utc>),
526    Bool(bool),
527}
528
529/// The `WHERE` clause (empty without filters) for `filter`, with `$n`/`?` placeholders, and
530/// the values to bind to them in order. Statuses and reasons compare case-insensitively and
531/// ignore the JSON quotes older rows may carry.
532fn 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
578/// Binds every [`ArchiveBind`] to a query, in order.
579macro_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
593/// Columns of an archived job listing.
594const ARCHIVED_JOB_COLUMNS: &str = "id, queue_name, status, created_at, archived_at, \
595     archival_reason, original_payload_size, payload_compressed, archived_by";
596
597/// The status stored in the archive (possibly JSON-quoted); unknown values read as `Dead`,
598/// as the core library does.
599fn 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
616/// The rows of a dashboard listing that could be decoded. A row that cannot be decoded
617/// is skipped with a warning naming its id, so one corrupt row does not turn the whole
618/// page into an error.
619pub(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
631/// An archived job from a row of [`ARCHIVED_JOB_COLUMNS`], with the id already decoded.
632fn 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
660/// Filters shared by count and delete: `$n`/`?` placeholders, queue then cutoff.
661fn 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
681/// `SELECT` of the ids of [`JobHistory::jobs_with_status`]: the status, and the queue when
682/// `queue`, are bound.
683fn 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
700/// Reads the jobs `ids` (in order) with [`DatabaseQueue::get_job`]; ids that are not
701/// UUIDs, jobs that no longer exist and jobs that cannot be read are skipped.
702async 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        // GREATEST ignores NULLs on PostgreSQL.
908        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                // Simple-query protocol: VACUUM may not run inside a transaction block.
987                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                // OPTIMIZE TABLE reports failures as result rows, not as SQL errors.
1277                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
1298/// A text column of a MySQL admin statement's result, which may arrive as binary.
1299fn 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    /// #71: a dashboard listing skips the rows it cannot decode.
1323    #[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    /// #94: an archive dry run counts what `archive_jobs` would select.
1351    #[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(&nothing, 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    /// M11: archive filters become SQL, so the listing never reads the whole archive.
1423    #[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        // Without a queue filter the other queue's completion is included too.
1625        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        // Jobs can be listed by any status, scoped to a queue or not, up to a limit.
1643        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        // Counting and deleting are scoped to the queue and kind.
1669        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    /// #103: a dead job without `failed_at` is counted and deleted by the same predicate.
1685    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}