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}