Skip to main content

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}