majordomus_cli/capability/executor.rs
1//! The one execution path.
2//!
3//! Every call of an executable capability — from the stdio MCP session, from `/mcp`, from
4//! an HTTP route, from the command line, from the Cockpit or from a benchmark runner —
5//! goes through [`CapabilityExecutor::execute`]: registry lookup, counters, the cache the
6//! capability's own policy asks for, then the handler. Transport adapters convert protocol
7//! to JSON and back and own nothing else, so instrumentation and caching apply to every
8//! transport at once and cannot drift apart. A second execution path is the defect this
9//! module exists to make impossible.
10//!
11//! # The cache key
12//!
13//! Process memory, bounded per capability by its [`CachePolicy`], keyed
14//! by three things: the canonical id, the input **normalised** (object keys sorted at every
15//! level, so `{"a":1,"b":2}` and `{"b":2,"a":1}` are one entry), and the registry
16//! fingerprint — which hashes every descriptor and every declarative object's content. Two
17//! processes over different repository states therefore never share an entry, and a changed
18//! layer never answers from an old one.
19//!
20//! Errors are never cached. Commands are never cached, and the registry refuses a
21//! descriptor that asks for it, so that is a build-time refusal rather than a runtime one.
22//!
23//! Two policies narrow the key further, for answers the registry fingerprint cannot see
24//! move. [`CachePolicy::Generation`] adds the context's [`Context::reading`]: an answer
25//! read off the index, including the git state it was read at, is kept for as long as that
26//! reading is the one asked. [`CachePolicy::Checkout`] adds that and the fingerprint of the
27//! checkout, taken again on every call (`capability::checkout`): HEAD, the staging index,
28//! the refs, and every changed or untracked path of the work tree with its size and
29//! modification time. A checkout git cannot fingerprint is not cached.
30//!
31//! # One handler per key at a time
32//!
33//! Concurrent calls that miss on the same key do not each run the handler: the first runs
34//! it, the others wait for it and are answered from what it stored. A screen that asks the
35//! same source from two panels at once, or two browsers that open one page together, pay
36//! for one computation. A handler that fails stores nothing, so a caller that waited on it
37//! runs the handler itself.
38//!
39//! # Switching it off
40//!
41//! `MAJORDOMUS_CAPABILITY_CACHE=off` in a process's environment makes every call run its
42//! handler and store nothing, whatever the capability's policy: the way to settle a doubt
43//! about a cached answer by asking without the cache (`project.cache-is-invisible`).
44//!
45//! # Invisibility
46//!
47//! A cached answer is byte-identical to a computed one; nothing in a response says which it
48//! was. `project.cache-is-invisible` requires it, and [`canonical_json`] is what makes the
49//! key well-defined enough for it to be true.
50//!
51//! ```
52//! use majordomus_cli::capability::executor::canonical_json;
53//!
54//! // the same input written two ways is one cache entry, at every level of nesting
55//! let one = serde_json::json!({ "b": 2, "a": { "y": 1, "x": 0 } });
56//! let other = serde_json::json!({ "a": { "x": 0, "y": 1 }, "b": 2 });
57//! assert_eq!(canonical_json(&one), canonical_json(&other));
58//!
59//! // and a different input is a different entry
60//! assert_ne!(canonical_json(&one), canonical_json(&serde_json::json!({ "b": 3 })));
61//! ```
62
63use std::collections::{HashMap, VecDeque};
64use std::sync::atomic::{AtomicU64, Ordering};
65use std::sync::{Arc, Mutex};
66use std::time::{Duration, Instant};
67
68use serde_json::Value;
69
70use crate::perf::{self, Counters, Phase, COUNTERS};
71
72use super::handler::{CapabilityError, Context};
73use super::model::CachePolicy;
74
75/// The executor of one registry.
76#[derive(Debug, Default)]
77pub struct CapabilityExecutor {
78 cache: Mutex<Cache>,
79 /// The handlers this executor ran: cache misses and uncached calls. A measurement of
80 /// this executor reads it; the process-wide counters also count every other executor
81 /// in the process, which a test running beside another reads as its own.
82 invocations: AtomicU64,
83 /// How many calls of a capability that writes the repository have succeeded here.
84 writes: AtomicU64,
85 /// The latest reading of every source a page has asked for, kept current off the path
86 /// of the request (`capability::readings`). Beside the cache and not in it: the cache
87 /// holds answers, and a reading says how old it is.
88 pub(crate) readings: super::readings::Readings,
89}
90
91#[derive(Debug, Default)]
92struct Cache {
93 entries: HashMap<CacheKey, CacheEntry>,
94 /// Insertion order per capability, for eviction.
95 order: HashMap<String, VecDeque<CacheKey>>,
96 /// The keys whose handler is running now, each with the lock its waiters queue on.
97 flights: HashMap<CacheKey, Arc<Mutex<()>>>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Hash)]
101struct CacheKey {
102 id: String,
103 input: String,
104 fingerprint: String,
105 /// What the policy adds to the registry fingerprint: empty for
106 /// [`CachePolicy::Process`], the context's reading for [`CachePolicy::Generation`], and
107 /// the reading with the checkout's fingerprint for [`CachePolicy::Checkout`].
108 state: String,
109}
110
111/// The environment switch that turns the capability cache off for a process.
112pub const SWITCH: &str = "MAJORDOMUS_CAPABILITY_CACHE";
113
114/// Whether this process was told to run every capability without its cache: `SWITCH`
115/// set to `off`.
116///
117/// ```
118/// use majordomus_cli::capability::executor::{bypassed, SWITCH};
119/// assert_eq!(bypassed(), std::env::var_os(SWITCH).is_some_and(|v| v == "off"));
120/// ```
121pub fn bypassed() -> bool {
122 std::env::var_os(SWITCH).is_some_and(|v| v == "off")
123}
124
125#[derive(Debug)]
126struct CacheEntry {
127 /// Shared, so that a hit takes the cache's lock only for as long as it takes to count a
128 /// reference: the copy a caller is handed is made after the lock is released, and a
129 /// large answer copied for one caller never holds up every other capability's lookup.
130 value: Arc<Value>,
131 stored: Instant,
132}
133
134/// The answer a caller owns: the shared one itself when nothing else holds it, a copy of it
135/// otherwise. Never made under the cache's lock.
136fn owned(value: Arc<Value>) -> Value {
137 Arc::unwrap_or_clone(value)
138}
139
140impl CapabilityExecutor {
141 /// An executor with an empty cache.
142 pub fn new() -> Self {
143 Self::default()
144 }
145
146 /// Execute a capability by id with a JSON input. The one place a handler runs.
147 pub fn execute(&self, ctx: &Context, id: &str, input: Value) -> Result<Value, CapabilityError> {
148 self.execute_shared(ctx, id, input).map(owned)
149 }
150
151 /// The same call, answered with the value the cache shares rather than a copy of it:
152 /// what the latest reading of a source keeps (`capability::readings`), so that reading a
153 /// source again whose answer is cached copies nothing.
154 pub(crate) fn execute_shared(
155 &self,
156 ctx: &Context,
157 id: &str,
158 input: Value,
159 ) -> Result<Arc<Value>, CapabilityError> {
160 let policy = ctx.registry.get(id).map(|c| c.cache);
161 self.run(ctx, id, input, policy)
162 }
163
164 /// The same call with the cache stepped over: the handler runs, whatever is stored.
165 ///
166 /// One caller: the execution engine. A cached answer is indistinguishable from a fresh
167 /// one — that is the point of the cache and the rule `project.cache-is-invisible` says
168 /// so — but an *execution* is not an answer. It is a record of work happening, watched
169 /// while it happens, and an execution answered from a cache would report no steps, no
170 /// progress and no output of its own while claiming to have succeeded: a story of work
171 /// that did not occur. So an execution always runs the handler, and nothing it produces
172 /// is put into the cache either, because the cache holds what calls returned and this
173 /// call is a different kind of thing.
174 pub fn execute_uncached(
175 &self,
176 ctx: &Context,
177 id: &str,
178 input: Value,
179 ) -> Result<Value, CapabilityError> {
180 self.run(ctx, id, input, Some(CachePolicy::Disabled))
181 .map(owned)
182 }
183
184 fn run(
185 &self,
186 ctx: &Context,
187 id: &str,
188 input: Value,
189 policy: Option<CachePolicy>,
190 ) -> Result<Arc<Value>, CapabilityError> {
191 Counters::bump(&COUNTERS.executions);
192 let policy = policy.ok_or_else(|| CapabilityError::NotFound(format!("capability {id}")))?;
193 let policy = if bypassed() {
194 CachePolicy::Disabled
195 } else {
196 policy
197 };
198 let Some(key) = key_of(ctx, id, &input, policy) else {
199 return self.invoke(ctx, id, input).map(Arc::new);
200 };
201 let ttl = policy.ttl_seconds();
202 if let Some(value) = self.lookup(&key, ttl) {
203 return Ok(value);
204 }
205 // a miss: take the key's flight, so that one handler runs for it at a time, and look
206 // again once it is ours — a caller that waited is answered by the one it waited for
207 let flight = {
208 let mut cache = lock(&self.cache);
209 Arc::clone(cache.flights.entry(key.clone()).or_default())
210 };
211 let _flight = flight.lock().unwrap_or_else(|e| e.into_inner());
212 if let Some(value) = self.peek(&key, ttl) {
213 return Ok(value);
214 }
215 let value = self.invoke(ctx, id, input).map(Arc::new);
216 let mut cache = lock(&self.cache);
217 if cache
218 .flights
219 .get(&key)
220 .is_some_and(|f| Arc::ptr_eq(f, &flight))
221 {
222 cache.flights.remove(&key);
223 }
224 let value = value?;
225 let max_entries = policy.max_entries().unwrap_or(1);
226 let Cache { entries, order, .. } = &mut *cache;
227 let order = order.entry(key.id.clone()).or_default();
228 if !entries.contains_key(&key) {
229 order.push_back(key.clone());
230 while order.len() > max_entries {
231 if let Some(old) = order.pop_front() {
232 entries.remove(&old);
233 Counters::bump(&COUNTERS.cache_evictions);
234 }
235 }
236 }
237 entries.insert(
238 key,
239 CacheEntry {
240 value: Arc::clone(&value),
241 stored: Instant::now(),
242 },
243 );
244 Ok(value)
245 }
246
247 /// The stored answer for `key` when it is still fresh, counted as a hit or a miss.
248 fn lookup(&self, key: &CacheKey, ttl: Option<u64>) -> Option<Arc<Value>> {
249 let _guard = perf::phase(Phase::CacheLookup);
250 let mut cache = lock(&self.cache);
251 let fresh = cache
252 .entries
253 .get(key)
254 .map(|e| ttl.is_none_or(|ttl| e.stored.elapsed() < Duration::from_secs(ttl)));
255 match fresh {
256 Some(true) => {
257 Counters::bump(&COUNTERS.cache_hits);
258 return cache.entries.get(key).map(|e| Arc::clone(&e.value));
259 }
260 Some(false) => {
261 cache.entries.remove(key);
262 Counters::bump(&COUNTERS.cache_evictions);
263 Counters::bump(&COUNTERS.cache_misses);
264 }
265 None => Counters::bump(&COUNTERS.cache_misses),
266 }
267 None
268 }
269
270 /// The stored answer for `key` once its flight is ours: what the handler a caller waited
271 /// for stored. Counted as a hit, since no handler runs for it.
272 fn peek(&self, key: &CacheKey, ttl: Option<u64>) -> Option<Arc<Value>> {
273 let cache = lock(&self.cache);
274 let value = cache
275 .entries
276 .get(key)
277 .filter(|e| ttl.is_none_or(|ttl| e.stored.elapsed() < Duration::from_secs(ttl)))
278 .map(|e| Arc::clone(&e.value));
279 if value.is_some() {
280 Counters::bump(&COUNTERS.cache_hits);
281 }
282 value
283 }
284
285 /// Run the handler: counted, timed, and its writes noted.
286 fn invoke(&self, ctx: &Context, id: &str, input: Value) -> Result<Value, CapabilityError> {
287 Counters::bump(&COUNTERS.handler_invocations);
288 self.invocations.fetch_add(1, Ordering::Relaxed);
289 let value = {
290 let _guard = perf::phase(Phase::HandlerExecution);
291 ctx.registry.dispatch(ctx, id, input)?
292 };
293 // counted on the one path every transport and every execution shares, so a write
294 // made from a page, a tool, a route or an execution is seen by `crate::live` alike
295 if ctx
296 .registry
297 .get(id)
298 .is_some_and(|c| c.execution.effect == crate::capability::Effect::RepositoryMutation)
299 {
300 self.writes.fetch_add(1, Ordering::SeqCst);
301 }
302 Ok(value)
303 }
304
305 /// Count one repository mutation, as a successful mutating call does: what a test of a
306 /// reader of [`Executor::writes`] needs without a layer that a mutation can succeed on.
307 #[cfg(test)]
308 pub(crate) fn count_write(&self) {
309 self.writes.fetch_add(1, Ordering::SeqCst);
310 }
311
312 /// How many calls of a capability whose effect is a repository mutation have succeeded
313 /// through this executor.
314 ///
315 /// A write to a tracked file moves no git control file, so a long-lived process that
316 /// follows the repository by those files would go on answering from the picture it had
317 /// before its own write. The executor outlives a reload, so this number is what
318 /// `crate::live` compares: when it has moved, the process wrote, and the next reading
319 /// is a new generation.
320 ///
321 /// ```
322 /// use majordomus_cli::capability::executor::CapabilityExecutor;
323 /// let executor = CapabilityExecutor::new();
324 /// assert_eq!(executor.writes(), 0, "an executor that has run nothing has written nothing");
325 /// ```
326 pub fn writes(&self) -> u64 {
327 self.writes.load(Ordering::SeqCst)
328 }
329
330 /// How many entries the cache holds, all capabilities together.
331 pub fn cached_entries(&self) -> usize {
332 lock(&self.cache).entries.len()
333 }
334
335 /// How many handlers this executor has run — its cache misses and uncached calls, never
336 /// another executor's. Dropping the cache does not reset it.
337 ///
338 /// ```
339 /// use majordomus_cli::capability::executor::CapabilityExecutor;
340 /// let e = CapabilityExecutor::default();
341 /// assert_eq!(e.handler_invocations(), 0);
342 /// ```
343 pub fn handler_invocations(&self) -> u64 {
344 self.invocations.load(Ordering::Relaxed)
345 }
346
347 /// The answer this executor holds for `id` asked with `input` in the repository `ctx`
348 /// reads, when its cache has one that is still fresh, and `None` otherwise. No handler
349 /// runs, nothing is stored, and a miss is not counted: it is how the latest reading of a
350 /// source learns that the answer for a moved repository is already at hand
351 /// (`capability::readings`).
352 ///
353 /// ```
354 /// use majordomus_cli::synthetic::SyntheticRepository;
355 /// use serde_json::json;
356 ///
357 /// let repo = SyntheticRepository::small().unwrap();
358 /// let ctx = repo.context().unwrap();
359 /// let describe = json!({ "id": "plan.status" });
360 /// // nothing is held before the capability was asked, and asking this way runs nothing
361 /// assert_eq!(ctx.executor.cached(&ctx, "capabilities.describe", &describe), None);
362 /// assert_eq!(ctx.executor.handler_invocations(), 0);
363 /// // once it was asked, what it answered is at hand
364 /// let answer = ctx.execute("capabilities.describe", describe.clone()).unwrap();
365 /// assert_eq!(ctx.executor.cached(&ctx, "capabilities.describe", &describe), Some(answer));
366 /// // a capability that does not exist holds nothing
367 /// assert_eq!(ctx.executor.cached(&ctx, "no.such.capability", &json!({})), None);
368 /// ```
369 pub fn cached(&self, ctx: &Context, id: &str, input: &Value) -> Option<Value> {
370 self.cached_shared(ctx, id, input).map(owned)
371 }
372
373 /// The same answer, shared rather than copied.
374 pub(crate) fn cached_shared(
375 &self,
376 ctx: &Context,
377 id: &str,
378 input: &Value,
379 ) -> Option<Arc<Value>> {
380 if bypassed() {
381 return None;
382 }
383 let policy = ctx.registry.get(id).map(|c| c.cache)?;
384 let key = key_of(ctx, id, input, policy)?;
385 self.peek(&key, policy.ttl_seconds())
386 }
387
388 /// How many sources this executor remembers the latest reading of
389 /// (`capability::readings`).
390 ///
391 /// ```
392 /// use majordomus_cli::capability::executor::CapabilityExecutor;
393 /// assert_eq!(CapabilityExecutor::new().readings_remembered(), 0);
394 /// ```
395 pub fn readings_remembered(&self) -> usize {
396 self.readings.remembered()
397 }
398
399 /// Drop every cached entry.
400 pub fn clear(&self) {
401 let mut cache = lock(&self.cache);
402 cache.entries.clear();
403 cache.order.clear();
404 }
405}
406
407/// The key a call is kept under, or `None` when its policy keeps nothing — or keeps it per
408/// checkout and the checkout has no fingerprint.
409fn key_of(ctx: &Context, id: &str, input: &Value, policy: CachePolicy) -> Option<CacheKey> {
410 let state = match policy {
411 CachePolicy::Disabled => return None,
412 CachePolicy::Process { .. } => String::new(),
413 CachePolicy::Generation { .. } => format!("reading {}", ctx.reading),
414 CachePolicy::Checkout { .. } => {
415 let root = std::path::Path::new(&ctx.index.repository.root);
416 let checkout = super::checkout::fingerprint(root)?;
417 format!("reading {} checkout {checkout}", ctx.reading)
418 }
419 };
420 Some(CacheKey {
421 id: id.to_string(),
422 input: canonical_json(input),
423 fingerprint: ctx.registry.fingerprint().to_string(),
424 state,
425 })
426}
427
428fn lock(m: &Mutex<Cache>) -> std::sync::MutexGuard<'_, Cache> {
429 m.lock().unwrap_or_else(|e| e.into_inner())
430}
431
432/// The input as one canonical string: object keys sorted at every level, no whitespace,
433/// so that two inputs equal as data are equal as keys whatever order a client wrote them.
434///
435/// ```
436/// use majordomus_cli::capability::executor::canonical_json;
437/// use serde_json::json;
438/// assert_eq!(canonical_json(&json!({"b": 2, "a": {"d": 1, "c": [3, {"z": 0, "y": 1}]}})),
439/// canonical_json(&json!({"a": {"c": [3, {"y": 1, "z": 0}], "d": 1}, "b": 2})));
440/// assert_ne!(canonical_json(&json!([1, 2])), canonical_json(&json!([2, 1])), "arrays are ordered");
441/// ```
442pub fn canonical_json(v: &Value) -> String {
443 fn write(v: &Value, out: &mut String) {
444 match v {
445 Value::Object(m) => {
446 let mut keys: Vec<&String> = m.keys().collect();
447 keys.sort();
448 out.push('{');
449 for (i, k) in keys.iter().enumerate() {
450 if i > 0 {
451 out.push(',');
452 }
453 out.push_str(&serde_json::to_string(k).unwrap_or_default());
454 out.push(':');
455 write(&m[*k], out);
456 }
457 out.push('}');
458 }
459 Value::Array(a) => {
460 out.push('[');
461 for (i, x) in a.iter().enumerate() {
462 if i > 0 {
463 out.push(',');
464 }
465 write(x, out);
466 }
467 out.push(']');
468 }
469 other => out.push_str(&other.to_string()),
470 }
471 }
472 let mut out = String::new();
473 write(v, &mut out);
474 out
475}