1use std::any::Any;
44use std::panic;
45use std::sync::Arc;
46use std::sync::Mutex;
47use std::sync::OnceLock;
48use std::sync::atomic::{AtomicUsize, Ordering};
49use std::sync::mpsc;
50use std::time::Duration;
51
52pub trait PhaseEndTrigger: Send + Sync + 'static {
57 fn fire(&self, event: &PhaseEndEvent);
62
63 fn name(&self) -> &str {
66 "phase-end-trigger"
67 }
68}
69
70#[derive(Debug, Clone)]
72pub struct PhaseEndEvent {
73 pub phase_name: String,
74 pub phase_labels: String,
75 pub outcome: PhaseOutcome,
76 pub duration_secs: f64,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum PhaseOutcome {
82 Completed,
83 Failed { error: String },
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89pub struct TriggerId(u64);
90
91struct Entry {
92 id: TriggerId,
93 trigger: Arc<dyn PhaseEndTrigger>,
94}
95
96struct Registry {
97 next_id: u64,
98 triggers: Vec<Entry>,
99 dispatch: Option<mpsc::Sender<PhaseEndEvent>>,
100}
101
102static REGISTRY: OnceLock<Mutex<Registry>> = OnceLock::new();
103
104static TRIGGER_COUNT: AtomicUsize = AtomicUsize::new(0);
112
113fn registry() -> &'static Mutex<Registry> {
114 REGISTRY.get_or_init(|| {
115 Mutex::new(Registry {
116 next_id: 1,
117 triggers: Vec::new(),
118 dispatch: None,
119 })
120 })
121}
122
123pub fn register(trigger: Arc<dyn PhaseEndTrigger>) -> TriggerId {
132 let mut reg = registry()
133 .lock()
134 .expect("phase-end-triggers registry poisoned");
135 let id = TriggerId(reg.next_id);
136 reg.next_id += 1;
137 reg.triggers.push(Entry { id, trigger });
138 TRIGGER_COUNT.store(reg.triggers.len(), Ordering::Release);
139 if reg.dispatch.is_none() {
143 let (tx, rx) = mpsc::channel::<PhaseEndEvent>();
144 reg.dispatch = Some(tx);
145 std::thread::Builder::new()
146 .name("phase-end-trigger-worker".into())
147 .spawn(move || dispatch_loop(rx))
148 .expect("spawn phase-end-trigger worker");
149 }
150 id
151}
152
153pub fn unregister(id: TriggerId) {
157 let mut reg = registry()
158 .lock()
159 .expect("phase-end-triggers registry poisoned");
160 reg.triggers.retain(|e| e.id != id);
161 TRIGGER_COUNT.store(reg.triggers.len(), Ordering::Release);
162}
163
164#[cfg(test)]
168pub fn reset_for_tests() {
169 let mut reg = registry()
170 .lock()
171 .expect("phase-end-triggers registry poisoned");
172 reg.triggers.clear();
173 TRIGGER_COUNT.store(0, Ordering::Release);
174 reg.next_id = 1;
175 }
178
179pub fn fire_phase_completed(name: &str, labels: &str, duration_secs: f64) {
182 fire(PhaseEndEvent {
183 phase_name: name.to_string(),
184 phase_labels: labels.to_string(),
185 outcome: PhaseOutcome::Completed,
186 duration_secs,
187 });
188}
189
190pub fn fire_phase_failed(name: &str, labels: &str, error: &str) {
193 fire(PhaseEndEvent {
194 phase_name: name.to_string(),
195 phase_labels: labels.to_string(),
196 outcome: PhaseOutcome::Failed {
197 error: error.to_string(),
198 },
199 duration_secs: 0.0,
200 });
201}
202
203fn fire(event: PhaseEndEvent) {
204 if TRIGGER_COUNT.load(Ordering::Acquire) == 0 {
208 return;
209 }
210 let reg = registry()
211 .lock()
212 .expect("phase-end-triggers registry poisoned");
213 if reg.triggers.is_empty() {
214 return;
215 }
216 if let Some(tx) = reg.dispatch.as_ref() {
217 let _ = tx.send(event);
222 }
223}
224
225fn dispatch_loop(rx: mpsc::Receiver<PhaseEndEvent>) {
226 loop {
228 match rx.recv_timeout(Duration::from_secs(60)) {
229 Ok(event) => {
230 let snap: Vec<Arc<dyn PhaseEndTrigger>> = {
234 let reg = match registry().lock() {
235 Ok(r) => r,
236 Err(_) => return, };
238 reg.triggers.iter().map(|e| e.trigger.clone()).collect()
239 };
240 for trigger in snap {
241 let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
244 trigger.fire(&event);
245 }));
246 if let Err(payload) = result {
247 let msg = payload_to_message(payload);
248 crate::diag!(
252 crate::observer::LogLevel::Warn,
253 "phase-end trigger '{name}' panicked: {msg}",
254 name = trigger.name(),
255 );
256 }
257 }
258 }
259 Err(mpsc::RecvTimeoutError::Timeout) => {
260 }
264 Err(mpsc::RecvTimeoutError::Disconnected) => {
265 return;
268 }
269 }
270 }
271}
272
273fn payload_to_message(payload: Box<dyn Any + Send>) -> String {
274 if let Some(s) = payload.downcast_ref::<&'static str>() {
275 (*s).to_string()
276 } else if let Some(s) = payload.downcast_ref::<String>() {
277 s.clone()
278 } else {
279 "<non-string panic payload>".to_string()
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use std::sync::Mutex as StdMutex;
287 use std::sync::atomic::{AtomicUsize, Ordering};
288 use std::time::Instant;
289
290 struct CountingTrigger {
293 count: Arc<AtomicUsize>,
294 names: Arc<StdMutex<Vec<String>>>,
295 name: &'static str,
296 }
297 impl PhaseEndTrigger for CountingTrigger {
298 fn name(&self) -> &str {
299 self.name
300 }
301 fn fire(&self, event: &PhaseEndEvent) {
302 self.count.fetch_add(1, Ordering::Release);
303 self.names.lock().unwrap().push(event.phase_name.clone());
304 }
305 }
306
307 fn wait_for(count: &AtomicUsize, target: usize, timeout: Duration) -> bool {
311 let start = Instant::now();
312 while count.load(Ordering::Acquire) < target {
313 if start.elapsed() > timeout {
314 return false;
315 }
316 std::thread::sleep(Duration::from_millis(5));
317 }
318 true
319 }
320
321 static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
324
325 #[test]
326 fn registered_trigger_fires_for_completed_phase() {
327 let _g = TEST_LOCK.lock().unwrap();
328 reset_for_tests();
329 let count = Arc::new(AtomicUsize::new(0));
330 let names = Arc::new(StdMutex::new(Vec::new()));
331 let trig = Arc::new(CountingTrigger {
332 count: count.clone(),
333 names: names.clone(),
334 name: "test",
335 });
336 let _id = register(trig);
337
338 fire_phase_completed("setup", "", 1.5);
339 assert!(
340 wait_for(&count, 1, Duration::from_secs(2)),
341 "trigger did not fire within 2s"
342 );
343 assert_eq!(*names.lock().unwrap(), vec!["setup".to_string()]);
344 reset_for_tests();
345 }
346
347 #[test]
348 fn registered_trigger_fires_for_failed_phase() {
349 let _g = TEST_LOCK.lock().unwrap();
350 reset_for_tests();
351 let count = Arc::new(AtomicUsize::new(0));
352 let names = Arc::new(StdMutex::new(Vec::new()));
353 let trig = Arc::new(CountingTrigger {
354 count: count.clone(),
355 names: names.clone(),
356 name: "test",
357 });
358 let _id = register(trig);
359
360 fire_phase_failed("query", "k=10", "timeout");
361 assert!(wait_for(&count, 1, Duration::from_secs(2)));
362 assert_eq!(*names.lock().unwrap(), vec!["query".to_string()]);
363 reset_for_tests();
364 }
365
366 #[test]
367 fn unregister_stops_subsequent_dispatches() {
368 let _g = TEST_LOCK.lock().unwrap();
369 reset_for_tests();
370 let count = Arc::new(AtomicUsize::new(0));
371 let names = Arc::new(StdMutex::new(Vec::new()));
372 let trig = Arc::new(CountingTrigger {
373 count: count.clone(),
374 names: names.clone(),
375 name: "test",
376 });
377 let id = register(trig);
378
379 fire_phase_completed("a", "", 0.1);
380 assert!(wait_for(&count, 1, Duration::from_secs(2)));
381
382 unregister(id);
383 fire_phase_completed("b", "", 0.2);
384 std::thread::sleep(Duration::from_millis(100));
386 assert_eq!(
387 count.load(Ordering::Acquire),
388 1,
389 "trigger fired after unregister"
390 );
391 reset_for_tests();
392 }
393
394 #[test]
395 fn multiple_triggers_fire_in_registration_order() {
396 let _g = TEST_LOCK.lock().unwrap();
397 reset_for_tests();
398 let count_a = Arc::new(AtomicUsize::new(0));
399 let count_b = Arc::new(AtomicUsize::new(0));
400 let names = Arc::new(StdMutex::new(Vec::new()));
401 let _id_a = register(Arc::new(CountingTrigger {
402 count: count_a.clone(),
403 names: names.clone(),
404 name: "a",
405 }));
406 let _id_b = register(Arc::new(CountingTrigger {
407 count: count_b.clone(),
408 names: names.clone(),
409 name: "b",
410 }));
411
412 fire_phase_completed("phase1", "", 0.0);
413 assert!(wait_for(&count_a, 1, Duration::from_secs(2)));
414 assert!(wait_for(&count_b, 1, Duration::from_secs(2)));
415 assert_eq!(
416 *names.lock().unwrap(),
417 vec!["phase1".to_string(), "phase1".to_string()]
418 );
419 reset_for_tests();
420 }
421
422 #[test]
423 fn panic_in_one_trigger_does_not_stop_others() {
424 let _g = TEST_LOCK.lock().unwrap();
425 reset_for_tests();
426 struct PanickingTrigger;
427 impl PhaseEndTrigger for PanickingTrigger {
428 fn name(&self) -> &str {
429 "panicker"
430 }
431 fn fire(&self, _: &PhaseEndEvent) {
432 panic!("boom");
433 }
434 }
435 let count = Arc::new(AtomicUsize::new(0));
436 let names = Arc::new(StdMutex::new(Vec::new()));
437 let _a = register(Arc::new(PanickingTrigger));
438 let _b = register(Arc::new(CountingTrigger {
439 count: count.clone(),
440 names: names.clone(),
441 name: "after-panic",
442 }));
443 fire_phase_completed("phase", "", 0.0);
444 assert!(
446 wait_for(&count, 1, Duration::from_secs(2)),
447 "downstream trigger lost to upstream panic"
448 );
449 reset_for_tests();
450 }
451
452 #[test]
453 fn fire_with_no_triggers_is_noop() {
454 let _g = TEST_LOCK.lock().unwrap();
455 reset_for_tests();
456 fire_phase_completed("x", "", 1.0);
458 fire_phase_failed("y", "", "oops");
459 }
462}