1use 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
34pub const POLL_OVERLAP: chrono::Duration = chrono::Duration::seconds(5);
37
38type SentState = (String, i32, DateTime<Utc>);
40
41type QueueCounts = [u64; 5];
44
45pub struct LiveUpdates<Q> {
47 queue: Arc<Q>,
48 websocket: Arc<RwLock<WebSocketState>>,
49 max_jobs: u32,
50 last_poll: Option<DateTime<Utc>>,
53 sent: HashMap<String, SentState>,
55 queues: HashMap<String, QueueCounts>,
57}
58
59#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
61pub struct PollOutcome {
62 pub jobs: usize,
64 pub queues: usize,
66}
67
68impl<Q: JobHistory + 'static> LiveUpdates<Q> {
69 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 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 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 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 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
198pub 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
233pub 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 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 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 #[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 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 #[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 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 live.changed_queues(vec![stats("a", 1)]);
466 assert_eq!(live.queues.len(), 1);
467 }
468
469 #[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 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 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 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}