Skip to main content

bestool_alertd/
scheduler.rs

1use std::{
2	collections::{HashMap, HashSet},
3	path::PathBuf,
4	sync::Arc,
5	time::Duration,
6};
7
8use jiff::Timestamp;
9use miette::Result;
10use tokio::{
11	sync::{Mutex, Notify, RwLock},
12	task::JoinHandle,
13	time::{interval, sleep},
14};
15use tracing::{debug, error, info, warn};
16
17use crate::{
18	EmailConfig, LogError,
19	alert::{AlertDefinition, InternalContext, TicketSource},
20	events::{EventContext, EventManager, EventType},
21	glob_resolver::{GlobResolver, ResolvedPaths},
22	loader::{LoadedAlerts, load_alerts_from_paths},
23	metrics,
24	state_file::{PersistedAlertState, PersistedState},
25	targets::ResolvedTarget,
26};
27
28#[derive(Debug, Clone)]
29pub struct AlertState {
30	pub definition: AlertDefinition,
31	pub resolved_targets: Vec<ResolvedTarget>,
32	pub triggered_at: Option<Timestamp>,
33	pub last_sent_at: Option<Timestamp>,
34	pub last_output: Option<String>,
35	pub paused_until: Option<Timestamp>,
36	/// Was the last source read for this alert an error?
37	///
38	/// Used to detect the transition error → OK so a clearing canopy event
39	/// can be sent for the source-error issue.
40	pub source_was_erroring: bool,
41}
42
43impl AlertState {
44	pub fn new(definition: AlertDefinition, resolved_targets: Vec<ResolvedTarget>) -> Self {
45		Self {
46			definition,
47			resolved_targets,
48			triggered_at: None,
49			last_sent_at: None,
50			last_output: None,
51			paused_until: None,
52			source_was_erroring: false,
53		}
54	}
55
56	pub fn preserve_state_from(&mut self, old_state: &AlertState) {
57		self.triggered_at = old_state.triggered_at;
58		self.last_sent_at = old_state.last_sent_at;
59		self.last_output = old_state.last_output.clone();
60		self.paused_until = old_state.paused_until;
61		self.source_was_erroring = old_state.source_was_erroring;
62	}
63
64	pub fn hydrate_from_persisted(&mut self, entry: &PersistedAlertState) {
65		self.triggered_at = entry.triggered_at;
66		self.last_sent_at = entry.last_sent_at;
67		self.last_output = entry.last_output.clone();
68		self.paused_until = entry.paused_until;
69		self.source_was_erroring = entry.source_was_erroring;
70	}
71
72	pub fn to_persisted(&self) -> PersistedAlertState {
73		PersistedAlertState {
74			triggered_at: self.triggered_at,
75			last_sent_at: self.last_sent_at,
76			last_output: self.last_output.clone(),
77			paused_until: self.paused_until,
78			source_was_erroring: self.source_was_erroring,
79		}
80	}
81}
82
83pub struct Scheduler {
84	glob_resolver: GlobResolver,
85	resolved_paths: Arc<RwLock<ResolvedPaths>>,
86	ctx: Arc<InternalContext>,
87	email: Option<EmailConfig>,
88	dry_run: bool,
89	alerts: Arc<RwLock<HashMap<PathBuf, Arc<RwLock<AlertState>>>>>,
90	tasks: Arc<RwLock<HashMap<PathBuf, JoinHandle<()>>>>,
91	event_manager: Arc<RwLock<Option<EventManager>>>,
92	external_targets:
93		Arc<RwLock<std::collections::HashMap<String, Vec<crate::targets::ExternalTarget>>>>,
94	state_dirty: Arc<Notify>,
95	pending_hydration: Arc<Mutex<Option<PersistedState>>>,
96	/// Files that errored during definition loading on the previous
97	/// scheduling pass. Used to detect recovery so we can clear the
98	/// corresponding canopy issue.
99	last_definition_error_files: Arc<RwLock<HashSet<PathBuf>>>,
100	/// Mirrors the daemon's database-down tracking.
101	///
102	/// Kept on the scheduler so it can be persisted in the state snapshot
103	/// and restored on the next start — that way a recovery that happens
104	/// while the daemon was down still produces a canopy clear.
105	database_was_down: Arc<RwLock<bool>>,
106}
107
108impl Scheduler {
109	pub fn new(
110		alert_globs: Vec<String>,
111		ctx: Arc<InternalContext>,
112		email: Option<EmailConfig>,
113		dry_run: bool,
114	) -> Self {
115		let glob_resolver = GlobResolver::new(alert_globs);
116		Self {
117			glob_resolver,
118			resolved_paths: Arc::new(RwLock::new(ResolvedPaths {
119				dirs: Vec::new(),
120				files: Vec::new(),
121			})),
122			ctx,
123			email,
124			dry_run,
125			alerts: Arc::new(RwLock::new(HashMap::new())),
126			tasks: Arc::new(RwLock::new(HashMap::new())),
127			event_manager: Arc::new(RwLock::new(None)),
128			external_targets: Arc::new(RwLock::new(HashMap::new())),
129			state_dirty: Arc::new(Notify::new()),
130			pending_hydration: Arc::new(Mutex::new(None)),
131			last_definition_error_files: Arc::new(RwLock::new(HashSet::new())),
132			database_was_down: Arc::new(RwLock::new(false)),
133		}
134	}
135
136	/// Read the persisted database-down flag.
137	pub async fn database_was_down(&self) -> bool {
138		*self.database_was_down.read().await
139	}
140
141	/// Update the persisted database-down flag.
142	pub async fn set_database_was_down(&self, value: bool) {
143		*self.database_was_down.write().await = value;
144		self.state_dirty.notify_one();
145	}
146
147	/// Handle used by the persistence task to wake when alert state changes.
148	pub fn state_dirty(&self) -> Arc<Notify> {
149		self.state_dirty.clone()
150	}
151
152	/// Seed the next `load_and_schedule_alerts` call with persisted state.
153	///
154	/// Consumed on the next load (cold start). Reload calls leave previously
155	/// in-memory state in place via `preserve_state_from`, so hydration is a
156	/// cold-start-only concern.
157	pub async fn set_pending_hydration(&self, state: PersistedState) {
158		*self.pending_hydration.lock().await = Some(state);
159	}
160
161	pub fn get_event_manager(&self) -> Arc<RwLock<Option<EventManager>>> {
162		self.event_manager.clone()
163	}
164
165	pub async fn get_loaded_alerts(&self) -> Vec<PathBuf> {
166		let alerts = self.alerts.read().await;
167		let mut files: Vec<PathBuf> = alerts.keys().cloned().collect();
168		files.sort();
169		files
170	}
171
172	pub async fn get_alert_states(&self) -> HashMap<PathBuf, AlertState> {
173		let alerts = self.alerts.read().await;
174		let mut states = HashMap::new();
175		for (path, state_lock) in alerts.iter() {
176			let state = state_lock.read().await;
177			states.insert(path.clone(), state.clone());
178		}
179		states
180	}
181
182	pub async fn pause_alert(&self, path: &PathBuf, until: Timestamp) -> Result<bool> {
183		let alerts = self.alerts.read().await;
184		if let Some(alert_state) = alerts.get(path) {
185			let mut state = alert_state.write().await;
186			state.paused_until = Some(until);
187			info!(?path, until = %until, "paused alert");
188			drop(state);
189			self.state_dirty.notify_one();
190			Ok(true)
191		} else {
192			Ok(false)
193		}
194	}
195
196	/// Snapshot the in-memory state for serialisation by the persistence task.
197	pub async fn snapshot_for_persistence(&self) -> PersistedState {
198		let alerts = self.alerts.read().await;
199		let mut out = HashMap::with_capacity(alerts.len());
200		for (path, state_lock) in alerts.iter() {
201			let state = state_lock.read().await;
202			out.insert(path.clone(), state.to_persisted());
203		}
204		PersistedState {
205			saved_at: Some(Timestamp::now()),
206			alerts: out,
207			database_was_down: *self.database_was_down.read().await,
208			definition_error_files: self.last_definition_error_files.read().await.clone(),
209		}
210	}
211
212	pub async fn get_external_targets(
213		&self,
214	) -> std::collections::HashMap<String, Vec<crate::targets::ExternalTarget>> {
215		self.external_targets.read().await.clone()
216	}
217
218	pub async fn load_and_schedule_alerts(&self) -> Result<()> {
219		info!("resolving glob patterns and loading alerts");
220
221		// Consume any pending hydration first so subsequent code can rely on
222		// hydrated daemon-level state (database_was_down, last definition
223		// errors). Per-alert hydration happens later from the same value.
224		let hydration = self.pending_hydration.lock().await.take();
225		if let Some(ref h) = hydration {
226			*self.database_was_down.write().await = h.database_was_down;
227			*self.last_definition_error_files.write().await = h.definition_error_files.clone();
228		}
229
230		let resolved = self.glob_resolver.resolve()?;
231		debug!(
232			dirs = resolved.dirs.len(),
233			files = resolved.files.len(),
234			"resolved paths from globs"
235		);
236
237		let canopy_available = self.ctx.canopy_client.is_some();
238		let LoadedAlerts {
239			alerts,
240			external_targets,
241			definition_errors,
242		} = load_alerts_from_paths(&resolved, canopy_available)?;
243
244		// Update resolved paths
245		*self.resolved_paths.write().await = resolved;
246
247		// Separate event alerts from regular alerts
248		let (event_alerts, regular_alerts): (Vec<_>, Vec<_>) = alerts
249			.into_iter()
250			.partition(|(alert, _)| matches!(alert.source, TicketSource::Event { .. }));
251
252		// Store external targets
253		*self.external_targets.write().await = external_targets.clone();
254
255		// Create event manager with event alerts and external targets
256		let event_manager = EventManager::new(event_alerts, &external_targets);
257		*self.event_manager.write().await = Some(event_manager.clone());
258
259		// Send definition error events for any failed alert loads
260		if !definition_errors.is_empty() {
261			info!(
262				count = definition_errors.len(),
263				"triggering definition-error events for failed alerts"
264			);
265		}
266		let new_error_files: HashSet<PathBuf> =
267			definition_errors.iter().map(|e| e.file.clone()).collect();
268		for def_err in definition_errors {
269			info!(
270				file = %def_err.file.display(),
271				"triggering definition-error event"
272			);
273			let entity_key = def_err.file.display().to_string();
274			let event_context = EventContext::DefinitionError {
275				alert_file: entity_key.clone(),
276				error_message: def_err.error.clone(),
277			};
278			if let Err(err) = event_manager
279				.trigger_event(
280					EventType::DefinitionError,
281					&self.ctx,
282					self.email.as_ref(),
283					self.dry_run,
284					event_context,
285					Some(&entity_key),
286				)
287				.await
288			{
289				error!(
290					"failed to trigger definition-error event: {}",
291					LogError(&err)
292				);
293			}
294		}
295
296		// Clear definition-error events for files that errored last time but
297		// loaded cleanly this time.
298		let mut last_def_errors = self.last_definition_error_files.write().await;
299		for recovered in last_def_errors.difference(&new_error_files) {
300			info!(
301				file = %recovered.display(),
302				"clearing definition-error event (file now loads cleanly)"
303			);
304			let entity_key = recovered.display().to_string();
305			if let Err(err) = event_manager
306				.trigger_clear(
307					EventType::DefinitionError,
308					&self.ctx,
309					self.dry_run,
310					Some(&entity_key),
311				)
312				.await
313			{
314				error!("failed to clear definition-error event: {}", LogError(&err));
315			}
316		}
317		*last_def_errors = new_error_files;
318		drop(last_def_errors);
319
320		if regular_alerts.is_empty() {
321			warn!("no regular alerts found");
322			return Ok(());
323		}
324
325		info!(count = regular_alerts.len(), "scheduling regular alerts");
326
327		// Get old alerts to preserve state across hot reload.
328		let old_alerts = self.alerts.read().await.clone();
329
330		// Hydration was taken at the top of this method; reuse it here for
331		// per-alert state. On subsequent reloads it'll be None and
332		// preserve_state_from carries in-memory state forward instead.
333		let mut hydrated_count = 0usize;
334
335		let mut new_alerts = HashMap::new();
336		let mut tasks = HashMap::new();
337
338		for (definition, resolved_targets) in regular_alerts {
339			let file = definition.file.clone();
340
341			// Create new alert state
342			let mut new_state = AlertState::new(definition.clone(), resolved_targets.clone());
343
344			if let Some(old_alert_lock) = old_alerts.get(&file) {
345				let old_state = old_alert_lock.read().await;
346				new_state.preserve_state_from(&old_state);
347			} else if let Some(entry) = hydration.as_ref().and_then(|h| h.alerts.get(&file)) {
348				new_state.hydrate_from_persisted(entry);
349				hydrated_count += 1;
350			}
351
352			let state_lock = Arc::new(RwLock::new(new_state));
353			let task = self.spawn_alert_task(state_lock.clone());
354
355			new_alerts.insert(file.clone(), state_lock);
356			tasks.insert(file, task);
357		}
358
359		if hydrated_count > 0 {
360			info!(count = hydrated_count, "hydrated alert state from disk");
361		}
362
363		// Update alerts and tasks atomically
364		*self.alerts.write().await = new_alerts;
365		*self.tasks.write().await = tasks;
366
367		// Update metrics with count of loaded alerts
368		metrics::set_alerts_loaded(self.alerts.read().await.len());
369
370		Ok(())
371	}
372
373	pub async fn execute_all_alerts_once(&self) -> Result<()> {
374		info!("executing all alerts once");
375
376		let resolved = self.glob_resolver.resolve()?;
377		let canopy_available = self.ctx.canopy_client.is_some();
378		let LoadedAlerts {
379			alerts,
380			external_targets,
381			definition_errors,
382		} = load_alerts_from_paths(&resolved, canopy_available)?;
383
384		// Separate event alerts from regular alerts
385		let (event_alerts, regular_alerts): (Vec<_>, Vec<_>) = alerts
386			.into_iter()
387			.partition(|(alert, _)| matches!(alert.source, TicketSource::Event { .. }));
388
389		// Store external targets
390		*self.external_targets.write().await = external_targets.clone();
391
392		// Update event manager
393		let event_manager = EventManager::new(event_alerts, &external_targets);
394		*self.event_manager.write().await = Some(event_manager.clone());
395
396		// Send definition error events for any failed alert loads
397		if !definition_errors.is_empty() {
398			info!(
399				count = definition_errors.len(),
400				"triggering definition-error events for failed alerts"
401			);
402		}
403		for def_err in definition_errors {
404			info!(
405				file = %def_err.file.display(),
406				"triggering definition-error event"
407			);
408			let entity_key = def_err.file.display().to_string();
409			let event_context = EventContext::DefinitionError {
410				alert_file: entity_key.clone(),
411				error_message: def_err.error.clone(),
412			};
413			if let Err(err) = event_manager
414				.trigger_event(
415					EventType::DefinitionError,
416					&self.ctx,
417					self.email.as_ref(),
418					self.dry_run,
419					event_context,
420					Some(&entity_key),
421				)
422				.await
423			{
424				error!(
425					"failed to trigger definition-error event: {}",
426					LogError(&err)
427				);
428			}
429		}
430
431		if regular_alerts.is_empty() {
432			warn!("no regular alerts found");
433			return Ok(());
434		}
435
436		info!(count = regular_alerts.len(), "executing alerts");
437
438		for (alert, resolved_targets) in regular_alerts {
439			let ctx = self.ctx.clone();
440			let email = self.email.clone();
441			let dry_run = self.dry_run;
442			let file = alert.file.clone();
443
444			debug!(?file, "executing alert");
445			if let Err(err) = alert
446				.execute(ctx, email.as_ref(), dry_run, &resolved_targets)
447				.await
448			{
449				error!(?file, "error executing alert: {}", LogError(&err));
450			}
451		}
452
453		Ok(())
454	}
455
456	pub async fn check_and_reload_if_paths_changed(&self) -> Result<()> {
457		debug!("checking if resolved paths have changed");
458
459		let new_resolved = self.glob_resolver.resolve()?;
460		let old_resolved = self.resolved_paths.read().await;
461
462		if new_resolved.differs_from(&old_resolved) {
463			drop(old_resolved); // Release read lock before reloading
464			info!("resolved paths have changed, reloading alerts");
465			self.reload_alerts().await?;
466		}
467
468		Ok(())
469	}
470
471	pub async fn get_resolved_paths(&self) -> Vec<PathBuf> {
472		let resolved = self.resolved_paths.read().await;
473		resolved
474			.all_paths()
475			.iter()
476			.map(|p| p.to_path_buf())
477			.collect()
478	}
479
480	pub async fn reload_alerts(&self) -> Result<()> {
481		info!("reloading alerts");
482
483		// Cancel all existing tasks
484		{
485			let mut tasks = self.tasks.write().await;
486			for (path, handle) in tasks.drain() {
487				debug!(?path, "cancelling alert task");
488				handle.abort();
489			}
490		}
491
492		// Load and schedule new alerts
493		self.load_and_schedule_alerts().await
494	}
495
496	fn spawn_alert_task(&self, alert_state: Arc<RwLock<AlertState>>) -> JoinHandle<()> {
497		fn serialize_context_for_comparison(
498			context: &tera::Context,
499			when_changed: &crate::alert::WhenChanged,
500		) -> String {
501			use crate::alert::WhenChanged;
502
503			// Get the rows from the context
504			let rows = match context.get("rows") {
505				Some(value) => value,
506				None => return String::new(),
507			};
508
509			// Parse rows as array of objects
510			let rows_array = match rows.as_array() {
511				Some(arr) => arr,
512				None => return serde_json::to_string(rows).unwrap_or_default(),
513			};
514
515			match when_changed {
516				WhenChanged::Boolean(true) => {
517					// Simple mode: serialize everything
518					serde_json::to_string(rows).unwrap_or_default()
519				}
520				WhenChanged::Boolean(false) => {
521					// Not enabled
522					String::new()
523				}
524				WhenChanged::Detailed(config) => {
525					// Filter columns based on config
526					let filtered_rows: Vec<serde_json::Map<String, serde_json::Value>> = rows_array
527						.iter()
528						.filter_map(|row| {
529							let obj = row.as_object()?;
530							let mut filtered = serde_json::Map::new();
531
532							for (key, value) in obj {
533								let include = if !config.only.is_empty() {
534									// Only mode: include only specified columns
535									config.only.contains(key)
536								} else if !config.except.is_empty() {
537									// Except mode: include all except specified columns
538									!config.except.contains(key)
539								} else {
540									// No filters specified, include all
541									true
542								};
543
544								if include {
545									filtered.insert(key.clone(), value.clone());
546								}
547							}
548
549							Some(filtered)
550						})
551						.collect();
552
553					serde_json::to_string(&filtered_rows).unwrap_or_default()
554				}
555			}
556		}
557
558		let ctx = self.ctx.clone();
559		let email = self.email.clone();
560		let dry_run = self.dry_run;
561		let event_manager = self.event_manager.clone();
562		let state_dirty = self.state_dirty.clone();
563
564		tokio::spawn(async move {
565			// Read initial values from state
566			let (file, interval_duration) = {
567				let state = alert_state.read().await;
568				(
569					state.definition.file.clone(),
570					state.definition.interval_duration,
571				)
572			};
573			debug!(?file, ?interval_duration, "starting alert task");
574
575			// Add a small random delay to prevent all alerts from firing at exactly the same time
576			let jitter = Duration::from_millis(rand::random::<u64>() % 5000);
577			sleep(jitter).await;
578
579			let mut ticker = interval(interval_duration);
580			ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
581
582			loop {
583				ticker.tick().await;
584				metrics::record_activity();
585
586				// Check if alert is paused
587				let is_paused = {
588					let state = alert_state.read().await;
589					if let Some(until) = state.paused_until {
590						let now = Timestamp::now();
591						now < until
592					} else {
593						false
594					}
595				};
596
597				if is_paused {
598					debug!(?file, "alert is paused, skipping execution");
599					continue;
600				}
601
602				debug!(?file, "executing alert");
603
604				// Get alert definition and state
605				let (
606					alert,
607					resolved_targets,
608					was_triggered,
609					was_source_erroring,
610					always_send,
611					when_changed,
612				) = {
613					let state = alert_state.read().await;
614					(
615						state.definition.clone(),
616						state.resolved_targets.clone(),
617						state.triggered_at.is_some(),
618						state.source_was_erroring,
619						state.definition.always_send.clone(),
620						state.definition.when_changed.clone(),
621					)
622				};
623
624				// Check the triggering state
625				let now = jiff::Timestamp::now();
626				let mut tera_ctx = crate::templates::build_context(&alert, now);
627				let not_before = now - alert.interval_duration;
628
629				let mut state_changed = false;
630
631				let is_triggering = match alert
632					.read_sources(&ctx.pg_pool, not_before, &mut tera_ctx, was_triggered)
633					.await
634				{
635					Ok(flow) => {
636						// Source recovered: clear the source-error canopy issue.
637						if was_source_erroring {
638							info!(?file, "source recovered, clearing source-error event");
639							if let Some(ref event_mgr) = *event_manager.read().await {
640								let entity_key = file.display().to_string();
641								if let Err(event_err) = event_mgr
642									.trigger_clear(
643										EventType::SourceError,
644										&ctx,
645										dry_run,
646										Some(&entity_key),
647									)
648									.await
649								{
650									error!(
651										"failed to clear source_error event: {}",
652										LogError(&event_err)
653									);
654								}
655							}
656							let mut state = alert_state.write().await;
657							state.source_was_erroring = false;
658							state_changed = true;
659						}
660						flow.is_continue()
661					}
662					Err(err) => {
663						error!(?file, "error reading sources: {}", LogError(&err));
664						metrics::inc_alerts_failed();
665
666						// Trigger source_error event
667						if let Some(ref event_mgr) = *event_manager.read().await {
668							let entity_key = file.display().to_string();
669							let event_context = EventContext::SourceError {
670								alert_file: entity_key.clone(),
671								error_message: format!("{err:?}"),
672							};
673							if let Err(event_err) = event_mgr
674								.trigger_event(
675									EventType::SourceError,
676									&ctx,
677									email.as_ref(),
678									dry_run,
679									event_context,
680									Some(&entity_key),
681								)
682								.await
683							{
684								error!(
685									"failed to trigger source_error event: {}",
686									LogError(&event_err)
687								);
688							}
689						}
690						if !was_source_erroring {
691							let mut state = alert_state.write().await;
692							state.source_was_erroring = true;
693							state_dirty.notify_one();
694						}
695						continue;
696					}
697				};
698
699				if is_triggering {
700					// Alert is in triggering state
701					let mut state = alert_state.write().await;
702
703					let mut should_send = match &always_send {
704						crate::alert::AlwaysSend::Boolean(true) => true,
705						crate::alert::AlwaysSend::Boolean(false) => !was_triggered,
706						crate::alert::AlwaysSend::Timed(config) => {
707							// Check if enough time has passed since last send
708							match state.last_sent_at {
709								Some(last_sent_time) => {
710									let now = Timestamp::now();
711									let elapsed = now.duration_since(last_sent_time);
712									if let Ok(after_duration) =
713										jiff::SignedDuration::try_from(config.after_duration)
714									{
715										elapsed >= after_duration
716									} else {
717										false
718									}
719								}
720								None => true, // Never sent before, should send
721							}
722						}
723					};
724
725					// Check when-changed logic if configured
726					if should_send
727						&& !matches!(when_changed, crate::alert::WhenChanged::Boolean(false))
728					{
729						let current_output =
730							serialize_context_for_comparison(&tera_ctx, &when_changed);
731
732						let output_changed = match &state.last_output {
733							Some(prev_output) => prev_output != &current_output,
734							None => true, // First run, consider it changed
735						};
736
737						if output_changed {
738							debug!(?file, "output changed, will send");
739							state.last_output = Some(current_output);
740							state_changed = true;
741						} else {
742							debug!(?file, "output unchanged, skipping");
743							should_send = false;
744						}
745					}
746
747					if should_send {
748						debug!(?file, "alert triggered, sending notifications");
749
750						// Send to targets
751						for target in &resolved_targets {
752							if let Err(err) = target
753								.send(&alert, &mut tera_ctx, email.as_ref(), &ctx, dry_run)
754								.await
755							{
756								error!("sending: {}", LogError(&err));
757							}
758						}
759
760						metrics::inc_alerts_sent();
761
762						// Update last sent timestamp
763						state.last_sent_at = Some(Timestamp::now());
764						state_changed = true;
765					} else {
766						debug!(?file, "alert still triggered, not sending (already sent)");
767					}
768
769					// Update the triggered timestamp even if we didn't send
770					if !was_triggered {
771						state.triggered_at = Some(Timestamp::now());
772						state_changed = true;
773					}
774				} else {
775					// Alert is not in triggering state
776					if was_triggered {
777						info!(?file, "alert is no longer triggering, sending clear");
778						let all_cleared =
779							send_clear_to_targets(&resolved_targets, &alert, &ctx, dry_run).await;
780						if all_cleared {
781							let mut state = alert_state.write().await;
782							state.triggered_at = None;
783							state.last_sent_at = None;
784
785							// Clear last output when alert clears
786							if !matches!(when_changed, crate::alert::WhenChanged::Boolean(false)) {
787								state.last_output = None;
788							}
789							state_changed = true;
790						} else {
791							warn!(
792								?file,
793								"send_clear failed for one or more targets; will retry on next tick"
794							);
795						}
796					}
797				}
798
799				if state_changed {
800					state_dirty.notify_one();
801				}
802			}
803		})
804	}
805
806	pub async fn shutdown(&self) {
807		info!("shutting down scheduler");
808		let mut tasks = self.tasks.write().await;
809		for (path, handle) in tasks.drain() {
810			debug!(?path, "cancelling alert task");
811			handle.abort();
812		}
813	}
814}
815
816/// Send a clear notification to every resolved target.
817///
818/// Returns `true` if every target's `send_clear` succeeded; `false` if any
819/// failed. Caller should leave `triggered_at` set when this returns `false`
820/// so the next scheduler tick retries — otherwise a transient failure
821/// (network blip, canopy 5xx, TLS handshake during cert rollover) silently
822/// leaves stateful targets like canopy stuck on `active=true`.
823async fn send_clear_to_targets(
824	targets: &[ResolvedTarget],
825	alert: &AlertDefinition,
826	ctx: &InternalContext,
827	dry_run: bool,
828) -> bool {
829	let mut all_ok = true;
830	for target in targets {
831		if let Err(err) = target.send_clear(alert, ctx, dry_run).await {
832			error!("sending clear: {}", LogError(&err));
833			all_ok = false;
834		}
835	}
836	all_ok
837}
838
839#[cfg(test)]
840mod tests {
841	use super::*;
842	use crate::{
843		canopy::{DEFAULT_CANOPY_URL, Severity},
844		targets::{CanopyConfig, TargetCanopy, TargetConnection, TargetEmail},
845	};
846
847	async fn test_internal_context() -> InternalContext {
848		let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for tests");
849		let pg_pool = bestool_postgres::pool::create_pool(&db_url, "bestool-alertd-test")
850			.await
851			.unwrap();
852		InternalContext {
853			pg_pool,
854			http_client: reqwest::Client::new(),
855			canopy_client: None,
856		}
857	}
858
859	fn email_target() -> ResolvedTarget {
860		ResolvedTarget {
861			target_id: "ops".into(),
862			subject: None,
863			template: "body".into(),
864			conn: TargetConnection::Email(TargetEmail {
865				addresses: vec!["ops@example.com".into()],
866			}),
867		}
868	}
869
870	fn canopy_target() -> ResolvedTarget {
871		ResolvedTarget {
872			target_id: "default".into(),
873			subject: None,
874			template: "body".into(),
875			conn: TargetConnection::Canopy(TargetCanopy {
876				canopy: CanopyConfig {
877					url: DEFAULT_CANOPY_URL.parse().unwrap(),
878					source: "test".into(),
879					severity: Some(Severity::Error),
880				},
881			}),
882		}
883	}
884
885	fn test_alert() -> AlertDefinition {
886		AlertDefinition {
887			file: "test.yml".into(),
888			..Default::default()
889		}
890	}
891
892	#[tokio::test]
893	async fn send_clear_to_targets_returns_true_for_non_stateful_only() {
894		let ctx = test_internal_context().await;
895		let targets = vec![email_target()];
896		assert!(send_clear_to_targets(&targets, &test_alert(), &ctx, false).await);
897	}
898
899	#[tokio::test]
900	async fn send_clear_to_targets_returns_false_when_canopy_lacks_client() {
901		// Canopy target configured but ctx.canopy_client is None — send_clear
902		// returns Err and the helper should report failure so the caller
903		// leaves triggered_at set and retries on the next tick.
904		let ctx = test_internal_context().await;
905		let targets = vec![canopy_target()];
906		assert!(!send_clear_to_targets(&targets, &test_alert(), &ctx, false).await);
907	}
908
909	#[tokio::test]
910	async fn send_clear_to_targets_reports_failure_even_when_only_one_target_fails() {
911		// Mixed bag: email (succeeds) + canopy with no client (fails).
912		// Helper must return false so the scheduler keeps the alert in the
913		// triggered state and retries.
914		let ctx = test_internal_context().await;
915		let targets = vec![email_target(), canopy_target()];
916		assert!(!send_clear_to_targets(&targets, &test_alert(), &ctx, false).await);
917	}
918
919	#[tokio::test]
920	async fn send_clear_to_targets_in_dry_run_succeeds_regardless_of_canopy_client() {
921		// In dry-run mode, canopy never tries to use the missing client.
922		let ctx = test_internal_context().await;
923		let targets = vec![canopy_target()];
924		assert!(send_clear_to_targets(&targets, &test_alert(), &ctx, true).await);
925	}
926}