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