1use std::collections::{
2 HashMap,
3 VecDeque,
4};
5use std::sync::atomic::{
6 AtomicU64,
7 Ordering,
8};
9use std::sync::{
10 Arc,
11 Mutex,
12 PoisonError,
13};
14use std::time::Duration;
15
16use async_trait::async_trait;
17use chrono::Utc;
18use tokio::sync::mpsc::{
19 self,
20 error::TrySendError,
21};
22use tokio_util::sync::CancellationToken;
23
24use super::health_service::{
25 LocalNotification,
26 NotificationState,
27 ObservedNotification,
28};
29use super::pipeline_service::PipelineService;
30use super::provider_service::ProviderService;
31use crate::domain::{
32 AlertKind,
33 DomainError,
34 NewDeviceAlert,
35 NotificationPreferences,
36 RunFinish,
37};
38use crate::event::{
39 CoreEvent,
40 EventBus,
41};
42use crate::infrastructure::database::{
43 DeviceAlertRepository,
44 DeviceRepository,
45};
46
47const CHANNEL_CAPACITY: usize = 512;
48const PRUNE_INTERVAL: Duration = Duration::from_secs(3600);
49const RECENT_LIMIT: usize = 256;
50
51pub struct RecentNotifications {
52 entries: Mutex<VecDeque<ObservedNotification>>,
53 next_id: AtomicU64,
54}
55
56impl Default for RecentNotifications {
57 fn default() -> Self {
58 Self {
59 entries: Mutex::new(VecDeque::with_capacity(RECENT_LIMIT)),
60 next_id: AtomicU64::new(
61 u64::try_from(Utc::now().timestamp_micros()).unwrap_or_default(),
62 ),
63 }
64 }
65}
66
67impl RecentNotifications {
68 fn observe(&self, notice: LocalNotification) -> ObservedNotification {
69 let observed = ObservedNotification {
70 id: self.next_id.fetch_add(1, Ordering::Relaxed),
71 at: Utc::now(),
72 notice,
73 };
74 let mut entries = self.entries.lock().unwrap_or_else(PoisonError::into_inner);
75 if entries.len() == RECENT_LIMIT {
76 entries.pop_front();
77 }
78 entries.push_back(observed.clone());
79
80 observed
81 }
82
83 pub fn after(&self, after: u64) -> Vec<ObservedNotification> {
84 self.entries
85 .lock()
86 .unwrap_or_else(PoisonError::into_inner)
87 .iter()
88 .filter(|observed| observed.id > after)
89 .cloned()
90 .collect()
91 }
92}
93
94struct RecordingBus {
95 inner: Arc<dyn EventBus>,
96 tx: mpsc::Sender<CoreEvent>,
97}
98
99fn is_observed(event: &CoreEvent) -> bool {
100 matches!(
101 event,
102 CoreEvent::PipelinesUpdated { .. }
103 | CoreEvent::PipelinesFailed { .. }
104 | CoreEvent::ProviderHealthAlert { .. }
105 | CoreEvent::ProviderAdded { .. }
106 | CoreEvent::ProviderUpdated { .. }
107 | CoreEvent::ProviderStatusUpdated { .. }
108 | CoreEvent::ProviderRemoved { .. }
109 )
110}
111
112#[async_trait]
113impl EventBus for RecordingBus {
114 async fn emit(&self, event: CoreEvent) {
115 if is_observed(&event) {
116 if let Err(TrySendError::Full(_)) = self.tx.try_send(event.clone()) {
117 tracing::debug!("Alert queue is full; an observation was dropped");
118 }
119 }
120 self.inner.emit(event).await;
121 }
122
123 async fn emit_to(&self, target: &str, event: CoreEvent) {
124 self.inner.emit_to(target, event).await;
125 }
126}
127
128pub fn recording_bus(inner: Arc<dyn EventBus>) -> (Arc<dyn EventBus>, mpsc::Receiver<CoreEvent>) {
129 let (tx, rx) = mpsc::channel(CHANNEL_CAPACITY);
130
131 (Arc::new(RecordingBus { inner, tx }), rx)
132}
133
134pub async fn seed(pipelines: &PipelineService, providers: &ProviderService) -> NotificationState {
135 let mut state = NotificationState::default();
136 let cached = async {
137 Ok::<_, DomainError>((
138 pipelines.get_cached_pipelines(None).await?,
139 providers.list_providers().await?,
140 ))
141 };
142 match cached.await {
143 Ok((pipelines, providers)) => state.seed(&pipelines, &providers),
144 Err(error) => tracing::warn!(%error, "Failed to seed the alert recorder"),
145 }
146
147 state
148}
149
150pub async fn record(
151 rx: mpsc::Receiver<CoreEvent>, state: NotificationState, stop: CancellationToken,
152 alerts: DeviceAlertRepository, devices: DeviceRepository, bus: Arc<dyn EventBus>,
153 recent: Arc<RecentNotifications>,
154) {
155 record_pruning(
156 rx,
157 state,
158 stop,
159 alerts,
160 devices,
161 bus,
162 recent,
163 PRUNE_INTERVAL,
164 )
165 .await;
166}
167
168#[allow(clippy::too_many_arguments)]
169async fn record_pruning(
170 mut rx: mpsc::Receiver<CoreEvent>, mut state: NotificationState, stop: CancellationToken,
171 alerts: DeviceAlertRepository, devices: DeviceRepository, bus: Arc<dyn EventBus>,
172 recent: Arc<RecentNotifications>, prune_every: Duration,
173) {
174 let mut prune = tokio::time::interval(prune_every);
175 prune.tick().await;
176 loop {
177 let event = tokio::select! {
178 biased;
179 _ = stop.cancelled() => break,
180 _ = prune.tick() => {
181 if let Err(error) = alerts.prune().await {
182 tracing::warn!(%error, "Failed to prune old device alerts");
183 }
184 continue;
185 }
186 event = rx.recv() => match event {
187 Some(event) => event,
188 None => break,
189 },
190 };
191 let notices = state.observe_event(&event);
192 if notices.is_empty() {
193 continue;
194 }
195 for notice in ¬ices {
196 bus.emit(CoreEvent::NotificationObserved {
197 notification: recent.observe(notice.clone()),
198 })
199 .await;
200 }
201 match devices.list().await {
202 Ok(paired) if paired.is_empty() => continue,
203 Ok(_) => {}
204 Err(error) => {
205 tracing::warn!(%error, "Failed to list paired devices for alerts");
206 continue;
207 }
208 }
209 let mut watchers = if notices.iter().any(|notice| notice.finish.is_some()) {
210 match devices.notification_preferences().await {
211 Ok(preferences) => preferences,
212 Err(error) => {
213 tracing::warn!(%error, "Failed to load who watches finished runs");
214 HashMap::new()
215 }
216 }
217 } else {
218 HashMap::new()
219 };
220 for notice in notices {
221 if notice.kind == AlertKind::Finished && !wanted_by_any(&watchers, ¬ice) {
222 continue;
223 }
224 if let (Some(pipeline_id), Some(finish)) = (¬ice.pipeline_id, ¬ice.finish) {
225 fire_once(&devices, &mut watchers, pipeline_id, finish).await;
226 }
227 let alert = NewDeviceAlert {
228 key: notice.key,
229 kind: notice.kind,
230 title: notice.title,
231 body: notice.body,
232 pipeline_id: notice.pipeline_id,
233 provider_id: notice.provider_id,
234 provider_ids: notice.provider_ids,
235 finish: notice.finish,
236 };
237 match alerts.insert(&alert).await {
238 Ok(stored) => {
239 bus.emit(CoreEvent::AlertRecorded {
240 id: stored.id,
241 seq: stored.seq,
242 })
243 .await;
244 }
245 Err(error) => tracing::warn!(%error, "Failed to record a device alert"),
246 }
247 }
248 }
249}
250
251fn wanted_by_any(
252 watchers: &HashMap<String, NotificationPreferences>, notice: &LocalNotification,
253) -> bool {
254 watchers.values().any(|preferences| {
255 preferences.allows(
256 notice.kind,
257 ¬ice.provider_ids,
258 notice.pipeline_id.as_deref(),
259 notice.finish.as_ref(),
260 )
261 })
262}
263
264async fn fire_once(
265 devices: &DeviceRepository, watchers: &mut HashMap<String, NotificationPreferences>,
266 pipeline_id: &str, finish: &RunFinish,
267) {
268 for (device_id, preferences) in watchers.iter_mut() {
269 if preferences.fire_once(pipeline_id, finish).is_empty() {
270 continue;
271 }
272 match devices
273 .update_notification_preferences(device_id, |stored| {
274 !stored.fire_once(pipeline_id, finish).is_empty()
275 })
276 .await
277 {
278 Ok(Some(saved)) => *preferences = saved,
279 Ok(None) => {}
280 Err(error) => tracing::warn!(%error, "Failed to mark a one-time watch as used"),
281 }
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use chrono::{
288 Duration,
289 TimeZone,
290 Utc,
291 };
292
293 use super::*;
294 use crate::application::test_support::{
295 pipeline,
296 Harness,
297 };
298 use crate::domain::{
299 AlertKind,
300 DeviceAlert,
301 PairedDevice,
302 Pipeline,
303 PipelineStatus,
304 };
305 use crate::infrastructure::database::DatabasePool;
306
307 fn run(status: PipelineStatus, minute: i64) -> Pipeline {
308 let mut pipeline = pipeline(1, "api", status);
309 pipeline.last_run =
310 Some(Utc.with_ymd_and_hms(2026, 11, 3, 12, 0, 0).unwrap() + Duration::minutes(minute));
311 pipeline
312 }
313
314 fn updated(pipeline: Pipeline) -> CoreEvent {
315 CoreEvent::PipelinesUpdated {
316 pipelines: vec![pipeline],
317 provider_id: 1,
318 timestamp: 0,
319 }
320 }
321
322 async fn recorded(h: &Harness, paired: bool, events: Vec<CoreEvent>) -> Vec<DeviceAlert> {
323 let pool = DatabasePool::Sqlite(h.pool.clone());
324 let devices = DeviceRepository::new(pool.clone());
325 if paired {
326 devices
327 .insert(&PairedDevice::issue("Pixel", "android").0)
328 .await
329 .unwrap();
330 }
331 let (bus, rx) = recording_bus(h.bus.clone());
332 let state = seed(&h.pipeline_service, &h.provider_service).await;
333 let task = tokio::spawn(record(
334 rx,
335 state,
336 CancellationToken::new(),
337 DeviceAlertRepository::new(pool.clone()),
338 devices,
339 h.bus.clone(),
340 Arc::default(),
341 ));
342 for event in events {
343 bus.emit(event).await;
344 }
345 drop(bus);
346 task.await.unwrap();
347
348 DeviceAlertRepository::new(pool).list().await.unwrap()
349 }
350
351 #[tokio::test]
352 async fn the_recorder_stops_when_cancelled_while_events_can_still_arrive() {
353 let h = Harness::new().await;
354 let pool = DatabasePool::Sqlite(h.pool.clone());
355 let (bus, rx) = recording_bus(h.bus.clone());
356 let stop = CancellationToken::new();
357 let task = tokio::spawn(record(
358 rx,
359 NotificationState::default(),
360 stop.clone(),
361 DeviceAlertRepository::new(pool.clone()),
362 DeviceRepository::new(pool),
363 h.bus.clone(),
364 Arc::default(),
365 ));
366
367 stop.cancel();
368
369 tokio::time::timeout(std::time::Duration::from_secs(5), task)
370 .await
371 .expect("recorder kept running after cancel")
372 .unwrap();
373 drop(bus);
374 }
375
376 #[tokio::test]
377 async fn the_recorder_prunes_expired_alerts_periodically() {
378 let h = Harness::new().await;
379 let pool = DatabasePool::Sqlite(h.pool.clone());
380 let alerts = DeviceAlertRepository::new(pool.clone());
381 let stored = alerts
382 .insert(&NewDeviceAlert {
383 key: "pipeline:p".into(),
384 kind: AlertKind::Failed,
385 title: "t".into(),
386 body: "b".into(),
387 pipeline_id: None,
388 provider_id: None,
389 provider_ids: Vec::new(),
390 finish: None,
391 })
392 .await
393 .unwrap();
394 sqlx::query("UPDATE device_alerts SET created_at = ? WHERE seq = ?")
395 .bind((Utc::now() - Duration::days(8)).to_rfc3339())
396 .bind(stored.seq)
397 .execute(&h.pool)
398 .await
399 .unwrap();
400 let (_bus, rx) = recording_bus(h.bus.clone());
401 let stop = CancellationToken::new();
402 let task = tokio::spawn(record_pruning(
403 rx,
404 NotificationState::default(),
405 stop.clone(),
406 DeviceAlertRepository::new(pool.clone()),
407 DeviceRepository::new(pool),
408 h.bus.clone(),
409 Arc::default(),
410 std::time::Duration::from_millis(300),
411 ));
412
413 assert_eq!(alerts.list().await.unwrap().len(), 1);
414 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
415 while !alerts.list().await.unwrap().is_empty() {
416 assert!(
417 std::time::Instant::now() < deadline,
418 "expired alert was never pruned"
419 );
420 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
421 }
422
423 stop.cancel();
424 task.await.unwrap();
425 }
426
427 #[tokio::test]
428 async fn the_seed_reflects_the_cached_pipelines() {
429 let h = Harness::new().await;
430 assert_eq!(h.add_provider("ci").await, 1);
431 h.repository
432 .update_pipelines_cache(1, &[run(PipelineStatus::Success, 0)])
433 .await
434 .unwrap();
435
436 let stored = recorded(&h, true, vec![updated(run(PipelineStatus::Failed, 1))]).await;
437
438 assert_eq!(stored.len(), 1);
439 assert_eq!(stored[0].kind, AlertKind::Failed);
440 }
441
442 #[tokio::test]
443 async fn a_failing_pipeline_is_recorded_once_and_announced() {
444 let h = Harness::new().await;
445 let events = vec![
446 updated(run(PipelineStatus::Success, 0)),
447 updated(run(PipelineStatus::Failed, 1)),
448 ];
449
450 let stored = recorded(&h, true, events).await;
451
452 assert_eq!(stored.len(), 1);
453 assert_eq!(stored[0].kind, AlertKind::Failed);
454 assert_eq!(stored[0].pipeline_id.as_deref(), Some("github__1__api"));
455 let announced = h
456 .bus
457 .events()
458 .into_iter()
459 .filter_map(|event| match event {
460 CoreEvent::AlertRecorded { id, seq } => Some((id, seq)),
461 _ => None,
462 })
463 .collect::<Vec<_>>();
464 assert_eq!(announced, vec![(stored[0].id.clone(), stored[0].seq)]);
465 }
466
467 #[tokio::test]
468 async fn a_pipeline_that_passes_again_is_recorded_once_as_a_recovery_with_its_finish() {
469 let h = Harness::new().await;
470 let events = vec![
471 updated(run(PipelineStatus::Failed, 0)),
472 updated(run(PipelineStatus::Running, 1)),
473 updated(run(PipelineStatus::Success, 1)),
474 updated(run(PipelineStatus::Success, 1)),
475 ];
476
477 let stored = recorded(&h, true, events).await;
478
479 assert_eq!(stored.len(), 1);
480 assert_eq!(stored[0].kind, AlertKind::Recovered);
481 assert_eq!(stored[0].title, "api passed again");
482 assert_eq!(stored[0].pipeline_id.as_deref(), Some("github__1__api"));
483 assert!(stored[0].finish.is_some());
484 }
485
486 #[tokio::test]
487 async fn nothing_is_recorded_without_a_paired_device() {
488 let h = Harness::new().await;
489 let events = vec![
490 updated(run(PipelineStatus::Success, 0)),
491 updated(run(PipelineStatus::Failed, 1)),
492 ];
493
494 assert!(recorded(&h, false, events).await.is_empty());
495 assert_eq!(h.bus.count("alert-recorded"), 0);
496 }
497
498 #[tokio::test]
499 async fn the_first_sight_of_a_failed_pipeline_is_not_recorded() {
500 let h = Harness::new().await;
501 let events = vec![updated(run(PipelineStatus::Failed, 1))];
502
503 assert!(recorded(&h, true, events).await.is_empty());
504 }
505
506 #[tokio::test]
507 async fn a_finished_run_is_recorded_only_while_a_device_watches_it() {
508 let h = Harness::new().await;
509 let devices = DeviceRepository::new(DatabasePool::Sqlite(h.pool.clone()));
510 let device = PairedDevice::issue("Pixel", "android").0;
511 devices.insert(&device).await.unwrap();
512 let finishing = |minute| {
513 vec![
514 updated(run(PipelineStatus::Running, minute)),
515 updated(run(PipelineStatus::Success, minute)),
516 ]
517 };
518
519 assert!(recorded(&h, false, finishing(1)).await.is_empty());
520
521 devices
522 .set_notification_preferences(
523 &device.id,
524 &NotificationPreferences {
525 watched_pipelines: vec!["github__1__api".into()],
526 ..NotificationPreferences::default()
527 },
528 )
529 .await
530 .unwrap();
531 let stored = recorded(&h, false, finishing(2)).await;
532
533 assert_eq!(stored.len(), 1);
534 assert_eq!(stored[0].kind, AlertKind::Finished);
535 assert_eq!(stored[0].title, "api succeeded");
536 assert!(stored[0].finish.is_some());
537 }
538
539 #[tokio::test]
540 async fn a_one_time_watch_is_used_once_even_when_the_server_restarts() {
541 let h = Harness::new().await;
542 let devices = DeviceRepository::new(DatabasePool::Sqlite(h.pool.clone()));
543 let device = PairedDevice::issue("Pixel", "android").0;
544 devices.insert(&device).await.unwrap();
545 devices
546 .set_notification_preferences(
547 &device.id,
548 &NotificationPreferences {
549 watch_once: vec![crate::domain::OnceWatch {
550 pipeline_id: "github__1__api".into(),
551 since: Utc::now() - Duration::minutes(1),
552 fired_at: None,
553 }],
554 ..NotificationPreferences::default()
555 },
556 )
557 .await
558 .unwrap();
559 let finishing = |minute| {
560 vec![
561 updated(run(PipelineStatus::Running, minute)),
562 updated(run(PipelineStatus::Success, minute)),
563 ]
564 };
565
566 let first = recorded(&h, false, finishing(1)).await;
567 let after_restart = recorded(&h, false, finishing(2)).await;
568
569 assert_eq!(first.len(), 1);
570 assert_eq!(after_restart, first);
571 let saved = devices.notification_preferences().await.unwrap()[&device.id].clone();
572 assert_eq!(
573 saved.watch_once[0].fired_at,
574 first[0].finish.map(|finish| finish.at)
575 );
576 assert!(saved.allows(
577 AlertKind::Finished,
578 &[1],
579 Some("github__1__api"),
580 first[0].finish.as_ref()
581 ));
582 }
583
584 #[tokio::test]
585 async fn every_notice_is_announced_with_a_rising_id_even_without_a_paired_device() {
586 let h = Harness::new().await;
587 let recent = Arc::new(RecentNotifications::default());
588 let (bus, rx) = recording_bus(h.bus.clone());
589 let state = seed(&h.pipeline_service, &h.provider_service).await;
590 let task = tokio::spawn(record(
591 rx,
592 state,
593 CancellationToken::new(),
594 DeviceAlertRepository::new(DatabasePool::Sqlite(h.pool.clone())),
595 DeviceRepository::new(DatabasePool::Sqlite(h.pool.clone())),
596 h.bus.clone(),
597 Arc::clone(&recent),
598 ));
599 for event in [
600 updated(run(PipelineStatus::Running, 0)),
601 updated(run(PipelineStatus::Success, 0)),
602 updated(run(PipelineStatus::Failed, 1)),
603 ] {
604 bus.emit(event).await;
605 }
606 drop(bus);
607 task.await.unwrap();
608
609 let announced: Vec<ObservedNotification> = h
610 .bus
611 .events()
612 .into_iter()
613 .filter_map(|event| match event {
614 CoreEvent::NotificationObserved { notification } => Some(notification),
615 _ => None,
616 })
617 .collect();
618 assert_eq!(
619 announced
620 .iter()
621 .map(|observed| observed.notice.kind)
622 .collect::<Vec<_>>(),
623 [AlertKind::Finished, AlertKind::Failed]
624 );
625 assert!(announced[0].id < announced[1].id);
626 assert_eq!(recent.after(announced[0].id), announced[1..]);
627 assert_eq!(h.bus.count("alert-recorded"), 0);
628 }
629}