Skip to main content

hammerwork_web/
live.rs

1//! Live updates for connected dashboard clients.
2//!
3//! The dashboard normally runs as its own process (the `hammerwork-web` binary), apart
4//! from the workers. Job lifecycle events published to a worker's in-process
5//! [`EventManager`](hammerwork::events::EventManager) never reach it, so the dashboard
6//! learns about changes from the database they all share: [`LiveUpdates`] polls it on an
7//! interval and pushes what changed to the WebSocket clients, honouring each client's
8//! subscriptions:
9//!
10//! - a `JobUpdate` message (event type `job_updates`) for each job that was created,
11//!   started, completed, failed or timed out since the previous poll, or that went from
12//!   `Running` back to `Pending` (retried, requeued by the stale job reaper, or released
13//!   by a batch-mode worker). Those transitions leave no timestamp behind, so the poller
14//!   tracks the running jobs (up to the per-poll limit) and reads the state of the ones
15//!   that stopped running;
16//! - a `QueueUpdate` message (event type `queue_updates`) for each queue whose statistics
17//!   changed.
18//!
19//! The work is bounded: nothing is queried while no client is connected, one poll reads
20//! at most [`WebSocketConfig::live_update_max_jobs`](crate::config::WebSocketConfig)
21//! changed jobs, and the memory of what was already sent only covers a short window.
22//!
23//! An application that embeds the dashboard in the same process as its workers can also
24//! forward their events directly with [`forward_job_events`], which delivers them without
25//! waiting for the next poll.
26
27use crate::api::history::JobHistory;
28use crate::websocket::{JobUpdate, QueueStats, ServerMessage, WebSocketState};
29use chrono::{DateTime, Utc};
30use hammerwork::events::{EventSubscription, JobLifecycleEvent, JobLifecycleEventType};
31use std::collections::{HashMap, HashSet};
32use std::sync::Arc;
33use std::time::Duration;
34use tokio::sync::RwLock;
35use tokio::task::JoinHandle;
36use tracing::{debug, warn};
37
38/// How far back each poll looks before the previous one, so a change whose transaction
39/// committed after that poll ran (with a timestamp from before it) is still seen.
40pub const POLL_OVERLAP: chrono::Duration = chrono::Duration::seconds(5);
41
42/// What a job looked like when it was last pushed: its status, attempts and change time.
43type SentState = (String, i32, DateTime<Utc>);
44
45/// The queue statistics last pushed for a queue: pending, running, completed, failed,
46/// dead and timed out counts.
47type QueueCounts = [u64; 6];
48
49/// Polls the database for job and queue changes and pushes them to WebSocket clients.
50pub struct LiveUpdates<Q> {
51    queue: Arc<Q>,
52    websocket: Arc<RwLock<WebSocketState>>,
53    max_jobs: u32,
54    /// Database time of the previous poll; `None` before the first poll and while no
55    /// client is connected.
56    last_poll: Option<DateTime<Utc>>,
57    /// Jobs pushed within the overlap window, so the overlap does not push them twice.
58    sent: HashMap<String, SentState>,
59    /// Queue statistics last pushed, per queue.
60    queues: HashMap<String, QueueCounts>,
61    /// The jobs `Running` at the previous poll, so a job that goes
62    /// back to `Pending` (which changes none of the timestamps the change feed reads) is
63    /// still reported.
64    running: HashSet<String>,
65}
66
67/// What one poll pushed.
68#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
69pub struct PollOutcome {
70    /// `JobUpdate` messages pushed.
71    pub jobs: usize,
72    /// `QueueUpdate` messages pushed.
73    pub queues: usize,
74}
75
76impl<Q: JobHistory + 'static> LiveUpdates<Q> {
77    /// Live updates from `queue` to the clients of `websocket`, reading at most `max_jobs`
78    /// changed jobs per poll.
79    pub fn new(queue: Arc<Q>, websocket: Arc<RwLock<WebSocketState>>, max_jobs: u32) -> Self {
80        Self {
81            queue,
82            websocket,
83            max_jobs: max_jobs.max(1),
84            last_poll: None,
85            sent: HashMap::new(),
86            queues: HashMap::new(),
87            running: HashSet::new(),
88        }
89    }
90
91    /// Poll every `interval` in a background task. A failed poll is logged and retried at
92    /// the next interval.
93    pub fn spawn(mut self, interval: Duration) -> JoinHandle<()> {
94        tokio::spawn(async move {
95            let mut ticker = tokio::time::interval(interval);
96            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
97            loop {
98                ticker.tick().await;
99                if let Err(e) = self.poll_once().await {
100                    warn!(error = %e, "Dashboard live update poll failed");
101                }
102            }
103        })
104    }
105
106    /// Push the changes since the previous poll. Does nothing (and forgets what was sent)
107    /// while no client is connected.
108    pub async fn poll_once(&mut self) -> hammerwork::Result<PollOutcome> {
109        if self.websocket.read().await.connection_count() == 0 {
110            self.last_poll = None;
111            self.sent.clear();
112            self.queues.clear();
113            self.running.clear();
114            return Ok(PollOutcome::default());
115        }
116
117        let now = self.queue.database_now().await?;
118        let since = self.last_poll.unwrap_or(now) - POLL_OVERLAP;
119        let mut changes = self.queue.recent_job_changes(since, self.max_jobs).await?;
120        let running = self.queue.running_jobs(self.max_jobs).await?;
121        // Read the state of at most `max_jobs` of the jobs that left the running set per
122        // poll; the others stay tracked for the next polls.
123        let mut left = self.left_running(&changes, &running);
124        let unchecked = left.split_off(left.len().min(self.max_jobs as usize));
125        let states = if left.is_empty() {
126            Vec::new()
127        } else {
128            self.queue.job_states(&left).await?
129        };
130        let (still_running, stopped) = Self::stopped_running(&left, states, now);
131        self.running = running
132            .iter()
133            .chain(&changes)
134            .filter(|job| job.status == "Running")
135            .map(|job| job.id.clone())
136            .chain(still_running)
137            .chain(unchecked)
138            .collect();
139        changes.extend(stopped);
140        let stats = self.queue.get_all_queue_stats().await?;
141        self.last_poll = Some(now);
142
143        let jobs = self.fresh_changes(changes, since);
144        let queues = self.changed_queues(stats);
145        let outcome = PollOutcome {
146            jobs: jobs.len(),
147            queues: queues.len(),
148        };
149
150        let websocket = self.websocket.read().await;
151        for job in jobs {
152            websocket
153                .broadcast_to_subscribed(ServerMessage::JobUpdate { job }, "job_updates")
154                .await
155                .map_err(|e| hammerwork::HammerworkError::Processing(e.to_string()))?;
156        }
157        for (queue_name, stats) in queues {
158            websocket
159                .broadcast_to_subscribed(
160                    ServerMessage::QueueUpdate { queue_name, stats },
161                    "queue_updates",
162                )
163                .await
164                .map_err(|e| hammerwork::HammerworkError::Processing(e.to_string()))?;
165        }
166        if outcome != PollOutcome::default() {
167            debug!(?outcome, "Pushed dashboard live updates");
168        }
169        Ok(outcome)
170    }
171
172    /// The jobs tracked as `Running` that `running` (the running jobs now, up to the
173    /// per-poll limit) does not show and whose change is not in `changes`, sorted: they
174    /// stopped running without a timestamp the change feed reads, which is how a job
175    /// goes back to `Pending`, or they only fell out of the `running` window.
176    fn left_running(&self, changes: &[JobUpdate], running: &[JobUpdate]) -> Vec<String> {
177        let seen: HashSet<&str> = changes
178            .iter()
179            .chain(running)
180            .map(|job| job.id.as_str())
181            .collect();
182        let mut left: Vec<String> = self
183            .running
184            .iter()
185            .filter(|id| !seen.contains(id.as_str()))
186            .cloned()
187            .collect();
188        left.sort_unstable();
189        left
190    }
191
192    /// Splits `states`, the current state of the jobs `left` (see
193    /// [`left_running`](Self::left_running)), into the ids of those still `Running` (they
194    /// only fell out of the window) and the changes of those that stopped, reported at
195    /// `now`, when the change was seen: the timestamps they carry predate it. Jobs
196    /// missing from `states` no longer exist.
197    fn stopped_running(
198        left: &[String],
199        states: Vec<JobUpdate>,
200        now: DateTime<Utc>,
201    ) -> (Vec<String>, Vec<JobUpdate>) {
202        let left: HashSet<&str> = left.iter().map(String::as_str).collect();
203        let (running, stopped): (Vec<JobUpdate>, Vec<JobUpdate>) = states
204            .into_iter()
205            .filter(|job| left.contains(job.id.as_str()))
206            .partition(|job| job.status == "Running");
207        let stopped = stopped
208            .into_iter()
209            .map(|mut job| {
210                job.updated_at = now;
211                job
212            })
213            .collect();
214        (running.into_iter().map(|job| job.id).collect(), stopped)
215    }
216
217    /// The changes not pushed yet, oldest first; remembers them and forgets the jobs
218    /// pushed before `since`.
219    fn fresh_changes(&mut self, changes: Vec<JobUpdate>, since: DateTime<Utc>) -> Vec<JobUpdate> {
220        self.sent
221            .retain(|_, (_, _, changed_at)| *changed_at > since);
222        let mut fresh: Vec<JobUpdate> = changes
223            .into_iter()
224            .filter(|job| {
225                let state = (job.status.clone(), job.attempts, job.updated_at);
226                self.sent.insert(job.id.clone(), state.clone()) != Some(state)
227            })
228            .collect();
229        fresh.sort_by_key(|job| job.updated_at);
230        fresh
231    }
232
233    /// The queues whose statistics changed since they were last pushed.
234    fn changed_queues(
235        &mut self,
236        stats: Vec<hammerwork::stats::QueueStats>,
237    ) -> Vec<(String, QueueStats)> {
238        let present: HashSet<&str> = stats.iter().map(|s| s.queue_name.as_str()).collect();
239        self.queues
240            .retain(|name, _| present.contains(name.as_str()));
241        let mut changed = Vec::new();
242        for queue in stats {
243            let counts = [
244                queue.pending_count,
245                queue.running_count,
246                queue.completed_count,
247                queue.statistics.failed,
248                queue.dead_count,
249                queue.timed_out_count,
250            ];
251            if self.queues.insert(queue.queue_name.clone(), counts) == Some(counts) {
252                continue;
253            }
254            let failed_count = crate::api::failed_job_count(&queue);
255            changed.push((
256                queue.queue_name,
257                QueueStats {
258                    pending_count: queue.pending_count,
259                    running_count: queue.running_count,
260                    completed_count: queue.completed_count,
261                    failed_count,
262                    dead_count: queue.dead_count,
263                    throughput_per_minute: queue.statistics.throughput_per_minute,
264                    avg_processing_time_ms: queue.statistics.avg_processing_time_ms,
265                    error_rate: queue.statistics.error_rate,
266                    updated_at: Utc::now(),
267                },
268            ));
269        }
270        changed
271    }
272}
273
274/// Forward the job lifecycle events of an in-process
275/// [`EventManager`](hammerwork::events::EventManager) subscription to the WebSocket
276/// clients subscribed to `job_updates`, until the event manager is dropped.
277///
278/// Only useful when the dashboard runs in the same process as the workers that publish
279/// the events; the standalone dashboard relies on [`LiveUpdates`]. Events the
280/// subscription's filter rejects are skipped, and a subscriber that falls behind skips the
281/// events it missed.
282pub fn forward_job_events(
283    websocket: Arc<RwLock<WebSocketState>>,
284    mut subscription: EventSubscription,
285) -> JoinHandle<()> {
286    use tokio::sync::broadcast::error::RecvError;
287    tokio::spawn(async move {
288        loop {
289            match subscription.receiver.recv().await {
290                Ok(event) if subscription.filter.matches(&event) => {
291                    let message = ServerMessage::JobUpdate {
292                        job: job_update_from_event(&event),
293                    };
294                    let state = websocket.read().await;
295                    if let Err(e) = state.broadcast_to_subscribed(message, "job_updates").await {
296                        warn!(error = %e, "Failed to forward a job event to dashboard clients");
297                    }
298                }
299                Ok(_) => {}
300                Err(RecvError::Lagged(missed)) => {
301                    debug!(missed, "Dashboard event forwarding fell behind");
302                }
303                Err(RecvError::Closed) => break,
304            }
305        }
306    })
307}
308
309/// The `JobUpdate` sent for a job lifecycle event.
310pub fn job_update_from_event(event: &JobLifecycleEvent) -> JobUpdate {
311    use JobLifecycleEventType as E;
312    let status = match event.event_type {
313        E::Enqueued | E::Retried | E::Restored => "Pending",
314        E::Started => "Running",
315        E::Completed => "Completed",
316        E::Failed => "Failed",
317        E::Dead => "Dead",
318        E::TimedOut => "TimedOut",
319        E::Cancelled => "Cancelled",
320        E::Archived => "Archived",
321    };
322    let attempts = event
323        .error
324        .as_ref()
325        .and_then(|error| error.retry_attempt)
326        .and_then(|attempt| i32::try_from(attempt).ok())
327        .unwrap_or_default();
328    JobUpdate {
329        id: event.job_id.to_string(),
330        queue_name: event.queue_name.clone(),
331        status: status.to_string(),
332        priority: event.priority.to_string(),
333        attempts,
334        updated_at: event.timestamp,
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::config::WebSocketConfig;
342    use hammerwork::events::{EventFilter, EventManager, JobLifecycleEventBuilder};
343    use hammerwork::priority::JobPriority;
344    use uuid::Uuid;
345    use warp::Filter;
346
347    type Shared = Arc<RwLock<WebSocketState>>;
348
349    fn new_state() -> Shared {
350        Arc::new(RwLock::new(WebSocketState::new(WebSocketConfig::default())))
351    }
352
353    async fn connect(state: &Shared) -> warp::test::WsClient {
354        let state = state.clone();
355        let route = warp::path("ws")
356            .and(warp::ws())
357            .map(move |ws: warp::ws::Ws| {
358                let state = state.clone();
359                ws.on_upgrade(move |socket| async move {
360                    let _ = WebSocketState::serve_connection(state, socket).await;
361                })
362            });
363        warp::test::ws()
364            .path("/ws")
365            .handshake(route)
366            .await
367            .expect("handshake")
368    }
369
370    async fn wait_for_connections(state: &Shared, expected: usize) {
371        for _ in 0..200 {
372            if state.read().await.connection_count() == expected {
373                return;
374            }
375            tokio::time::sleep(Duration::from_millis(10)).await;
376        }
377        panic!("expected {expected} connections");
378    }
379
380    /// The next text message as JSON, or `None` if nothing arrives soon.
381    async fn next_json(client: &mut warp::test::WsClient) -> Option<serde_json::Value> {
382        match tokio::time::timeout(Duration::from_millis(500), client.recv()).await {
383            Ok(Ok(message)) if message.is_text() => {
384                Some(serde_json::from_str(message.to_str().unwrap()).unwrap())
385            }
386            _ => None,
387        }
388    }
389
390    /// Subscribes `client` to `event_types` and waits for the server to apply it.
391    async fn subscribe(client: &mut warp::test::WsClient, event_types: &[&str]) {
392        let message = serde_json::json!({"type": "Subscribe", "event_types": event_types});
393        client.send_text(message.to_string()).await;
394        tokio::time::sleep(Duration::from_millis(100)).await;
395    }
396
397    #[test]
398    fn job_events_map_to_dashboard_statuses() {
399        let id = Uuid::new_v4();
400        let event = JobLifecycleEvent::failed(
401            id,
402            "emails".to_string(),
403            JobPriority::High,
404            hammerwork::events::JobError {
405                message: "boom".to_string(),
406                error_type: None,
407                details: None,
408                retry_attempt: Some(2),
409            },
410        );
411        let update = job_update_from_event(&event);
412        assert_eq!(update.id, id.to_string());
413        assert_eq!(update.queue_name, "emails");
414        assert_eq!(update.status, "Failed");
415        assert_eq!(update.priority, "high");
416        assert_eq!(update.attempts, 2);
417        assert_eq!(update.updated_at, event.timestamp);
418
419        let completed =
420            JobLifecycleEvent::completed(id, "emails".to_string(), JobPriority::Normal, 12);
421        assert_eq!(job_update_from_event(&completed).status, "Completed");
422        let started = JobLifecycleEvent::started(id, "emails".to_string(), JobPriority::Normal);
423        assert_eq!(job_update_from_event(&started).status, "Running");
424    }
425
426    /// #71: in-process job events reach the clients subscribed to `job_updates`, and only
427    /// those.
428    #[tokio::test]
429    async fn in_process_job_events_reach_subscribed_clients() {
430        let state = new_state();
431        let events = EventManager::new_default();
432        let subscription = events.subscribe(EventFilter::new()).await.unwrap();
433        let forwarder = forward_job_events(state.clone(), subscription);
434
435        let mut jobs = connect(&state).await;
436        let mut archive_only = connect(&state).await;
437        wait_for_connections(&state, 2).await;
438        subscribe(&mut jobs, &["job_updates"]).await;
439        subscribe(&mut archive_only, &["archive_events"]).await;
440
441        let id = Uuid::new_v4();
442        events
443            .publish_event(JobLifecycleEvent::completed(
444                id,
445                "emails".to_string(),
446                JobPriority::Normal,
447                5,
448            ))
449            .await
450            .unwrap();
451
452        let message = next_json(&mut jobs).await.expect("job update delivered");
453        assert_eq!(message["type"], "JobUpdate");
454        assert_eq!(message["job"]["id"], id.to_string());
455        assert_eq!(message["job"]["status"], "Completed");
456        assert!(next_json(&mut archive_only).await.is_none());
457
458        // The forwarder ends with the event manager.
459        drop(events);
460        tokio::time::timeout(Duration::from_secs(2), forwarder)
461            .await
462            .expect("forwarder stopped")
463            .unwrap();
464    }
465
466    fn change(id: &str, status: &str, at: DateTime<Utc>) -> JobUpdate {
467        JobUpdate {
468            id: id.to_string(),
469            queue_name: "q".to_string(),
470            status: status.to_string(),
471            priority: "normal".to_string(),
472            attempts: 0,
473            updated_at: at,
474        }
475    }
476
477    /// The overlap between polls does not push a change twice, a new state of the same
478    /// job is pushed, and the memory of sent changes only covers the overlap. (Async only
479    /// because the unreachable queue's lazy pool needs a runtime.)
480    #[tokio::test]
481    async fn overlapping_polls_push_each_change_once() {
482        let mut live = LiveUpdates::new(
483            crate::api::test_support::unreachable_queue(),
484            new_state(),
485            10,
486        );
487        let t0 = Utc::now();
488        let first = live.fresh_changes(
489            vec![
490                change("b", "Running", t0),
491                change("a", "Pending", t0 - POLL_OVERLAP / 2),
492            ],
493            t0 - POLL_OVERLAP,
494        );
495        let ids: Vec<&str> = first.iter().map(|j| j.id.as_str()).collect();
496        assert_eq!(ids, ["a", "b"], "oldest first");
497
498        let t1 = t0 + chrono::Duration::seconds(1);
499        let second = live.fresh_changes(
500            vec![
501                change("a", "Running", t1),
502                change("b", "Running", t0),
503                change("c", "Pending", t1),
504            ],
505            t1 - POLL_OVERLAP,
506        );
507        let pushed: Vec<(&str, &str)> = second
508            .iter()
509            .map(|j| (j.id.as_str(), j.status.as_str()))
510            .collect();
511        assert_eq!(pushed, [("a", "Running"), ("c", "Pending")]);
512
513        // Changes older than the overlap are forgotten.
514        live.fresh_changes(Vec::new(), t1 + chrono::Duration::hours(1));
515        assert!(live.sent.is_empty());
516    }
517
518    #[tokio::test]
519    async fn queue_statistics_are_pushed_when_they_change() {
520        let mut live = LiveUpdates::new(
521            crate::api::test_support::unreachable_queue(),
522            new_state(),
523            10,
524        );
525        let stats = |name: &str, pending: u64| hammerwork::stats::QueueStats {
526            queue_name: name.to_string(),
527            pending_count: pending,
528            running_count: 0,
529            dead_count: 0,
530            timed_out_count: 0,
531            completed_count: 0,
532            statistics: Default::default(),
533        };
534        let first = live.changed_queues(vec![stats("a", 1), stats("b", 0)]);
535        assert_eq!(first.len(), 2);
536        let second = live.changed_queues(vec![stats("a", 1), stats("b", 3)]);
537        assert_eq!(second.len(), 1);
538        assert_eq!(second[0].0, "b");
539        assert_eq!(second[0].1.pending_count, 3);
540        // A queue that disappears is forgotten.
541        live.changed_queues(vec![stats("a", 1)]);
542        assert_eq!(live.queues.len(), 1);
543
544        // A change in the `Failed` jobs alone is pushed, and `failed_count` counts every
545        // failed job (`Failed`, `Dead` and `TimedOut`), as the REST API does.
546        let mut failing = stats("a", 1);
547        failing.statistics.failed = 2;
548        failing.dead_count = 3;
549        failing.timed_out_count = 4;
550        live.changed_queues(vec![failing.clone()]);
551        let mut more_failed = failing.clone();
552        more_failed.statistics.failed = 5;
553        let changed = live.changed_queues(vec![more_failed]);
554        assert_eq!(changed.len(), 1, "a change in Failed jobs is pushed");
555        assert_eq!(changed[0].1.failed_count, 5 + 3 + 4);
556        assert_eq!(changed[0].1.dead_count, 3);
557    }
558
559    /// Without connected clients the poller does not touch the database (the queue here
560    /// is unreachable, so a query would fail).
561    #[tokio::test]
562    async fn without_clients_nothing_is_polled() {
563        let mut live = LiveUpdates::new(
564            crate::api::test_support::unreachable_queue(),
565            new_state(),
566            10,
567        );
568        assert_eq!(live.poll_once().await.unwrap(), PollOutcome::default());
569    }
570
571    /// #71: a client subscribed to job updates is told when a job changes state in the
572    /// database, whoever changed it; a client subscribed to other events is not.
573    async fn clients_receive_job_state_changes<Q: JobHistory + 'static>(queue: Arc<Q>) {
574        use hammerwork::Job;
575        let state = new_state();
576        let mut live = LiveUpdates::new(queue.clone(), state.clone(), 100);
577        let mut jobs = connect(&state).await;
578        let mut alerts_only = connect(&state).await;
579        wait_for_connections(&state, 2).await;
580        subscribe(&mut jobs, &["job_updates"]).await;
581        subscribe(&mut alerts_only, &["system_alerts"]).await;
582        live.poll_once().await.unwrap();
583        // Drain what the first poll pushed (other tests' recent jobs, queue statistics).
584        while next_json(&mut jobs).await.is_some() {}
585
586        let queue_name = format!("live_{}", Uuid::new_v4().simple());
587        let id = queue
588            .enqueue(Job::new(queue_name.clone(), serde_json::json!({})))
589            .await
590            .unwrap();
591        let pending = wait_for(&mut live, &mut jobs, id, "Pending").await;
592        assert_eq!(pending["job"]["queue_name"], queue_name.as_str());
593
594        let run = queue.dequeue(&queue_name).await.unwrap().expect("job");
595        queue
596            .finish_job_run(&run, hammerwork::queue::JobOutcome::Completed)
597            .await
598            .unwrap();
599        wait_for(&mut live, &mut jobs, id, "Completed").await;
600        assert!(
601            next_json(&mut alerts_only).await.is_none(),
602            "not subscribed to job updates"
603        );
604
605        queue.delete_job(id).await.unwrap();
606    }
607
608    /// #94: a job that goes from `Running` back to `Pending` changes none of the
609    /// timestamps the change feed reads; the poller still reports it. The job is created
610    /// well before the poll window, so only the tracking of running jobs can see it.
611    async fn clients_see_running_jobs_go_back_to_pending<Q: JobHistory + 'static>(queue: Arc<Q>) {
612        use hammerwork::Job;
613        let state = new_state();
614        let mut live = LiveUpdates::new(queue.clone(), state.clone(), 100);
615        let mut jobs = connect(&state).await;
616        wait_for_connections(&state, 1).await;
617        subscribe(&mut jobs, &["job_updates"]).await;
618        live.poll_once().await.unwrap();
619        while next_json(&mut jobs).await.is_some() {}
620
621        let queue_name = format!("live_back_{}", Uuid::new_v4().simple());
622        let mut job = Job::new(queue_name.clone(), serde_json::json!({}));
623        job.created_at = Utc::now() - chrono::Duration::hours(1);
624        job.scheduled_at = job.created_at;
625        let id = queue.enqueue(job).await.unwrap();
626        let cleanup = CleanupJob {
627            queue: queue.clone(),
628            id,
629        };
630
631        let run = queue.dequeue(&queue_name).await.unwrap().expect("job");
632        wait_for(&mut live, &mut jobs, id, "Running").await;
633        assert!(queue.release_job_run(&run).await.unwrap());
634        let released = wait_for(&mut live, &mut jobs, id, "Pending").await;
635        assert_eq!(released["job"]["attempts"], 0);
636
637        // A retry of a run goes back to Pending too.
638        let run = queue.dequeue(&queue_name).await.unwrap().expect("job");
639        wait_for(&mut live, &mut jobs, id, "Running").await;
640        queue
641            .finish_job_run(
642                &run,
643                hammerwork::queue::JobOutcome::Retry {
644                    retry_at: Utc::now() + chrono::Duration::hours(1),
645                    error: "again".to_string(),
646                    timed_out: false,
647                },
648            )
649            .await
650            .unwrap();
651        let retried = wait_for(&mut live, &mut jobs, id, "Pending").await;
652        assert_eq!(retried["job"]["attempts"], 1);
653        drop(cleanup);
654    }
655
656    /// Deletes a test job when dropped, so it is removed even if the test fails.
657    struct CleanupJob<Q: JobHistory + 'static> {
658        queue: Arc<Q>,
659        id: Uuid,
660    }
661
662    impl<Q: JobHistory + 'static> Drop for CleanupJob<Q> {
663        fn drop(&mut self) {
664            let (queue, id) = (self.queue.clone(), self.id);
665            // A test's runtime may be shutting down; deleting is best effort.
666            let _ = tokio::task::block_in_place(|| {
667                tokio::runtime::Handle::current().block_on(queue.delete_job(id))
668            });
669        }
670    }
671
672    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
673    #[ignore = "requires DATABASE_URL (PostgreSQL)"]
674    async fn postgres_clients_see_running_jobs_go_back_to_pending() {
675        let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
676        let pool = sqlx::PgPool::connect(&url).await.unwrap();
677        clients_see_running_jobs_go_back_to_pending(Arc::new(hammerwork::JobQueue::new(pool)))
678            .await;
679    }
680
681    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
682    #[ignore = "requires MYSQL_DATABASE_URL"]
683    async fn mysql_clients_see_running_jobs_go_back_to_pending() {
684        let url = std::env::var("MYSQL_DATABASE_URL").expect("MYSQL_DATABASE_URL");
685        let pool = sqlx::MySqlPool::connect(&url).await.unwrap();
686        clients_see_running_jobs_go_back_to_pending(Arc::new(hammerwork::JobQueue::new(pool)))
687            .await;
688    }
689
690    /// A tracked running job that the change feed and the running set no longer show is
691    /// looked up; one that went back to Pending is reported at the poll time, one still
692    /// running (out of the window) stays tracked, and a deleted one is forgotten.
693    #[tokio::test]
694    async fn jobs_that_stop_running_without_a_timestamp_are_reported() {
695        let mut live = LiveUpdates::new(
696            crate::api::test_support::unreachable_queue(),
697            new_state(),
698            10,
699        );
700        let t0 = Utc::now();
701        live.running = ["a", "b", "c", "d"].map(String::from).into_iter().collect();
702        let left = live.left_running(
703            &[change("d", "Completed", t0)],
704            &[change("c", "Running", t0)],
705        );
706        assert_eq!(left, ["a", "b"]);
707
708        let now = t0 + chrono::Duration::seconds(5);
709        let (still_running, stopped) =
710            LiveUpdates::<hammerwork::JobQueue<sqlx::Postgres>>::stopped_running(
711                &left,
712                vec![
713                    change("a", "Pending", t0 - chrono::Duration::hours(1)),
714                    change("b", "Running", t0),
715                    change("x", "Pending", t0),
716                ],
717                now,
718            );
719        assert_eq!(still_running, ["b"]);
720        assert_eq!(stopped.len(), 1);
721        assert_eq!(stopped[0].id, "a");
722        assert_eq!(stopped[0].status, "Pending");
723        assert_eq!(
724            stopped[0].updated_at, now,
725            "reported when the change was seen"
726        );
727    }
728
729    /// Polls until `client` is told that job `id` is now `status` (the database clock may
730    /// be a moment behind).
731    async fn wait_for<Q: JobHistory + 'static>(
732        live: &mut LiveUpdates<Q>,
733        client: &mut warp::test::WsClient,
734        id: Uuid,
735        status: &str,
736    ) -> serde_json::Value {
737        for _ in 0..20 {
738            live.poll_once().await.unwrap();
739            while let Some(message) = next_json(client).await {
740                assert_eq!(message["type"], "JobUpdate", "{message}");
741                if message["job"]["id"] == id.to_string().as_str()
742                    && message["job"]["status"] == status
743                {
744                    return message;
745                }
746            }
747        }
748        panic!("no {status} update for job {id}");
749    }
750
751    #[tokio::test]
752    #[ignore = "requires DATABASE_URL (PostgreSQL)"]
753    async fn postgres_clients_receive_job_state_changes() {
754        let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
755        let pool = sqlx::PgPool::connect(&url).await.unwrap();
756        clients_receive_job_state_changes(Arc::new(hammerwork::JobQueue::new(pool))).await;
757    }
758
759    #[tokio::test]
760    #[ignore = "requires MYSQL_DATABASE_URL"]
761    async fn mysql_clients_receive_job_state_changes() {
762        let url = std::env::var("MYSQL_DATABASE_URL").expect("MYSQL_DATABASE_URL");
763        let pool = sqlx::MySqlPool::connect(&url).await.unwrap();
764        clients_receive_job_state_changes(Arc::new(hammerwork::JobQueue::new(pool))).await;
765    }
766}