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}