Skip to main content

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}