majordomus_cli/perf.rs
1//! Performance truth: process-wide counters of the work that must happen once (a
2//! repository scan, an index build, a registry build, a schema generation, a projection
3//! build) and of the work that happens per call (executions, handler invocations, cache
4//! hits and misses), plus phase timings on a monotonic clock. They are what the
5//! structural tests read after hundreds of requests to prove that no request rebuilt
6//! canonical state, what `perf.counters` answers over every transport, and what the
7//! benchmark evidence carries beside its latencies. Atomics only: reading them costs
8//! nothing worth measuring, and nothing here allocates on a hot path.
9//!
10//! # What the counters are for
11//!
12//! They exist to make one class of regression visible that no functional test can see: a
13//! change that makes every request correct and rebuilds the index to get there. The
14//! startup counters must stay at their post-startup values however many requests arrive,
15//! and `tests/hot_path.rs` asserts exactly that. `project.hot-path-reads-once` is the rule.
16//!
17//! ```
18//! use majordomus_cli::perf::{Counters, Phase, COUNTERS};
19//!
20//! // a phase is timed by a guard, so the timing ends when the work does and not later
21//! let before = COUNTERS.snapshot();
22//! {
23//! let _timing = majordomus_cli::perf::phase(Phase::IndexBuild);
24//! Counters::bump(&COUNTERS.index_builds);
25//! }
26//! let after = COUNTERS.snapshot();
27//! assert_eq!(after.index_builds, before.index_builds + 1);
28//!
29//! // and what happened once at startup is reported apart from what happens per call
30//! assert!(after.startup_work().iter().any(|(name, _)| *name == "index_builds"));
31//! ```
32
33use std::sync::atomic::{AtomicU64, Ordering};
34use std::time::Instant;
35
36use schemars::JsonSchema;
37use serde::{Deserialize, Serialize};
38
39/// The counters of this process.
40pub static COUNTERS: Counters = Counters::new();
41
42/// One counter per unit of work. Each is incremented at exactly one place in the code,
43/// named in its doc comment.
44#[derive(Debug)]
45pub struct Counters {
46 /// `discovery::discover`: one enumeration of the repository's declared sources.
47 pub repository_scans: AtomicU64,
48 /// `Index::build`.
49 pub index_builds: AtomicU64,
50 /// `CapabilityRegistry::builder().build()`.
51 pub registry_builds: AtomicU64,
52 /// `CanonicalSchema::of`: one JSON Schema derived from a Rust type.
53 pub schema_generations: AtomicU64,
54 /// `Surface`'s tool and resource listings computed (once per shared listing).
55 pub mcp_projection_builds: AtomicU64,
56 /// `openapi::document`.
57 pub openapi_builds: AtomicU64,
58 /// `Router::new`.
59 pub http_projection_builds: AtomicU64,
60 /// `graph::derive`: one graph derived from the registry and the index.
61 pub graph_builds: AtomicU64,
62 /// `CapabilityExecutor::execute`: every call through every transport.
63 pub executions: AtomicU64,
64 /// Handlers actually run (an execution the cache did not answer), at every depth: a
65 /// handler that composes another capability through the executor counts both, which
66 /// is how composition is seen to use the one execution path rather than reach into
67 /// another module behind it. So `handler_invocations` equals `executions` minus
68 /// `cache_hits`, and an execution that ran a handler twice is exactly what breaks it.
69 pub handler_invocations: AtomicU64,
70 /// Executions answered from the cache.
71 pub cache_hits: AtomicU64,
72 /// Executions of a cached capability that ran the handler.
73 pub cache_misses: AtomicU64,
74 /// Entries dropped to keep a capability under its `max_entries`, or past their TTL.
75 pub cache_evictions: AtomicU64,
76 phases: [PhaseCell; Phase::ALL.len()],
77}
78
79#[derive(Debug)]
80struct PhaseCell {
81 count: AtomicU64,
82 nanos: AtomicU64,
83}
84
85impl PhaseCell {
86 const fn new() -> Self {
87 PhaseCell {
88 count: AtomicU64::new(0),
89 nanos: AtomicU64::new(0),
90 }
91 }
92}
93
94/// The phases timed. Each has one owner in the code, named in its doc comment.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
96#[serde(rename_all = "snake_case")]
97pub enum Phase {
98 /// `Repository::discover`: finding the root and reading the manifest.
99 RepositoryDiscovery,
100 /// `Index::build`: enumeration, reading, validation of every declared file.
101 IndexBuild,
102 /// `CapabilityRegistry` build and validation.
103 RegistryBuild,
104 /// `openapi::document`.
105 OpenApiBuild,
106 /// The MCP tool and resource listings.
107 McpProjectionBuild,
108 /// `graph::derive`: deriving one graph.
109 GraphBuild,
110 /// A handler running inside the executor.
111 HandlerExecution,
112 /// A cache lookup inside the executor, hit or miss.
113 CacheLookup,
114}
115
116impl Phase {
117 /// Every phase, in declaration order.
118 pub const ALL: [Phase; 8] = [
119 Phase::RepositoryDiscovery,
120 Phase::IndexBuild,
121 Phase::RegistryBuild,
122 Phase::OpenApiBuild,
123 Phase::McpProjectionBuild,
124 Phase::GraphBuild,
125 Phase::HandlerExecution,
126 Phase::CacheLookup,
127 ];
128
129 /// The name as serialised.
130 pub fn name(self) -> &'static str {
131 match self {
132 Phase::RepositoryDiscovery => "repository_discovery",
133 Phase::IndexBuild => "index_build",
134 Phase::RegistryBuild => "registry_build",
135 Phase::OpenApiBuild => "open_api_build",
136 Phase::McpProjectionBuild => "mcp_projection_build",
137 Phase::GraphBuild => "graph_build",
138 Phase::HandlerExecution => "handler_execution",
139 Phase::CacheLookup => "cache_lookup",
140 }
141 }
142}
143
144/// Times one phase from creation to drop.
145pub struct PhaseGuard {
146 phase: Phase,
147 start: Instant,
148}
149
150impl Drop for PhaseGuard {
151 fn drop(&mut self) {
152 let cell = &COUNTERS.phases[self.phase as usize];
153 cell.count.fetch_add(1, Ordering::Relaxed);
154 cell.nanos
155 .fetch_add(self.start.elapsed().as_nanos() as u64, Ordering::Relaxed);
156 }
157}
158
159/// Time a phase until the guard drops.
160///
161/// ```
162/// use majordomus_cli::perf::{self, Phase};
163/// let before = perf::COUNTERS.snapshot().phases["cache_lookup"].count;
164/// {
165/// let _guard = perf::phase(Phase::CacheLookup);
166/// }
167/// assert_eq!(perf::COUNTERS.snapshot().phases["cache_lookup"].count, before + 1);
168/// ```
169pub fn phase(phase: Phase) -> PhaseGuard {
170 PhaseGuard {
171 phase,
172 start: Instant::now(),
173 }
174}
175
176impl Counters {
177 const fn new() -> Self {
178 Counters {
179 repository_scans: AtomicU64::new(0),
180 index_builds: AtomicU64::new(0),
181 registry_builds: AtomicU64::new(0),
182 schema_generations: AtomicU64::new(0),
183 mcp_projection_builds: AtomicU64::new(0),
184 openapi_builds: AtomicU64::new(0),
185 http_projection_builds: AtomicU64::new(0),
186 graph_builds: AtomicU64::new(0),
187 executions: AtomicU64::new(0),
188 handler_invocations: AtomicU64::new(0),
189 cache_hits: AtomicU64::new(0),
190 cache_misses: AtomicU64::new(0),
191 cache_evictions: AtomicU64::new(0),
192 phases: [
193 PhaseCell::new(),
194 PhaseCell::new(),
195 PhaseCell::new(),
196 PhaseCell::new(),
197 PhaseCell::new(),
198 PhaseCell::new(),
199 PhaseCell::new(),
200 PhaseCell::new(),
201 ],
202 }
203 }
204
205 /// Add one to a counter.
206 #[inline]
207 pub fn bump(counter: &AtomicU64) {
208 counter.fetch_add(1, Ordering::Relaxed);
209 }
210
211 /// The counters and phases as they stand.
212 pub fn snapshot(&self) -> CounterSnapshot {
213 let read = |c: &AtomicU64| c.load(Ordering::Relaxed);
214 CounterSnapshot {
215 repository_scans: read(&self.repository_scans),
216 index_builds: read(&self.index_builds),
217 registry_builds: read(&self.registry_builds),
218 schema_generations: read(&self.schema_generations),
219 mcp_projection_builds: read(&self.mcp_projection_builds),
220 openapi_builds: read(&self.openapi_builds),
221 http_projection_builds: read(&self.http_projection_builds),
222 graph_builds: read(&self.graph_builds),
223 executions: read(&self.executions),
224 handler_invocations: read(&self.handler_invocations),
225 cache_hits: read(&self.cache_hits),
226 cache_misses: read(&self.cache_misses),
227 cache_evictions: read(&self.cache_evictions),
228 phases: Phase::ALL
229 .iter()
230 .map(|p| {
231 let cell = &self.phases[*p as usize];
232 (
233 p.name().to_string(),
234 PhaseTotals {
235 count: read(&cell.count),
236 total_nanos: read(&cell.nanos),
237 },
238 )
239 })
240 .collect(),
241 }
242 }
243}
244
245/// A phase's totals.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
247pub struct PhaseTotals {
248 /// How many times the phase ran.
249 pub count: u64,
250 /// Nanoseconds spent in it, summed.
251 pub total_nanos: u64,
252}
253
254/// The counters and phase totals of this process at one moment. Every value is a count
255/// or a duration this process measured; none is written anywhere by hand.
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
257pub struct CounterSnapshot {
258 /// Enumerations of the repository's declared sources.
259 pub repository_scans: u64,
260 /// Index builds.
261 pub index_builds: u64,
262 /// Registry builds.
263 pub registry_builds: u64,
264 /// JSON Schemas derived from Rust types.
265 pub schema_generations: u64,
266 /// MCP tool and resource listings computed.
267 pub mcp_projection_builds: u64,
268 /// OpenAPI documents built.
269 pub openapi_builds: u64,
270 /// HTTP routers built.
271 pub http_projection_builds: u64,
272 /// Graphs derived from the registry and the index.
273 pub graph_builds: u64,
274 /// Calls through the executor, every transport.
275 pub executions: u64,
276 /// Handlers actually run.
277 pub handler_invocations: u64,
278 /// Executions answered from the cache.
279 pub cache_hits: u64,
280 /// Executions of a cached capability that ran the handler.
281 pub cache_misses: u64,
282 /// Cache entries dropped.
283 pub cache_evictions: u64,
284 /// Phase totals by phase name.
285 pub phases: std::collections::BTreeMap<String, PhaseTotals>,
286}
287
288impl CounterSnapshot {
289 /// The counters that must not move once a process serves: a request that moves one of
290 /// them rebuilt canonical state.
291 pub fn startup_work(&self) -> [(&'static str, u64); 7] {
292 [
293 ("repository_scans", self.repository_scans),
294 ("index_builds", self.index_builds),
295 ("registry_builds", self.registry_builds),
296 ("schema_generations", self.schema_generations),
297 ("mcp_projection_builds", self.mcp_projection_builds),
298 ("openapi_builds", self.openapi_builds),
299 ("http_projection_builds", self.http_projection_builds),
300 ]
301 }
302}