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;
12//! - a `QueueUpdate` message (event type `queue_updates`) for each queue whose statistics
13//!   changed.
14//!
15//! The work is bounded: nothing is queried while no client is connected, one poll reads
16//! at most [`WebSocketConfig::live_update_max_jobs`](crate::config::WebSocketConfig)
17//! changed jobs, and the memory of what was already sent only covers a short window.
18//!
19//! An application that embeds the dashboard in the same process as its workers can also
20//! forward their events directly with [`forward_job_events`], which delivers them without
21//! waiting for the next poll.
22
23use crate::api::history::JobHistory;
24use crate::websocket::{JobUpdate, QueueStats, ServerMessage, WebSocketState};
25use chrono::{DateTime, Utc};
26use hammerwork::events::{EventSubscription, JobLifecycleEvent, JobLifecycleEventType};
27use std::collections::HashMap;
28use std::sync::Arc;
29use std::time::Duration;
30use tokio::sync::RwLock;
31use tokio::task::JoinHandle;
32use tracing::{debug, warn};
33
34/// How far back each poll looks before the previous one, so a change whose transaction
35/// committed after that poll ran (with a timestamp from before it) is still seen.
36pub const POLL_OVERLAP: chrono::Duration = chrono::Duration::seconds(5);
37
38/// What a job looked like when it was last pushed: its status, attempts and change time.
39type SentState = (String, i32, DateTime<Utc>);
40
41/// The queue statistics last pushed for a queue: pending, running, completed, dead and
42/// timed out counts.
43type QueueCounts = [u64; 5];
44
45/// Polls the database for job and queue changes and pushes them to WebSocket clients.
46pub struct LiveUpdates<Q> {
47    queue: Arc<Q>,
48    websocket: Arc<RwLock<WebSocketState>>,
49    max_jobs: u32,
50    /// Database time of the previous poll; `None` before the first poll and while no
51    /// client is connected.
52    last_poll: Option<DateTime<Utc>>,
53    /// Jobs pushed within the overlap window, so the overlap does not push them twice.
54    sent: HashMap<String, SentState>,
55    /// Queue statistics last pushed, per queue.
56    queues: HashMap<String, QueueCounts>,
57}
58
59/// What one poll pushed.
60#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
61pub struct PollOutcome {
62    /// `JobUpdate` messages pushed.
63    pub jobs: usize,
64    /// `QueueUpdate` messages pushed.
65    pub queues: usize,
66}
67
68impl<Q: JobHistory + 'static> LiveUpdates<Q> {
69    /// Live updates from `queue` to the clients of `websocket`, reading at most `max_jobs`
70    /// changed jobs per poll.
71    pub fn new(queue: Arc<Q>, websocket: Arc<RwLock<WebSocketState>>, max_jobs: u32) -> Self {
72        Self {
73            queue,
74            websocket,
75            max_jobs: max_jobs.max(1),
76            last_poll: None,
77            sent: HashMap::new(),
78            queues: HashMap::new(),
79        }
80    }
81
82    /// Poll every `interval` in a background task. A failed poll is logged and retried at
83    /// the next interval.
84    pub fn spawn(mut self, interval: Duration) -> JoinHandle<()> {
85        tokio::spawn(async move {
86            let mut ticker = tokio::time::interval(interval);
87            ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
88            loop {
89                ticker.tick().await;
90                if let Err(e) = self.poll_once().await {
91                    warn!(error = %e, "Dashboard live update poll failed");
92                }
93            }
94        })
95    }
96
97    /// Push the changes since the previous poll. Does nothing (and forgets what was sent)
98    /// while no client is connected.
99    pub async fn poll_once(&mut self) -> hammerwork::Result<PollOutcome> {
100        if self.websocket.read().await.connection_count() == 0 {
101            self.last_poll = None;
102            self.sent.clear();
103            self.queues.clear();
104            return Ok(PollOutcome::default());
105        }
106
107        let now = self.queue.database_now().await?;
108        let since = self.last_poll.unwrap_or(now) - POLL_OVERLAP;
109        let changes = self.queue.recent_job_changes(since, self.max_jobs).await?;
110        let stats = self.queue.get_all_queue_stats().await?;
111        self.last_poll = Some(now);
112
113        let jobs = self.fresh_changes(changes, since);
114        let queues = self.changed_queues(stats);
115        let outcome = PollOutcome {
116            jobs: jobs.len(),
117            queues: queues.len(),
118        };
119
120        let websocket = self.websocket.read().await;
121        for job in jobs {
122            websocket
123                .broadcast_to_subscribed(ServerMessage::JobUpdate { job }, "job_updates")
124                .await
125                .map_err(|e| hammerwork::HammerworkError::Processing(e.to_string()))?;
126        }
127        for (queue_name, stats) in queues {
128            websocket
129                .broadcast_to_subscribed(
130                    ServerMessage::QueueUpdate { queue_name, stats },
131                    "queue_updates",
132                )
133                .await
134                .map_err(|e| hammerwork::HammerworkError::Processing(e.to_string()))?;
135        }
136        if outcome != PollOutcome::default() {
137            debug!(?outcome, "Pushed dashboard live updates");
138        }
139        Ok(outcome)
140    }
141
142    /// The changes not pushed yet, oldest first; remembers them and forgets the jobs
143    /// pushed before `since`.
144    fn fresh_changes(&mut self, changes: Vec<JobUpdate>, since: DateTime<Utc>) -> Vec<JobUpdate> {
145        self.sent
146            .retain(|_, (_, _, changed_at)| *changed_at > since);
147        let mut fresh: Vec<JobUpdate> = changes
148            .into_iter()
149            .filter(|job| {
150                let state = (job.status.clone(), job.attempts, job.updated_at);
151                self.sent.insert(job.id.clone(), state.clone()) != Some(state)
152            })
153            .collect();
154        fresh.sort_by_key(|job| job.updated_at);
155        fresh
156    }
157
158    /// The queues whose statistics changed since they were last pushed.
159    fn changed_queues(
160        &mut self,
161        stats: Vec<hammerwork::stats::QueueStats>,
162    ) -> Vec<(String, QueueStats)> {
163        let present: std::collections::HashSet<&str> =
164            stats.iter().map(|s| s.queue_name.as_str()).collect();
165        self.queues
166            .retain(|name, _| present.contains(name.as_str()));
167        let mut changed = Vec::new();
168        for queue in stats {
169            let counts = [
170                queue.pending_count,
171                queue.running_count,
172                queue.completed_count,
173                queue.dead_count,
174                queue.timed_out_count,
175            ];
176            if self.queues.insert(queue.queue_name.clone(), counts) == Some(counts) {
177                continue;
178            }
179            changed.push((
180                queue.queue_name,
181                QueueStats {
182                    pending_count: queue.pending_count,
183                    running_count: queue.running_count,
184                    completed_count: queue.completed_count,
185                    failed_count: queue.statistics.failed,
186                    dead_count: queue.dead_count,
187                    throughput_per_minute: queue.statistics.throughput_per_minute,
188                    avg_processing_time_ms: queue.statistics.avg_processing_time_ms,
189                    error_rate: queue.statistics.error_rate,
190                    updated_at: Utc::now(),
191                },
192            ));
193        }
194        changed
195    }
196}
197
198/// Forward the job lifecycle events of an in-process
199/// [`EventManager`](hammerwork::events::EventManager) subscription to the WebSocket
200/// clients subscribed to `job_updates`, until the event manager is dropped.
201///
202/// Only useful when the dashboard runs in the same process as the workers that publish
203/// the events; the standalone dashboard relies on [`LiveUpdates`]. Events the
204/// subscription's filter rejects are skipped, and a subscriber that falls behind skips the
205/// events it missed.
206pub fn forward_job_events(
207    websocket: Arc<RwLock<WebSocketState>>,
208    mut subscription: EventSubscription,
209) -> JoinHandle<()> {
210    use tokio::sync::broadcast::error::RecvError;
211    tokio::spawn(async move {
212        loop {
213            match subscription.receiver.recv().await {
214                Ok(event) if subscription.filter.matches(&event) => {
215                    let message = ServerMessage::JobUpdate {
216                        job: job_update_from_event(&event),
217                    };
218                    let state = websocket.read().await;
219                    if let Err(e) = state.broadcast_to_subscribed(message, "job_updates").await {
220                        warn!(error = %e, "Failed to forward a job event to dashboard clients");
221                    }
222                }
223                Ok(_) => {}
224                Err(RecvError::Lagged(missed)) => {
225                    debug!(missed, "Dashboard event forwarding fell behind");
226                }
227                Err(RecvError::Closed) => break,
228            }
229        }
230    })
231}
232
233/// The `JobUpdate` sent for a job lifecycle event.
234pub fn job_update_from_event(event: &JobLifecycleEvent) -> JobUpdate {
235    use JobLifecycleEventType as E;
236    let status = match event.event_type {
237        E::Enqueued | E::Retried | E::Restored => "Pending",
238        E::Started => "Running",
239        E::Completed => "Completed",
240        E::Failed => "Failed",
241        E::Dead => "Dead",
242        E::TimedOut => "TimedOut",
243        E::Cancelled => "Cancelled",
244        E::Archived => "Archived",
245    };
246    let attempts = event
247        .error
248        .as_ref()
249        .and_then(|error| error.retry_attempt)
250        .and_then(|attempt| i32::try_from(attempt).ok())
251        .unwrap_or_default();
252    JobUpdate {
253        id: event.job_id.to_string(),
254        queue_name: event.queue_name.clone(),
255        status: status.to_string(),
256        priority: event.priority.to_string(),
257        attempts,
258        updated_at: event.timestamp,
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::config::WebSocketConfig;
266    use hammerwork::events::{EventFilter, EventManager, JobLifecycleEventBuilder};
267    use hammerwork::priority::JobPriority;
268    use uuid::Uuid;
269    use warp::Filter;
270
271    type Shared = Arc<RwLock<WebSocketState>>;
272
273    fn new_state() -> Shared {
274        Arc::new(RwLock::new(WebSocketState::new(WebSocketConfig::default())))
275    }
276
277    async fn connect(state: &Shared) -> warp::test::WsClient {
278        let state = state.clone();
279        let route = warp::path("ws")
280            .and(warp::ws())
281            .map(move |ws: warp::ws::Ws| {
282                let state = state.clone();
283                ws.on_upgrade(move |socket| async move {
284                    let _ = WebSocketState::serve_connection(state, socket).await;
285                })
286            });
287        warp::test::ws()
288            .path("/ws")
289            .handshake(route)
290            .await
291            .expect("handshake")
292    }
293
294    async fn wait_for_connections(state: &Shared, expected: usize) {
295        for _ in 0..200 {
296            if state.read().await.connection_count() == expected {
297                return;
298            }
299            tokio::time::sleep(Duration::from_millis(10)).await;
300        }
301        panic!("expected {expected} connections");
302    }
303
304    /// The next text message as JSON, or `None` if nothing arrives soon.
305    async fn next_json(client: &mut warp::test::WsClient) -> Option<serde_json::Value> {
306        match tokio::time::timeout(Duration::from_millis(500), client.recv()).await {
307            Ok(Ok(message)) if message.is_text() => {
308                Some(serde_json::from_str(message.to_str().unwrap()).unwrap())
309            }
310            _ => None,
311        }
312    }
313
314    /// Subscribes `client` to `event_types` and waits for the server to apply it.
315    async fn subscribe(client: &mut warp::test::WsClient, event_types: &[&str]) {
316        let message = serde_json::json!({"type": "Subscribe", "event_types": event_types});
317        client.send_text(message.to_string()).await;
318        tokio::time::sleep(Duration::from_millis(100)).await;
319    }
320
321    #[test]
322    fn job_events_map_to_dashboard_statuses() {
323        let id = Uuid::new_v4();
324        let event = JobLifecycleEvent::failed(
325            id,
326            "emails".to_string(),
327            JobPriority::High,
328            hammerwork::events::JobError {
329                message: "boom".to_string(),
330                error_type: None,
331                details: None,
332                retry_attempt: Some(2),
333            },
334        );
335        let update = job_update_from_event(&event);
336        assert_eq!(update.id, id.to_string());
337        assert_eq!(update.queue_name, "emails");
338        assert_eq!(update.status, "Failed");
339        assert_eq!(update.priority, "high");
340        assert_eq!(update.attempts, 2);
341        assert_eq!(update.updated_at, event.timestamp);
342
343        let completed =
344            JobLifecycleEvent::completed(id, "emails".to_string(), JobPriority::Normal, 12);
345        assert_eq!(job_update_from_event(&completed).status, "Completed");
346        let started = JobLifecycleEvent::started(id, "emails".to_string(), JobPriority::Normal);
347        assert_eq!(job_update_from_event(&started).status, "Running");
348    }
349
350    /// #71: in-process job events reach the clients subscribed to `job_updates`, and only
351    /// those.
352    #[tokio::test]
353    async fn in_process_job_events_reach_subscribed_clients() {
354        let state = new_state();
355        let events = EventManager::new_default();
356        let subscription = events.subscribe(EventFilter::new()).await.unwrap();
357        let forwarder = forward_job_events(state.clone(), subscription);
358
359        let mut jobs = connect(&state).await;
360        let mut archive_only = connect(&state).await;
361        wait_for_connections(&state, 2).await;
362        subscribe(&mut jobs, &["job_updates"]).await;
363        subscribe(&mut archive_only, &["archive_events"]).await;
364
365        let id = Uuid::new_v4();
366        events
367            .publish_event(JobLifecycleEvent::completed(
368                id,
369                "emails".to_string(),
370                JobPriority::Normal,
371                5,
372            ))
373            .await
374            .unwrap();
375
376        let message = next_json(&mut jobs).await.expect("job update delivered");
377        assert_eq!(message["type"], "JobUpdate");
378        assert_eq!(message["job"]["id"], id.to_string());
379        assert_eq!(message["job"]["status"], "Completed");
380        assert!(next_json(&mut archive_only).await.is_none());
381
382        // The forwarder ends with the event manager.
383        drop(events);
384        tokio::time::timeout(Duration::from_secs(2), forwarder)
385            .await
386            .expect("forwarder stopped")
387            .unwrap();
388    }
389
390    fn change(id: &str, status: &str, at: DateTime<Utc>) -> JobUpdate {
391        JobUpdate {
392            id: id.to_string(),
393            queue_name: "q".to_string(),
394            status: status.to_string(),
395            priority: "normal".to_string(),
396            attempts: 0,
397            updated_at: at,
398        }
399    }
400
401    /// The overlap between polls does not push a change twice, a new state of the same
402    /// job is pushed, and the memory of sent changes only covers the overlap. (Async only
403    /// because the unreachable queue's lazy pool needs a runtime.)
404    #[tokio::test]
405    async fn overlapping_polls_push_each_change_once() {
406        let mut live = LiveUpdates::new(
407            crate::api::test_support::unreachable_queue(),
408            new_state(),
409            10,
410        );
411        let t0 = Utc::now();
412        let first = live.fresh_changes(
413            vec![
414                change("b", "Running", t0),
415                change("a", "Pending", t0 - POLL_OVERLAP / 2),
416            ],
417            t0 - POLL_OVERLAP,
418        );
419        let ids: Vec<&str> = first.iter().map(|j| j.id.as_str()).collect();
420        assert_eq!(ids, ["a", "b"], "oldest first");
421
422        let t1 = t0 + chrono::Duration::seconds(1);
423        let second = live.fresh_changes(
424            vec![
425                change("a", "Running", t1),
426                change("b", "Running", t0),
427                change("c", "Pending", t1),
428            ],
429            t1 - POLL_OVERLAP,
430        );
431        let pushed: Vec<(&str, &str)> = second
432            .iter()
433            .map(|j| (j.id.as_str(), j.status.as_str()))
434            .collect();
435        assert_eq!(pushed, [("a", "Running"), ("c", "Pending")]);
436
437        // Changes older than the overlap are forgotten.
438        live.fresh_changes(Vec::new(), t1 + chrono::Duration::hours(1));
439        assert!(live.sent.is_empty());
440    }
441
442    #[tokio::test]
443    async fn queue_statistics_are_pushed_when_they_change() {
444        let mut live = LiveUpdates::new(
445            crate::api::test_support::unreachable_queue(),
446            new_state(),
447            10,
448        );
449        let stats = |name: &str, pending: u64| hammerwork::stats::QueueStats {
450            queue_name: name.to_string(),
451            pending_count: pending,
452            running_count: 0,
453            dead_count: 0,
454            timed_out_count: 0,
455            completed_count: 0,
456            statistics: Default::default(),
457        };
458        let first = live.changed_queues(vec![stats("a", 1), stats("b", 0)]);
459        assert_eq!(first.len(), 2);
460        let second = live.changed_queues(vec![stats("a", 1), stats("b", 3)]);
461        assert_eq!(second.len(), 1);
462        assert_eq!(second[0].0, "b");
463        assert_eq!(second[0].1.pending_count, 3);
464        // A queue that disappears is forgotten.
465        live.changed_queues(vec![stats("a", 1)]);
466        assert_eq!(live.queues.len(), 1);
467    }
468
469    /// Without connected clients the poller does not touch the database (the queue here
470    /// is unreachable, so a query would fail).
471    #[tokio::test]
472    async fn without_clients_nothing_is_polled() {
473        let mut live = LiveUpdates::new(
474            crate::api::test_support::unreachable_queue(),
475            new_state(),
476            10,
477        );
478        assert_eq!(live.poll_once().await.unwrap(), PollOutcome::default());
479    }
480
481    /// #71: a client subscribed to job updates is told when a job changes state in the
482    /// database, whoever changed it; a client subscribed to other events is not.
483    async fn clients_receive_job_state_changes<Q: JobHistory + 'static>(queue: Arc<Q>) {
484        use hammerwork::Job;
485        let state = new_state();
486        let mut live = LiveUpdates::new(queue.clone(), state.clone(), 100);
487        let mut jobs = connect(&state).await;
488        let mut alerts_only = connect(&state).await;
489        wait_for_connections(&state, 2).await;
490        subscribe(&mut jobs, &["job_updates"]).await;
491        subscribe(&mut alerts_only, &["system_alerts"]).await;
492        live.poll_once().await.unwrap();
493        // Drain what the first poll pushed (other tests' recent jobs, queue statistics).
494        while next_json(&mut jobs).await.is_some() {}
495
496        let queue_name = format!("live_{}", Uuid::new_v4().simple());
497        let id = queue
498            .enqueue(Job::new(queue_name.clone(), serde_json::json!({})))
499            .await
500            .unwrap();
501        let pending = wait_for(&mut live, &mut jobs, id, "Pending").await;
502        assert_eq!(pending["job"]["queue_name"], queue_name.as_str());
503
504        let run = queue.dequeue(&queue_name).await.unwrap().expect("job");
505        queue
506            .finish_job_run(&run, hammerwork::queue::JobOutcome::Completed)
507            .await
508            .unwrap();
509        wait_for(&mut live, &mut jobs, id, "Completed").await;
510        assert!(
511            next_json(&mut alerts_only).await.is_none(),
512            "not subscribed to job updates"
513        );
514
515        queue.delete_job(id).await.unwrap();
516    }
517
518    /// Polls until `client` is told that job `id` is now `status` (the database clock may
519    /// be a moment behind).
520    async fn wait_for<Q: JobHistory + 'static>(
521        live: &mut LiveUpdates<Q>,
522        client: &mut warp::test::WsClient,
523        id: Uuid,
524        status: &str,
525    ) -> serde_json::Value {
526        for _ in 0..20 {
527            live.poll_once().await.unwrap();
528            while let Some(message) = next_json(client).await {
529                assert_eq!(message["type"], "JobUpdate", "{message}");
530                if message["job"]["id"] == id.to_string().as_str()
531                    && message["job"]["status"] == status
532                {
533                    return message;
534                }
535            }
536        }
537        panic!("no {status} update for job {id}");
538    }
539
540    #[tokio::test]
541    #[ignore = "requires DATABASE_URL (PostgreSQL)"]
542    async fn postgres_clients_receive_job_state_changes() {
543        let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
544        let pool = sqlx::PgPool::connect(&url).await.unwrap();
545        clients_receive_job_state_changes(Arc::new(hammerwork::JobQueue::new(pool))).await;
546    }
547
548    #[tokio::test]
549    #[ignore = "requires MYSQL_DATABASE_URL"]
550    async fn mysql_clients_receive_job_state_changes() {
551        let url = std::env::var("MYSQL_DATABASE_URL").expect("MYSQL_DATABASE_URL");
552        let pool = sqlx::MySqlPool::connect(&url).await.unwrap();
553        clients_receive_job_state_changes(Arc::new(hammerwork::JobQueue::new(pool))).await;
554    }
555}