Skip to main content

chartml_core/resolver/
hooks.rs

1//! Observability for the resolver pipeline (chartml 5.0 phase 3c).
2//!
3//! Consumers implement [`ResolverHooks`] and pass it to
4//! [`ChartML::set_hooks`](crate::ChartML::set_hooks); the resolver fires
5//! events at every pipeline decision point so consumers can wire progress
6//! bars, cache-hit telemetry, custom error recovery, etc. without reading
7//! private resolver state.
8//!
9//! Mirrors the JS `PLUGIN_HOOKS.md` semantics with a Rust-idiomatic
10//! trait-based API.
11//!
12//! # Important constraints (read before implementing)
13//!
14//! - **Hooks must be panic-free.** `std::panic::catch_unwind` does not work
15//!   across `.await` points and is not available on WASM at all. We do not
16//!   try to catch hook panics — if a hook panics on native the spawned task
17//!   crashes; on WASM the whole module typically aborts. Hooks should always
18//!   catch their own errors internally.
19//! - **Hooks must never acquire any lock the resolver holds.** Re-entering
20//!   the resolver from within a hook is explicitly unsupported. The
21//!   fire-and-forget spawn in `spawn_hook` mitigates this in practice
22//!   because the hook runs on a separate task after the resolver's locks
23//!   are released, but a hook that takes its own lock that the resolver
24//!   later wants will still deadlock.
25//! - **Hook events are fire-and-forget on the runtime — the resolver does
26//!   NOT await hook delivery.** There is no ordering guarantee between
27//!   events: events for the same source may arrive in any order, and events
28//!   across sources interleave freely. Each `spawn_hook` call submits an
29//!   independent task with no happens-before relationship to subsequent
30//!   emits, so consumers that need a stable order must impose it themselves
31//!   (e.g. by sequence-stamping events on the way out of the hook).
32//! - **Hook errors are never propagated.** The trait methods return `()` so
33//!   there's no `Result` to swallow; if `tokio::spawn` / `spawn_local` itself
34//!   fails, the failure is logged via [`tracing::warn`] and not surfaced.
35
36use std::future::Future;
37use std::time::Duration;
38
39use async_trait::async_trait;
40
41/// Pipeline phase a [`ProgressEvent`] or [`ErrorEvent`] originated from.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum Phase {
44    /// Resolver fetch — cache walk + provider dispatch.
45    Fetch,
46    /// `ChartML::transform` — middleware application after fetch.
47    Transform,
48    /// `ChartML::render_prepared_to_svg` — final SVG emission.
49    Render,
50}
51
52/// Cache tier the entry was served from on a [`CacheHitEvent`].
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum CacheTier {
55    /// Tier-1 in-process cache (typically `MemoryBackend`).
56    Memory,
57    /// Tier-2 persistent cache (e.g., `IndexedDbBackend` in the browser).
58    Persistent,
59}
60
61/// Why a [`CacheMissEvent`] fired.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum MissReason {
64    /// Key was not present in any tier.
65    NotFound,
66    /// Key was present but the entry's TTL had elapsed.
67    Expired,
68    /// Key was explicitly invalidated via [`crate::resolver::Resolver::invalidate`]
69    /// (per-key) or one of the bulk APIs
70    /// ([`crate::resolver::Resolver::invalidate_all`],
71    /// [`crate::resolver::Resolver::invalidate_by_slug`],
72    /// [`crate::resolver::Resolver::invalidate_by_namespace`]).
73    /// Per-key invalidations report `Invalidated` for the next miss on that
74    /// exact key; bulk invalidations report `Invalidated` for the very next
75    /// miss on any key and then revert to `NotFound` / `Expired` reasoning
76    /// (the resolver doesn't enumerate every just-evicted key — see the
77    /// field-level docs on `Resolver::recently_invalidated` for the
78    /// rationale).
79    Invalidated,
80}
81
82/// Generic progress notification — fires at the start of each pipeline phase
83/// and again on completion. `loaded` / `total` are populated when a provider
84/// surfaces them; otherwise both are `None`.
85#[derive(Debug, Clone)]
86pub struct ProgressEvent {
87    pub phase: Phase,
88    pub source_name: Option<String>,
89    pub loaded: Option<u64>,
90    pub total: Option<u64>,
91    pub message: String,
92}
93
94/// Cache-hit notification.
95#[derive(Debug, Clone)]
96pub struct CacheHitEvent {
97    pub key: u64,
98    pub source_name: Option<String>,
99    pub tier: CacheTier,
100    pub age: Duration,
101}
102
103/// Cache-miss notification.
104#[derive(Debug, Clone)]
105pub struct CacheMissEvent {
106    pub key: u64,
107    pub source_name: Option<String>,
108    pub reason: MissReason,
109}
110
111/// Error notification — fires when a provider call (or transform, when
112/// emitted from `ChartML::transform`) returns an error. The error is
113/// stringified at the emission site so consumers don't need to know the
114/// concrete error type.
115#[derive(Debug, Clone)]
116pub struct ErrorEvent {
117    pub phase: Phase,
118    pub source_name: Option<String>,
119    pub error: String,
120}
121
122/// Observability trait. Consumers implement only the methods they care
123/// about; every default is a no-op so partial impls are zero-cost.
124///
125/// All methods are `async` so consumers can `await` inside (e.g. forwarding
126/// to an async telemetry sink). Return type is `()` because the resolver
127/// fires events fire-and-forget — there's no path for hook errors to
128/// propagate back. Hook impls should catch their own errors internally.
129///
130/// # Safety contract
131///
132/// - Hooks **must not panic** (see module-level docs).
133/// - Hooks **must not acquire any lock the resolver holds** (deadlock risk).
134/// - Hooks **must not call back into the resolver** (re-entry is undefined).
135// Native: hooks must be `Send + Sync` so they can be moved into spawned
136// tokio tasks. WASM: the resolver and runtime are single-threaded, so the
137// trait stays `?Send` to match `DataSourceProvider` / `CacheBackend`.
138#[cfg(not(target_arch = "wasm32"))]
139#[async_trait]
140pub trait ResolverHooks: Send + Sync {
141    /// Generic progress notification.
142    async fn on_progress(&self, _event: ProgressEvent) {}
143    /// Cache hit at any tier.
144    async fn on_cache_hit(&self, _event: CacheHitEvent) {}
145    /// Cache miss at every tier (or expired entry forcing re-fetch).
146    async fn on_cache_miss(&self, _event: CacheMissEvent) {}
147    /// Provider or transform error. Note: hook fires per-source for
148    /// `NamedMap` shapes even though only the first error bubbles up to the
149    /// caller as the user-facing `ChartError`.
150    async fn on_error(&self, _event: ErrorEvent) {}
151}
152
153#[cfg(target_arch = "wasm32")]
154#[async_trait(?Send)]
155pub trait ResolverHooks {
156    /// Generic progress notification.
157    async fn on_progress(&self, _event: ProgressEvent) {}
158    /// Cache hit at any tier.
159    async fn on_cache_hit(&self, _event: CacheHitEvent) {}
160    /// Cache miss at every tier (or expired entry forcing re-fetch).
161    async fn on_cache_miss(&self, _event: CacheMissEvent) {}
162    /// Provider or transform error. Note: hook fires per-source for
163    /// `NamedMap` shapes even though only the first error bubbles up to the
164    /// caller as the user-facing `ChartError`.
165    async fn on_error(&self, _event: ErrorEvent) {}
166}
167
168/// Zero-cost default impl. Used by the resolver when no hook has been
169/// registered. Public so consumers writing tests can opt into "explicitly
170/// no hooks" without inventing a noop type.
171#[derive(Debug, Default, Clone, Copy)]
172pub struct NullHooks;
173
174#[cfg(not(target_arch = "wasm32"))]
175#[async_trait]
176impl ResolverHooks for NullHooks {}
177
178#[cfg(target_arch = "wasm32")]
179#[async_trait(?Send)]
180impl ResolverHooks for NullHooks {}
181
182/// Shared handle to a registered hook impl. `Arc` on native (so spawned
183/// hook tasks can hold a clone without lifetime juggling), `Rc` on WASM
184/// (single-threaded; matches the resolver's `SharedRef` story).
185#[cfg(not(target_arch = "wasm32"))]
186pub type HooksRef = std::sync::Arc<dyn ResolverHooks>;
187#[cfg(target_arch = "wasm32")]
188pub type HooksRef = std::rc::Rc<dyn ResolverHooks>;
189
190/// Fire-and-forget hook dispatch. Wraps `tokio::spawn` on native and
191/// `wasm_bindgen_futures::spawn_local` on WASM so a slow telemetry sink
192/// can't stall the resolver.
193///
194/// **Native fallback:** if no tokio runtime is current, the event is
195/// logged via [`tracing::warn`] and dropped. Run the resolver inside a
196/// tokio runtime (e.g. `#[tokio::main]` or `tokio::runtime::Runtime`)
197/// to receive hook events.
198///
199/// Spawn failures are logged via `tracing::warn!` and never propagated.
200#[cfg(not(target_arch = "wasm32"))]
201pub(crate) fn spawn_hook<F>(fut: F)
202where
203    F: Future<Output = ()> + Send + 'static,
204{
205    match tokio::runtime::Handle::try_current() {
206        Ok(handle) => {
207            // Spawn the hook on the current runtime. We intentionally
208            // discard the `JoinHandle` because hooks are fire-and-forget
209            // by design; dropping the handle does NOT cancel the spawned
210            // task on tokio's multi-thread or current-thread runtimes.
211            // Using a named binding (`_handle`) instead of bare `_` keeps
212            // the JoinHandle alive for the duration of this scope and
213            // makes intent explicit.
214            let _handle = handle.spawn(fut);
215        }
216        Err(_) => {
217            // No tokio runtime available (e.g., a non-tokio test harness).
218            // We cannot safely `block_on` here — the caller is already
219            // inside an async context. Log and drop so the resolver
220            // doesn't stall.
221            tracing::warn!(
222                target: "chartml::resolver::hooks",
223                "no tokio runtime available; dropping hook event (consider running inside a tokio runtime to receive resolver hooks)"
224            );
225            drop(fut);
226        }
227    }
228}
229
230/// WASM variant — `spawn_local` requires the `wasm-bindgen` event loop,
231/// which is always present in browser contexts. No fallback needed.
232#[cfg(target_arch = "wasm32")]
233pub(crate) fn spawn_hook<F>(fut: F)
234where
235    F: Future<Output = ()> + 'static,
236{
237    wasm_bindgen_futures::spawn_local(fut);
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    /// `NullHooks` is genuinely zero-cost — no allocation, no state.
245    #[test]
246    fn null_hooks_is_zst() {
247        assert_eq!(std::mem::size_of::<NullHooks>(), 0);
248    }
249
250    /// Trait docstring contains the panic-free constraint that the
251    /// `test_hook_panic_is_documented` integration test checks for.
252    #[test]
253    fn module_docs_document_panic_free_requirement() {
254        // The integration test reads the source file directly to verify
255        // wording; this unit test exists so the requirement is also
256        // exercised by `cargo test --lib`.
257        let module_doc = include_str!("hooks.rs");
258        assert!(
259            module_doc.contains("Hooks must be panic-free")
260                || module_doc.contains("must not panic"),
261            "module docs must document the panic-free requirement"
262        );
263        assert!(
264            module_doc.contains("fire-and-forget on the runtime")
265                || module_doc.contains("no ordering guarantee"),
266            "module docs must document the fire-and-forget / no-ordering semantics"
267        );
268    }
269}