query_flow/
tracer.rs

1//! Tracer trait for observing query-flow execution.
2//!
3//! This module defines the [`Tracer`] trait and related types for observing
4//! query execution. The default [`NoopTracer`] provides zero-cost when tracing
5//! is not needed.
6//!
7//! # Example
8//!
9//! ```ignore
10//! use query_flow::{QueryRuntime, Tracer, SpanId, TraceId, SpanContext, QueryCacheKey};
11//!
12//! // Custom tracer implementation
13//! struct MyTracer;
14//!
15//! impl Tracer for MyTracer {
16//!     fn new_span_id(&self) -> SpanId {
17//!         SpanId(1)
18//!     }
19//!
20//!     fn new_trace_id(&self) -> TraceId {
21//!         TraceId(1)
22//!     }
23//!
24//!     fn on_query_start(&self, ctx: &SpanContext, query: &QueryCacheKey) {
25//!         println!("Query started: {:?} (trace={:?})", query, ctx.trace_id);
26//!     }
27//! }
28//!
29//! let runtime = QueryRuntime::with_tracer(MyTracer);
30//! ```
31
32use serde::{Deserialize, Serialize};
33
34use crate::key::{AssetCacheKey, FullCacheKey, QueryCacheKey};
35
36/// Unique identifier for a query execution span.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
38pub struct SpanId(pub u64);
39
40impl SpanId {
41    /// Zero value, used by NoopTracer.
42    pub const ZERO: Self = Self(0);
43}
44
45/// Unique identifier for a trace (a complete query execution tree).
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
47pub struct TraceId(pub u64);
48
49impl TraceId {
50    /// Zero value, used by NoopTracer.
51    pub const ZERO: Self = Self(0);
52}
53
54/// Context for a span within a trace, providing parent-child relationships.
55///
56/// This enables reconstructing the full dependency tree of query executions.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
58pub struct SpanContext {
59    /// Unique identifier for this span.
60    pub span_id: SpanId,
61    /// Identifier for the trace this span belongs to.
62    pub trace_id: TraceId,
63    /// The parent span's ID, if this is a nested query.
64    pub parent_span_id: Option<SpanId>,
65}
66
67/// Query execution result classification.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub enum ExecutionResult {
70    /// Query computed a new value (output changed).
71    Changed,
72    /// Query completed but output unchanged (early cutoff applied).
73    Unchanged,
74    /// Query returned cached value without execution.
75    CacheHit,
76    /// Query suspended waiting for async loading.
77    Suspended,
78    /// Query detected a dependency cycle.
79    CycleDetected,
80    /// Query failed with an error.
81    Error { message: String },
82}
83
84/// Asset loading state for tracing.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum TracerAssetState {
87    /// Asset is currently loading.
88    Loading,
89    /// Asset is ready with a value.
90    Ready,
91    /// Asset was not found.
92    NotFound,
93}
94
95/// Reason for cache invalidation.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum InvalidationReason {
98    /// A dependency query changed its output.
99    DependencyChanged { dep: FullCacheKey },
100    /// An asset dependency was updated.
101    AssetChanged { asset: FullCacheKey },
102    /// Manual invalidation was triggered.
103    ManualInvalidation,
104    /// An asset was removed.
105    AssetRemoved { asset: FullCacheKey },
106}
107
108/// Tracer trait for observing query-flow execution.
109///
110/// Implementations can collect events for testing, forward to the `tracing` crate,
111/// or provide custom observability.
112///
113/// All methods have default empty implementations, so you only need to override
114/// the events you're interested in. The [`NoopTracer`] uses all defaults for
115/// zero-cost when tracing is disabled.
116///
117/// # Thread Safety
118///
119/// Implementations must be `Send + Sync` as the tracer may be called from
120/// multiple threads concurrently.
121pub trait Tracer: Send + Sync + 'static {
122    /// Generate a new unique span ID.
123    ///
124    /// Called at the start of each query execution.
125    fn new_span_id(&self) -> SpanId;
126
127    /// Generate a new unique trace ID.
128    ///
129    /// Called when starting a root query (one with no parent in the span stack).
130    fn new_trace_id(&self) -> TraceId;
131
132    /// Called when a query execution starts.
133    ///
134    /// Use `query.type_name()` to get the query type and `query.debug_repr()` for the key.
135    #[inline]
136    fn on_query_start(&self, _ctx: &SpanContext, _query: &QueryCacheKey) {}
137
138    /// Called when cache validity is checked.
139    #[inline]
140    fn on_cache_check(&self, _ctx: &SpanContext, _query: &QueryCacheKey, _valid: bool) {}
141
142    /// Called when a query execution ends.
143    #[inline]
144    fn on_query_end(&self, _ctx: &SpanContext, _query: &QueryCacheKey, _result: ExecutionResult) {}
145
146    /// Called when a query dependency is registered during execution.
147    #[inline]
148    fn on_dependency_registered(
149        &self,
150        _ctx: &SpanContext,
151        _parent: &FullCacheKey,
152        _dependency: &FullCacheKey,
153    ) {
154    }
155
156    /// Called when an asset dependency is registered during execution.
157    #[inline]
158    fn on_asset_dependency_registered(
159        &self,
160        _ctx: &SpanContext,
161        _parent: &FullCacheKey,
162        _asset: &FullCacheKey,
163    ) {
164    }
165
166    /// Called when early cutoff comparison is performed.
167    #[inline]
168    fn on_early_cutoff_check(
169        &self,
170        _ctx: &SpanContext,
171        _query: &QueryCacheKey,
172        _output_changed: bool,
173    ) {
174    }
175
176    /// Called when an asset is requested (START event).
177    ///
178    /// This is called BEFORE the locator executes. Child queries called by
179    /// the locator will appear as children of this asset in the trace tree.
180    #[inline]
181    fn on_asset_requested(&self, _ctx: &SpanContext, _asset: &AssetCacheKey) {}
182
183    /// Called when an asset locator finishes execution.
184    ///
185    /// This is the "end" event for assets, corresponding to `on_query_end` for queries.
186    /// Called after the locator executes with the final state.
187    #[inline]
188    fn on_asset_located(
189        &self,
190        _ctx: &SpanContext,
191        _asset: &AssetCacheKey,
192        _state: TracerAssetState,
193    ) {
194    }
195
196    /// Called when an asset is resolved with a value.
197    #[inline]
198    fn on_asset_resolved(&self, _asset: &AssetCacheKey, _changed: bool) {}
199
200    /// Called when an asset is invalidated.
201    #[inline]
202    fn on_asset_invalidated(&self, _asset: &AssetCacheKey) {}
203
204    /// Called when a query is invalidated.
205    #[inline]
206    fn on_query_invalidated(&self, _query: &QueryCacheKey, _reason: InvalidationReason) {}
207
208    /// Called when a dependency cycle is detected.
209    ///
210    /// The path can contain both queries and assets since cycles may involve asset locators.
211    #[inline]
212    fn on_cycle_detected(&self, _path: &[FullCacheKey]) {}
213
214    /// Called when a query is accessed, providing the [`FullCacheKey`] for GC tracking.
215    ///
216    /// This is called at the start of each query execution, before `on_query_start`.
217    /// Use this to track access times or reference counts for garbage collection.
218    ///
219    /// # Example
220    ///
221    /// ```ignore
222    /// use query_flow::{FullCacheKey, Tracer, SpanId};
223    /// use std::collections::HashMap;
224    /// use std::sync::Mutex;
225    /// use std::time::Instant;
226    ///
227    /// struct GcTracer {
228    ///     access_times: Mutex<HashMap<FullCacheKey, Instant>>,
229    /// }
230    ///
231    /// impl Tracer for GcTracer {
232    ///     fn new_span_id(&self) -> SpanId { SpanId(0) }
233    ///
234    ///     fn on_query_key(&self, full_key: &FullCacheKey) {
235    ///         self.access_times.lock().unwrap()
236    ///             .insert(full_key.clone(), Instant::now());
237    ///     }
238    /// }
239    /// ```
240    #[inline]
241    fn on_query_key(&self, _full_key: &FullCacheKey) {}
242}
243
244/// Zero-cost tracer that discards all events.
245///
246/// This is the default tracer for [`QueryRuntime`](crate::QueryRuntime).
247pub struct NoopTracer;
248
249impl Tracer for NoopTracer {
250    #[inline(always)]
251    fn new_span_id(&self) -> SpanId {
252        // ZERO is valid because all callbacks are no-ops, so no one observes these IDs.
253        // This avoids atomic counter overhead.
254        SpanId::ZERO
255    }
256
257    #[inline(always)]
258    fn new_trace_id(&self) -> TraceId {
259        TraceId::ZERO
260    }
261    // All other methods use the default empty implementations
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use crate::key::QueryCacheKey;
268    use std::sync::atomic::AtomicUsize;
269    use std::sync::atomic::Ordering;
270    use std::sync::Arc;
271
272    struct CountingTracer {
273        start_count: AtomicUsize,
274        end_count: AtomicUsize,
275    }
276
277    impl CountingTracer {
278        fn new() -> Self {
279            Self {
280                start_count: AtomicUsize::new(0),
281                end_count: AtomicUsize::new(0),
282            }
283        }
284    }
285
286    impl Tracer for CountingTracer {
287        fn new_span_id(&self) -> SpanId {
288            SpanId(1)
289        }
290
291        fn new_trace_id(&self) -> TraceId {
292            TraceId(1)
293        }
294
295        fn on_query_start(&self, _ctx: &SpanContext, _query: &QueryCacheKey) {
296            self.start_count.fetch_add(1, Ordering::Relaxed);
297        }
298
299        fn on_query_end(
300            &self,
301            _ctx: &SpanContext,
302            _query: &QueryCacheKey,
303            _result: ExecutionResult,
304        ) {
305            self.end_count.fetch_add(1, Ordering::Relaxed);
306        }
307    }
308
309    #[test]
310    fn test_noop_tracer_returns_zero() {
311        let tracer = NoopTracer;
312        assert_eq!(tracer.new_span_id(), SpanId::ZERO);
313        assert_eq!(tracer.new_trace_id(), TraceId::ZERO);
314    }
315
316    #[test]
317    fn test_counting_tracer() {
318        let tracer = CountingTracer::new();
319        let key = QueryCacheKey::new(("TestQuery",));
320
321        let ctx1 = SpanContext {
322            span_id: SpanId(1),
323            trace_id: TraceId(1),
324            parent_span_id: None,
325        };
326        let ctx2 = SpanContext {
327            span_id: SpanId(2),
328            trace_id: TraceId(1),
329            parent_span_id: Some(SpanId(1)),
330        };
331
332        tracer.on_query_start(&ctx1, &key);
333        tracer.on_query_start(&ctx2, &key);
334        tracer.on_query_end(&ctx1, &key, ExecutionResult::Changed);
335
336        assert_eq!(tracer.start_count.load(Ordering::Relaxed), 2);
337        assert_eq!(tracer.end_count.load(Ordering::Relaxed), 1);
338    }
339
340    #[test]
341    fn test_tracer_is_send_sync() {
342        fn assert_send_sync<T: Send + Sync>() {}
343        assert_send_sync::<NoopTracer>();
344        assert_send_sync::<Arc<CountingTracer>>();
345    }
346}