Skip to main content

linkrs_core/
event_dispatch.rs

1use std::sync::atomic::{AtomicU64, Ordering};
2use std::sync::Arc;
3
4/// Best-effort synchronous dispatch for event-hook observers.
5///
6/// Callbacks run inline on the emitter path, each isolated with
7/// `catch_unwind` so a panicking observer cannot break the main flow
8/// or starve later observers. Observers must return quickly, must not
9/// hold locks across the call, and must not call back into the
10/// emitting subsystem.
11///
12/// This is a notification hook: observers cannot veto or mutate the
13/// event. Decision hooks (commit veto, query interrupt) and pipeline
14/// hooks (parser/binder/planner extensions) are separate mechanisms.
15///
16/// Retention rule: callbacks must not retain `&E` or any borrow derived
17/// from it beyond the dispatch call. The event (and heavy payloads it may
18/// carry, e.g. a transaction write set) is only valid for the duration of
19/// the callback; observers needing data afterwards must clone explicitly
20/// and pay that cost themselves. Prefer filtered subscriptions over
21/// cloning the full event stream.
22/// Event-hook observer callbacks run inline on the emitter path.
23pub type EventCallbacks<E> = [Arc<dyn Fn(&E) + Send + Sync>];
24
25pub fn dispatch_event_callbacks<E>(owner: &str, callbacks: &EventCallbacks<E>, event: &E) -> usize {
26    let mut panics = 0;
27    for (index, callback) in callbacks.iter().enumerate() {
28        if let Err(payload) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
29            callback(event);
30        })) {
31            panics += 1;
32            log::error!(
33                "{} callback #{} panicked: {}; continuing dispatch",
34                owner,
35                index,
36                panic_payload_message(&payload)
37            );
38        }
39    }
40    panics
41}
42
43/// Outcome of a filtered dispatch.
44///
45/// `panics` counts both panicking filters and panicking callbacks.
46/// A panicking filter skips its observer without invoking it.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub struct DispatchOutcome {
49    pub delivered: usize,
50    pub skipped_by_filter: usize,
51    pub panics: usize,
52}
53
54/// Outcome of a timed dispatch (`dispatch_timed`).
55///
56/// `outcome` is the standard breakdown; `slow` counts callbacks whose
57/// wall time reached `slow_threshold`, and `slowest_ms` is the maximum
58/// observed callback latency in milliseconds.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
60pub struct TimedDispatchOutcome {
61    pub outcome: DispatchOutcome,
62    pub slow: usize,
63    pub slowest_ms: u64,
64}
65
66/// Handle returned when registering an event-hook observer.
67///
68/// The id can be passed back to `EventSubscriptions::remove` to unsubscribe.
69/// Registration only appends; removal is explicit so long-running processes
70/// do not accumulate dead observers.
71pub type SubscriptionId = u64;
72
73/// Filter predicate for event-hook observers.
74///
75/// Returning `false` skips the observer for that event without invoking it.
76pub type EventFilter<E> = Arc<dyn Fn(&E) -> bool + Send + Sync>;
77
78struct SubscriptionEntry<E> {
79    id: SubscriptionId,
80    callback: Arc<dyn Fn(&E) + Send + Sync>,
81    filter: Option<EventFilter<E>>,
82}
83
84struct SnapshotEntry<E> {
85    callback: Arc<dyn Fn(&E) + Send + Sync>,
86    filter: Option<EventFilter<E>>,
87}
88
89enum FilterEval {
90    Matched,
91    Skip,
92    Panicked,
93}
94
95fn eval_filter<E>(
96    owner: &str,
97    index: usize,
98    filter: Option<&EventFilter<E>>,
99    event: &E,
100) -> FilterEval {
101    match filter {
102        None => FilterEval::Matched,
103        Some(filter) => {
104            match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| filter(event))) {
105                Ok(true) => FilterEval::Matched,
106                Ok(false) => FilterEval::Skip,
107                Err(payload) => {
108                    log::error!(
109                        "{} filter #{} panicked: {}; skipping observer",
110                        owner,
111                        index,
112                        panic_payload_message(&payload)
113                    );
114                    FilterEval::Panicked
115                }
116            }
117        }
118    }
119}
120
121fn invoke_callback<E>(
122    callback: &dyn Fn(&E),
123    event: &E,
124) -> Result<(), Box<dyn std::any::Any + Send>> {
125    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(event)))
126}
127
128/// Cancellable, optionally filtered registry for event-hook observers.
129///
130/// Synchronous best-effort notification semantics match
131/// `dispatch_event_callbacks`: observers run inline on the emitter path,
132/// each isolated with `catch_unwind`. Observers must return quickly, must
133/// not hold locks across the call, and must not call back into the
134/// emitting subsystem.
135///
136/// Reentrancy: `dispatch` snapshots the subscription list, releases the
137/// internal lock, then evaluates filters and invokes callbacks. A filter
138/// or callback may therefore register or remove observers without
139/// deadlocking; registrations made during dispatch do not affect the
140/// in-flight dispatch. A panicking filter skips its observer and is
141/// counted the same as a panicking callback.
142pub struct EventSubscriptions<E> {
143    next_id: AtomicU64,
144    entries: parking_lot::RwLock<Vec<SubscriptionEntry<E>>>,
145}
146
147impl<E> Default for EventSubscriptions<E> {
148    fn default() -> Self {
149        Self::new()
150    }
151}
152
153impl<E> EventSubscriptions<E> {
154    pub fn new() -> Self {
155        Self {
156            next_id: AtomicU64::new(1),
157            entries: parking_lot::RwLock::new(Vec::new()),
158        }
159    }
160
161    /// Register an observer, returning its subscription id.
162    pub fn add(&self, callback: Arc<dyn Fn(&E) + Send + Sync>) -> SubscriptionId {
163        self.add_filtered(callback, None)
164    }
165
166    /// Register an observer with an optional filter predicate.
167    pub fn add_filtered(
168        &self,
169        callback: Arc<dyn Fn(&E) + Send + Sync>,
170        filter: Option<EventFilter<E>>,
171    ) -> SubscriptionId {
172        let id = self.next_id.fetch_add(1, Ordering::SeqCst);
173        self.entries.write().push(SubscriptionEntry {
174            id,
175            callback,
176            filter,
177        });
178        id
179    }
180
181    /// Remove a previously registered observer. Returns true if present.
182    pub fn remove(&self, id: SubscriptionId) -> bool {
183        let mut entries = self.entries.write();
184        let before = entries.len();
185        entries.retain(|entry| entry.id != id);
186        entries.len() != before
187    }
188
189    /// Remove all registered observers.
190    pub fn clear(&self) {
191        self.entries.write().clear();
192    }
193
194    /// Register an observer and receive an RAII guard that unsubscribes
195    /// on drop. Useful for scoped subscriptions in tests and request
196    /// handlers where manual `remove` is easy to forget.
197    pub fn subscribe(&self, callback: Arc<dyn Fn(&E) + Send + Sync>) -> SubscriptionGuard<'_, E> {
198        let id = self.add(callback);
199        SubscriptionGuard::new(self, id)
200    }
201
202    /// Number of registered observers.
203    pub fn len(&self) -> usize {
204        self.entries.read().len()
205    }
206
207    /// Whether any observer is registered.
208    pub fn is_empty(&self) -> bool {
209        self.entries.read().is_empty()
210    }
211
212    /// Dispatch an event to matching observers with panic isolation.
213    ///
214    /// Returns the number of observers that panicked.
215    pub fn dispatch(&self, owner: &str, event: &E) -> usize {
216        self.dispatch_detailed(owner, event).panics
217    }
218
219    fn snapshot_locked(&self) -> Vec<SnapshotEntry<E>> {
220        let entries = self.entries.read();
221        if entries.is_empty() {
222            return Vec::new();
223        }
224        entries
225            .iter()
226            .map(|entry| SnapshotEntry {
227                callback: Arc::clone(&entry.callback),
228                filter: entry.filter.clone(),
229            })
230            .collect()
231    }
232
233    /// Dispatch with a full outcome breakdown.
234    pub fn dispatch_detailed(&self, owner: &str, event: &E) -> DispatchOutcome {
235        let snapshot = self.snapshot_locked();
236        if snapshot.is_empty() {
237            return DispatchOutcome::default();
238        }
239        let mut outcome = DispatchOutcome::default();
240        for (index, item) in snapshot.iter().enumerate() {
241            match eval_filter(owner, index, item.filter.as_ref(), event) {
242                FilterEval::Skip => {
243                    outcome.skipped_by_filter += 1;
244                    continue;
245                }
246                FilterEval::Panicked => {
247                    outcome.panics += 1;
248                    outcome.skipped_by_filter += 1;
249                    continue;
250                }
251                FilterEval::Matched => {}
252            }
253            match invoke_callback(item.callback.as_ref(), event) {
254                Ok(()) => {
255                    outcome.delivered += 1;
256                }
257                Err(payload) => {
258                    outcome.panics += 1;
259                    log::error!(
260                        "{} callback #{} panicked: {}; continuing dispatch",
261                        owner,
262                        index,
263                        panic_payload_message(&payload)
264                    );
265                    continue;
266                }
267            }
268        }
269        outcome
270    }
271
272    /// Timed dispatch: like `dispatch_detailed`, additionally measuring each
273    /// callback's wall time. Callbacks reaching `slow_threshold` are counted
274    /// in `slow`, contribute to `slowest_ms`, and emit a `log::warn` so slow
275    /// observers are visible without affecting delivery.
276    ///
277    /// Timing costs one `Instant::now()` pair per delivered callback, so
278    /// hot paths should keep using `dispatch`/`dispatch_detailed` and opt
279    /// into this only for diagnostics or slow-path emission points.
280    pub fn dispatch_timed(
281        &self,
282        owner: &str,
283        event: &E,
284        slow_threshold: std::time::Duration,
285    ) -> TimedDispatchOutcome {
286        let snapshot = self.snapshot_locked();
287        if snapshot.is_empty() {
288            return TimedDispatchOutcome::default();
289        }
290        let mut result = TimedDispatchOutcome::default();
291        for (index, item) in snapshot.iter().enumerate() {
292            match eval_filter(owner, index, item.filter.as_ref(), event) {
293                FilterEval::Skip => {
294                    result.outcome.skipped_by_filter += 1;
295                    continue;
296                }
297                FilterEval::Panicked => {
298                    result.outcome.panics += 1;
299                    result.outcome.skipped_by_filter += 1;
300                    continue;
301                }
302                FilterEval::Matched => {}
303            }
304            let start = std::time::Instant::now();
305            match invoke_callback(item.callback.as_ref(), event) {
306                Ok(()) => {
307                    result.outcome.delivered += 1;
308                }
309                Err(payload) => {
310                    result.outcome.panics += 1;
311                    log::error!(
312                        "{} callback #{} panicked: {}; continuing dispatch",
313                        owner,
314                        index,
315                        panic_payload_message(&payload)
316                    );
317                }
318            }
319            let elapsed = start.elapsed();
320            if elapsed >= slow_threshold {
321                result.slow += 1;
322                let elapsed_ms = elapsed.as_millis() as u64;
323                result.slowest_ms = result.slowest_ms.max(elapsed_ms);
324                log::warn!(
325                    "{} callback #{} slow: {}ms >= {}ms threshold",
326                    owner,
327                    index,
328                    elapsed_ms,
329                    slow_threshold.as_millis() as u64,
330                );
331            }
332        }
333        result
334    }
335}
336
337/// RAII unsubscription guard returned by `EventSubscriptions::subscribe`.
338///
339/// Dropping the guard removes the observer. Use `forget` to keep a
340/// long-lived subscription, or `id` to inspect the subscription id.
341pub struct SubscriptionGuard<'a, E> {
342    registry: Option<&'a EventSubscriptions<E>>,
343    id: SubscriptionId,
344}
345
346impl<'a, E> SubscriptionGuard<'a, E> {
347    fn new(registry: &'a EventSubscriptions<E>, id: SubscriptionId) -> Self {
348        Self {
349            registry: Some(registry),
350            id,
351        }
352    }
353
354    /// Subscription id of the guarded observer.
355    pub fn id(&self) -> SubscriptionId {
356        self.id
357    }
358
359    /// Keep the subscription alive beyond the guard without removing it.
360    pub fn forget(mut self) -> SubscriptionId {
361        let id = self.id;
362        self.registry = None;
363        std::mem::forget(self);
364        id
365    }
366}
367
368impl<E> Drop for SubscriptionGuard<'_, E> {
369    fn drop(&mut self) {
370        if let Some(registry) = self.registry {
371            registry.remove(self.id);
372        }
373    }
374}
375
376pub fn panic_payload_message(payload: &Box<dyn std::any::Any + Send>) -> String {
377    if let Some(message) = payload.downcast_ref::<&str>() {
378        message.to_string()
379    } else if let Some(message) = payload.downcast_ref::<String>() {
380        message.clone()
381    } else {
382        "unknown panic payload".to_string()
383    }
384}
385
386/// Opt-in observer-side escape hatch for slow consumers.
387///
388/// The leaf registries always dispatch synchronously inline, so a slow
389/// observer would block the emitter path. Wrap such an observer in an
390/// `AsyncForwarder`: the dispatch-path callback only does a bounded
391/// `try_send` (never blocks), while a dedicated worker thread runs the
392/// real callback. When the queue is full the event is dropped and
393/// `dropped_count` is incremented, so backpressure is explicit instead of
394/// silently stalling commits, DDL, or checkpoints.
395///
396/// Dropping the forwarder closes the channel and ends the worker thread.
397/// Use `adapter` to get an `Arc` callback suitable for `add`/`subscribe`.
398pub struct AsyncForwarder<E> {
399    sender: std::sync::mpsc::SyncSender<E>,
400    dropped: std::sync::atomic::AtomicUsize,
401}
402
403impl<E> AsyncForwarder<E>
404where
405    E: Clone + Send + 'static,
406{
407    /// Spawn a worker running `callback` for each forwarded event.
408    ///
409    /// Returns the forwarder plus the worker's join handle. Drop the
410    /// forwarder (closing the channel) before joining. A panicking
411    /// `callback` is isolated per event: the error is logged and the
412    /// worker keeps consuming.
413    pub fn spawn(
414        capacity: usize,
415        callback: impl Fn(&E) + Send + 'static,
416    ) -> (Arc<Self>, std::thread::JoinHandle<()>) {
417        let (sender, receiver) = std::sync::mpsc::sync_channel(capacity);
418        let forwarder = Arc::new(Self {
419            sender,
420            dropped: std::sync::atomic::AtomicUsize::new(0),
421        });
422        let handle = std::thread::spawn(move || {
423            for event in receiver {
424                if invoke_callback(&callback, &event).is_err() {
425                    log::error!("async forwarder callback panicked; continuing");
426                }
427            }
428        });
429        (forwarder, handle)
430    }
431
432    /// Hand an event to the worker without blocking. Drops (and counts)
433    /// the event when the queue is full or the worker is gone.
434    pub fn forward(&self, event: &E) {
435        if self.sender.try_send(event.clone()).is_err() {
436            self.dropped
437                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
438        }
439    }
440
441    /// Number of events dropped due to a full queue or a gone worker.
442    pub fn dropped_count(&self) -> usize {
443        self.dropped.load(std::sync::atomic::Ordering::Relaxed)
444    }
445
446    /// Dispatch-path adapter: `registry.add(forwarder.adapter())`.
447    pub fn adapter(self: &Arc<Self>) -> Arc<dyn Fn(&E) + Send + Sync> {
448        let forwarder = Arc::clone(self);
449        Arc::new(move |event: &E| forwarder.forward(event))
450    }
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use std::sync::atomic::{AtomicUsize, Ordering};
457
458    #[test]
459    fn dispatch_reaches_all_and_isolates_panic() {
460        let registry: EventSubscriptions<u32> = EventSubscriptions::new();
461        let hits = Arc::new(AtomicUsize::new(0));
462        let first = Arc::clone(&hits);
463        registry.add(Arc::new(move |_| {
464            first.fetch_add(1, Ordering::SeqCst);
465        }));
466        registry.add(Arc::new(|_: &u32| panic!("boom")));
467        let last = Arc::clone(&hits);
468        registry.add(Arc::new(move |_| {
469            last.fetch_add(1, Ordering::SeqCst);
470        }));
471        let panics = registry.dispatch("test", &1);
472        assert_eq!(panics, 1);
473        assert_eq!(hits.load(Ordering::SeqCst), 2);
474    }
475
476    #[test]
477    fn remove_and_filter_are_honored() {
478        let registry: EventSubscriptions<u32> = EventSubscriptions::new();
479        let hits = Arc::new(AtomicUsize::new(0));
480        let probe = Arc::clone(&hits);
481        let id = registry.add(Arc::new(move |_| {
482            probe.fetch_add(1, Ordering::SeqCst);
483        }));
484        assert_eq!(registry.len(), 1);
485        assert!(registry.remove(id));
486        assert!(!registry.remove(id));
487        assert_eq!(registry.dispatch("test", &1), 0);
488        assert_eq!(hits.load(Ordering::SeqCst), 0);
489
490        let even_probe = Arc::clone(&hits);
491        registry.add_filtered(
492            Arc::new(move |_| {
493                even_probe.fetch_add(1, Ordering::SeqCst);
494            }),
495            Some(Arc::new(|event: &u32| event.is_multiple_of(2))),
496        );
497        assert_eq!(registry.dispatch("test", &1), 0);
498        assert_eq!(registry.dispatch("test", &2), 0);
499        assert_eq!(hits.load(Ordering::SeqCst), 1);
500    }
501
502    #[test]
503    fn panicking_filter_skips_observer_without_aborting_dispatch() {
504        let registry: EventSubscriptions<u32> = EventSubscriptions::new();
505        let hits = Arc::new(AtomicUsize::new(0));
506        let bad_probe = Arc::clone(&hits);
507        registry.add_filtered(
508            Arc::new(move |_| {
509                bad_probe.fetch_add(1, Ordering::SeqCst);
510            }),
511            Some(Arc::new(|_: &u32| panic!("filter boom"))),
512        );
513        let good_probe = Arc::clone(&hits);
514        registry.add(Arc::new(move |_| {
515            good_probe.fetch_add(1, Ordering::SeqCst);
516        }));
517        let outcome = registry.dispatch_detailed("test", &1);
518        assert_eq!(outcome.panics, 1);
519        assert_eq!(outcome.delivered, 1);
520        assert_eq!(outcome.skipped_by_filter, 1);
521        assert_eq!(hits.load(Ordering::SeqCst), 1);
522    }
523
524    #[test]
525    fn dispatch_does_not_hold_lock_during_callbacks() {
526        let registry: Arc<EventSubscriptions<u32>> = Arc::new(EventSubscriptions::new());
527        let nested: Arc<EventSubscriptions<u32>> = Arc::clone(&registry);
528        let hits = Arc::new(AtomicUsize::new(0));
529        let probe = Arc::clone(&hits);
530        registry.add(Arc::new(move |_| {
531            probe.fetch_add(1, Ordering::SeqCst);
532            nested.add(Arc::new(|_| {}));
533        }));
534        let nested_filter: Arc<EventSubscriptions<u32>> = Arc::clone(&registry);
535        registry.add_filtered(
536            Arc::new(|_| {}),
537            Some(Arc::new(move |_| {
538                nested_filter.len();
539                true
540            })),
541        );
542        let outcome = registry.dispatch_detailed("test", &1);
543        assert_eq!(outcome.delivered, 2);
544        assert_eq!(outcome.panics, 0);
545        assert_eq!(hits.load(Ordering::SeqCst), 1);
546        assert_eq!(registry.len(), 3);
547    }
548
549    #[test]
550    fn subscription_guard_removes_on_drop_and_clear_empties() {
551        let registry: EventSubscriptions<u32> = EventSubscriptions::new();
552        {
553            let _guard = registry.subscribe(Arc::new(|_: &u32| {}));
554            assert_eq!(registry.len(), 1);
555        }
556        assert_eq!(registry.len(), 0);
557        registry.add(Arc::new(|_: &u32| {}));
558        registry.add(Arc::new(|_: &u32| {}));
559        assert_eq!(registry.len(), 2);
560        registry.clear();
561        assert!(registry.is_empty());
562    }
563
564    #[test]
565    fn timed_dispatch_flags_slow_observer() {
566        use std::time::Duration;
567        let registry: EventSubscriptions<u32> = EventSubscriptions::new();
568        registry.add(Arc::new(|_| {}));
569        registry.add(Arc::new(|_: &u32| {
570            std::thread::sleep(Duration::from_millis(15));
571        }));
572        let outcome = registry.dispatch_timed("test", &1, Duration::from_millis(5));
573        assert_eq!(outcome.outcome.delivered, 2);
574        assert_eq!(outcome.outcome.panics, 0);
575        assert_eq!(outcome.slow, 1);
576        assert!(outcome.slowest_ms >= 5);
577
578        // Empty registry: zero outcome, no timing cost.
579        let empty: EventSubscriptions<u32> = EventSubscriptions::new();
580        assert_eq!(
581            empty.dispatch_timed("test", &1, Duration::from_millis(1)),
582            TimedDispatchOutcome::default()
583        );
584    }
585
586    #[test]
587    fn async_forwarder_delivers_without_blocking_emitter() {
588        let registry: EventSubscriptions<u32> = EventSubscriptions::new();
589        let received = Arc::new(AtomicUsize::new(0));
590        let probe = Arc::clone(&received);
591        let (forwarder, handle) = AsyncForwarder::spawn(16, move |_: &u32| {
592            probe.fetch_add(1, Ordering::SeqCst);
593        });
594        registry.add(forwarder.adapter());
595        for event in 0..5u32 {
596            registry.dispatch("test", &event);
597        }
598        assert_eq!(forwarder.dropped_count(), 0);
599        drop(registry);
600        drop(forwarder);
601        handle.join().expect("worker joins");
602        assert_eq!(received.load(Ordering::SeqCst), 5);
603    }
604
605    #[test]
606    fn async_forwarder_counts_drops_on_full_queue() {
607        // Rendezvous channel with a slow worker: some forwards land while
608        // the worker waits, the rest are dropped and counted. Dropped plus
609        // received must account for every sent event.
610        let received = Arc::new(AtomicUsize::new(0));
611        let probe = Arc::clone(&received);
612        let (forwarder, handle) = AsyncForwarder::spawn(0, move |_: &u32| {
613            probe.fetch_add(1, Ordering::SeqCst);
614            std::thread::sleep(std::time::Duration::from_millis(5));
615        });
616        for event in 0..10u32 {
617            forwarder.forward(&event);
618        }
619        let dropped = forwarder.dropped_count();
620        drop(forwarder);
621        handle.join().expect("worker joins");
622        assert_eq!(received.load(Ordering::SeqCst) + dropped, 10);
623    }
624}