Skip to main content

nmbrs_runtime/
phase_end_triggers.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Phase-end trigger registry.
5//!
6//! A simple event/callback registry that any subsystem can
7//! attach a callback to. Callbacks fire after every
8//! `phase_completed` and `phase_failed` event from the
9//! executor. Triggers are dispatched on a background worker
10//! thread so a slow callback (e.g. re-rendering a plot
11//! against the live session.db) never blocks the run loop.
12//!
13//! ## Why a separate registry instead of new `RunObserver`
14//! methods
15//!
16//! `RunObserver` is the surface for *display* observers (TUI,
17//! log-only, stderr). A trigger is *behavioral* — it runs work
18//! in response to lifecycle events without contributing to
19//! display rendering. Trying to merge the two via a generic
20//! observer interface forces every plot author to implement
21//! the noisy `phase_starting` / `set_status_line` / `reporters`
22//! surface. The registry is the focused alternative.
23//!
24//! ## Lifecycle
25//!
26//! - [`register`] adds a trigger and returns a [`TriggerId`].
27//! - [`unregister`] removes a trigger by id (no-op if absent).
28//! - The executor calls [`fire_phase_completed`] /
29//!   [`fire_phase_failed`] immediately after the observer's
30//!   matching callback. Triggers run on a single worker
31//!   thread in FIFO registration order so a panic in one
32//!   trigger doesn't take down the others (each call is
33//!   `catch_unwind`-guarded).
34//!
35//! ## Synchronization
36//!
37//! The registry sits behind a `std::sync::Mutex` for
38//! registration; the worker thread snapshots the trigger
39//! list on each event so a registration / unregistration mid-
40//! event won't dirty the dispatch. Total cost per phase end
41//! is one channel send + one Vec clone of `Arc<Trigger>`.
42
43use 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
52/// Trigger callback. Implementors fire whenever a phase
53/// completes (success or failure). The implementation should
54/// be cheap to set up — long work runs on the worker thread,
55/// not the executor.
56pub trait PhaseEndTrigger: Send + Sync + 'static {
57    /// Fire the trigger. `event` carries the phase identity
58    /// and outcome; the implementation reads whatever live
59    /// state it needs (the session db, the metrics dir) on
60    /// its own.
61    fn fire(&self, event: &PhaseEndEvent);
62
63    /// Human-readable trigger name for logging / debugging.
64    /// Default returns the type name via `Any` downcast hint.
65    fn name(&self) -> &str {
66        "phase-end-trigger"
67    }
68}
69
70/// What the executor knows about a finished phase.
71#[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/// Outcome flavor — success, failure, or skip-equivalent.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum PhaseOutcome {
82    Completed,
83    Failed { error: String },
84}
85
86/// Opaque registration handle returned by [`register`]. Pass
87/// to [`unregister`] to remove the trigger.
88#[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
104/// Lock-free mirror of `Registry::triggers.len()`, kept in sync under the
105/// registry lock by [`register`] / [`unregister`]. [`fire`] reads this
106/// FIRST so the common case — no triggers registered, which is every
107/// concurrent-workload session today — is a single relaxed atomic load
108/// with **no lock**: concurrent executions never synchronize on the
109/// global registry when firing phase-end events (SRD-88 "session
110/// services are lock-free for concurrent workloads").
111static 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
123/// Register a phase-end trigger. Returns a [`TriggerId`] the
124/// caller can pass to [`unregister`] later. Idempotent within
125/// a single registration — the same trigger object registered
126/// twice fires twice.
127///
128/// The first registration also spawns the worker thread; it
129/// stays alive for the rest of the process so subsequent
130/// registrations are cheap.
131pub 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    // Lazy worker startup — only when there's at least one
140    // trigger. The thread reads events forever; it shuts down
141    // when the channel sender is dropped (process exit).
142    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
153/// Remove a previously-registered trigger. No-op when the id
154/// doesn't match anything currently registered (already
155/// removed, never registered, or freed by another caller).
156pub 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/// Drain the registry — used by integration tests that need
165/// trigger isolation between cases. Production code should
166/// not call this.
167#[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    // We deliberately leave the dispatch channel alive — the
176    // worker thread is fine sitting idle on an empty channel.
177}
178
179/// Fire the trigger chain for a successful phase. Called by
180/// the executor right after `observer.phase_completed(...)`.
181pub 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
190/// Fire the trigger chain for a failed phase. Called by the
191/// executor right after `observer.phase_failed(...)`.
192pub 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    // Lock-free fast path: no triggers registered ⇒ a relaxed atomic
205    // load and return. Concurrent executions firing phase-end events
206    // never contend on the global registry lock in the common case.
207    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        // Send to the worker. A full channel would only
218        // happen if the worker is wedged for many seconds;
219        // we'd rather drop a single event than block the
220        // executor.
221        let _ = tx.send(event);
222    }
223}
224
225fn dispatch_loop(rx: mpsc::Receiver<PhaseEndEvent>) {
226    // Bounded snapshot to limit memory in long-running runs.
227    loop {
228        match rx.recv_timeout(Duration::from_secs(60)) {
229            Ok(event) => {
230                // Snapshot the trigger list so a registration
231                // / unregistration during dispatch can't
232                // mutate the slice underneath us.
233                let snap: Vec<Arc<dyn PhaseEndTrigger>> = {
234                    let reg = match registry().lock() {
235                        Ok(r) => r,
236                        Err(_) => return, // registry poisoned — worker exits
237                    };
238                    reg.triggers.iter().map(|e| e.trigger.clone()).collect()
239                };
240                for trigger in snap {
241                    // Per-trigger catch_unwind so a panic in
242                    // one doesn't drop subsequent ones.
243                    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                        // Surface the failure but don't take
249                        // the worker down. The session log
250                        // sink picks this up via diag!.
251                        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                // Idle wakeup — loop back. Keeps the thread
261                // responsive to a clean process shutdown
262                // without spamming CPU on busy waits.
263            }
264            Err(mpsc::RecvTimeoutError::Disconnected) => {
265                // Sender dropped — process is exiting. Quiet
266                // exit.
267                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    /// Counter trigger — records how many times it fired and
291    /// the names of the phases it saw.
292    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    /// Wait for `count` to reach `target` or `timeout` to
308    /// elapse. The worker is async — tests synchronize via
309    /// the counter, not via channel polling.
310    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    /// Locking across test cases — the registry is global, so
322    /// concurrent tests would step on each other.
323    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        // Worker is async — give it time to (not) fire.
385        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        // The downstream counter must still see its fire.
445        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        // No registrations, no panic, no channel send.
457        fire_phase_completed("x", "", 1.0);
458        fire_phase_failed("y", "", "oops");
459        // Nothing to assert — the call returns without
460        // touching the worker. Implicit pass.
461    }
462}