Skip to main content

cordis/
module_graph.rs

1//! Explicit module dependency graph beside the file-watch / HMR machinery.
2//!
3//! [`crate::watcher`] already fans changes out to *service-level* dependents:
4//! the debounced batch notifies [`crate::ReflectService`], which BFS-walks the
5//! `TypeId` dependents and refreshes fibers. That layer answers "which fibers
6//! consume this service type?" — it cannot answer "which *plugin* must reload
7//! because this file changed?", because file/plugin edges carry no TypeId.
8//!
9//! [`ModuleGraph`] is that missing edge layer: callers register every dynamic
10//! module under a key (typically the watched file stem or module URL) together
11//! with the keys it depends on and the plugin that implements it. When the
12//! watcher settles a batch, it maps each changed path to its file stem and
13//! hands the keys to [`ModuleGraph::change_many`], which
14//!
15//! 1. computes the **transitive** affected plugin set BEFORE mutating anything
16//!    ([`ModuleGraph::depends_on`] DFS with a visited set, so dependency
17//!    cycles terminate instead of spinning),
18//! 2. dedupes repeated input keys so every affected plugin reloads EXACTLY
19//!    ONCE per transaction,
20//! 3. applies sequentially through the [`ModuleReload`] seam, rolling the
21//!    failing plugin back to its previous state on the first error while the
22//!    successful siblings stay Active,
23//! 4. classifies the transaction: an input batch where NO key matches a
24//!    registered module is [`ChangeOutcome::Ignored`] (external/unknown
25//!    noise), otherwise [`ChangeOutcome::Reloaded`] names the plugins or
26//!    [`ChangeOutcome::RolledBack`] names the failure.
27//!
28//! Without a registered [`ModuleGraph`] on the context the watcher skips the
29//! layer entirely — zero cost for deployments that only need the TypeId path.
30//! The HMR dlopen fingerprint gate ([`crate::hmr`]) is untouched by this
31//! layer; dylib applies continue to happen before graph fan-out ordering
32//! concerns, and neither consults the other.
33
34use std::collections::{BTreeMap, HashSet, VecDeque};
35use std::sync::Arc;
36
37use crate::service::CordisError;
38
39/// One registered module: where it came from, what it consumes, which plugin
40/// implements it.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ModuleEntry {
43    /// Keys this module depends on. When any of them changes (directly or
44    /// transitively), this module's plugin is part of the affected set.
45    pub dependencies: Vec<String>,
46    /// Plugin that implements the module; the name handed to [`ModuleReload`].
47    pub plugin_name: String,
48}
49
50/// Apply seam between the graph and whatever actually re-instantiates a
51/// plugin (loader re-apply, HMR swap, test double).
52///
53/// `reload` brings `plugin` to its new state; `rollback` re-registers the
54/// PREVIOUS state after a failed `reload`, restoring the last-known-good
55/// configuration. Implementations must be idempotent per call site — the
56/// graph invokes each exactly once per affected plugin per transaction.
57pub trait ModuleReload: Send + Sync + 'static {
58    /// Reload `plugin` to reflect the settled change batch.
59    fn reload(&self, ctx: &Arc<crate::Context>, plugin: &str) -> Result<(), CordisError>;
60
61    /// Re-register the plugin's previous state after a failed reload.
62    fn rollback(&self, ctx: &Arc<crate::Context>, plugin: &str) -> Result<(), CordisError>;
63}
64
65/// Default seam: log-only, never fails. Deployments that only want the
66/// classification/propagation logic wire their own [`ModuleReload`].
67pub struct NoopReload;
68
69impl ModuleReload for NoopReload {
70    fn reload(&self, _ctx: &Arc<crate::Context>, plugin: &str) -> Result<(), CordisError> {
71        tracing::debug!(plugin = %plugin, "module-graph noop reload");
72        Ok(())
73    }
74
75    fn rollback(&self, _ctx: &Arc<crate::Context>, plugin: &str) -> Result<(), CordisError> {
76        tracing::debug!(plugin = %plugin, "module-graph noop rollback");
77        Ok(())
78    }
79}
80
81/// Classified result of one [`ModuleGraph::change_many`] transaction.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum ChangeOutcome {
84    /// No input key matched a registered module — external/unknown noise.
85    /// Nothing was computed against the applier and nothing changed.
86    Ignored,
87    /// Every affected plugin reloaded; list is deduped and follows
88    /// breadth-first propagation order from the input keys.
89    Reloaded(Vec<String>),
90    /// Sequential apply stopped at the first failure. `reloaded` siblings
91    /// stay Active; the failing plugin was rolled back to its previous state.
92    /// `error` carries the reload failure text (plus rollback status when the
93    /// rollback itself also failed).
94    RolledBack {
95        reloaded: Vec<String>,
96        failed_plugin: String,
97        error: String,
98    },
99}
100
101impl ChangeOutcome {
102    /// One-line rendering for logs and settle-barrier style reporting.
103    pub fn summary(&self) -> String {
104        match self {
105            ChangeOutcome::Ignored => "ignored (no registered module matched)".to_string(),
106            ChangeOutcome::Reloaded(plugins) => format!("reloaded [{}]", plugins.join(", ")),
107            ChangeOutcome::RolledBack {
108                reloaded,
109                failed_plugin,
110                error,
111            } => format!(
112                "rolled back {} after [{}] applied: {error}",
113                failed_plugin,
114                reloaded.join(", ")
115            ),
116        }
117    }
118}
119
120impl std::fmt::Display for ChangeOutcome {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        f.write_str(&self.summary())
123    }
124}
125
126/// Explicit file/plugin dependency graph over registered modules.
127///
128/// Keys are typically the watched file stem (`agents/foo.toon` → `"foo"`) or
129/// the module URL; edges point from a module to the keys it declares in
130/// `dependencies`. Storage is a `BTreeMap`, so propagation order is
131/// deterministic across runs regardless of registration order.
132pub struct ModuleGraph {
133    modules: parking_lot::RwLock<BTreeMap<String, ModuleEntry>>,
134    reloader: parking_lot::RwLock<Arc<dyn ModuleReload>>,
135}
136
137impl Default for ModuleGraph {
138    fn default() -> Self {
139        Self::new()
140    }
141}
142
143impl crate::Service for ModuleGraph {}
144
145impl ModuleGraph {
146    pub fn new() -> Self {
147        Self::with_reloader(Arc::new(NoopReload))
148    }
149
150    /// Graph wired to a concrete apply seam.
151    pub fn with_reloader(reloader: Arc<dyn ModuleReload>) -> Self {
152        Self {
153            modules: parking_lot::RwLock::new(BTreeMap::new()),
154            reloader: parking_lot::RwLock::new(reloader),
155        }
156    }
157
158    /// Swap the apply seam (e.g. install the production reloader after boot).
159    pub fn set_reloader(&self, reloader: Arc<dyn ModuleReload>) {
160        *self.reloader.write() = reloader;
161    }
162
163    /// Register (or replace) module `key` — the URL/file-stem identifier —
164    /// with its declared `dependencies` and implementing `plugin_name`.
165    pub fn register_module(
166        &self,
167        key: impl Into<String>,
168        dependencies: Vec<String>,
169        plugin_name: impl Into<String>,
170    ) {
171        self.modules.write().insert(
172            key.into(),
173            ModuleEntry {
174                dependencies,
175                plugin_name: plugin_name.into(),
176            },
177        );
178    }
179
180    /// Snapshot of one module entry, if registered.
181    pub fn get(&self, key: &str) -> Option<ModuleEntry> {
182        self.modules.read().get(key).cloned()
183    }
184
185    /// All registered module keys, ascending.
186    pub fn module_keys(&self) -> Vec<String> {
187        self.modules.read().keys().cloned().collect()
188    }
189
190    pub fn len(&self) -> usize {
191        self.modules.read().len()
192    }
193
194    pub fn is_empty(&self) -> bool {
195        self.modules.read().is_empty()
196    }
197
198    /// Transitive dependents of `key`, INCLUDING `key` itself, in
199    /// breadth-first propagation order.
200    ///
201    /// The DFS carries a visited set, so cyclic declarations (`A` depends on
202    /// `B`, `B` depends on `A`) terminate after visiting each module once
203    /// while still propagating to everything reachable around the cycle.
204    pub fn depends_on(&self, key: &str) -> Vec<String> {
205        let modules = self.modules.read();
206        let mut visited = HashSet::new();
207        let mut order = Vec::new();
208        let mut queue = VecDeque::new();
209        if modules.contains_key(key) {
210            visited.insert(key.to_string());
211            queue.push_back(key.to_string());
212        }
213        while let Some(current) = queue.pop_front() {
214            order.push(current.clone());
215            for dependent in reverse_edges(&modules, &current) {
216                if visited.insert(dependent.clone()) {
217                    queue.push_back(dependent);
218                }
219            }
220        }
221        order
222    }
223
224    /// Transaction over one settled batch of changed keys.
225    ///
226    /// Phase 1 (read-only) computes the transitive affected plugin set across
227    /// ALL input keys — shared visited set, so a plugin reachable from several
228    /// inputs appears exactly once. If no input key matches a registered
229    /// module the transaction ends [`ChangeOutcome::Ignored`] before any
230    /// mutation. Phase 2 applies the affected plugins sequentially; the first
231    /// failure rolls that plugin back to its previous state and stops the
232    /// batch, leaving earlier successes Active and reporting
233    /// [`ChangeOutcome::RolledBack`].
234    pub fn change_many(&self, ctx: &Arc<crate::Context>, keys: &[String]) -> ChangeOutcome {
235        // Phase 1: compute the full affected set BEFORE mutating anything.
236        let affected = {
237            let modules = self.modules.read();
238            if !keys.iter().any(|k| modules.contains_key(k)) {
239                return ChangeOutcome::Ignored;
240            }
241            let mut visited: HashSet<String> = HashSet::new();
242            let mut queue: VecDeque<String> = VecDeque::new();
243            for key in keys {
244                if modules.contains_key(key) && visited.insert(key.clone()) {
245                    queue.push_back(key.clone());
246                }
247            }
248            let mut plugins: Vec<String> = Vec::new();
249            let mut seen_plugins: HashSet<String> = HashSet::new();
250            while let Some(current) = queue.pop_front() {
251                if let Some(entry) = modules.get(&current) {
252                    if seen_plugins.insert(entry.plugin_name.clone()) {
253                        plugins.push(entry.plugin_name.clone());
254                    }
255                }
256                for dependent in reverse_edges(&modules, &current) {
257                    if visited.insert(dependent.clone()) {
258                        queue.push_back(dependent);
259                    }
260                }
261            }
262            plugins
263        };
264
265        // Phase 2: sequential apply with rollback-on-first-failure.
266        let reloader = self.reloader.read().clone();
267        let mut reloaded: Vec<String> = Vec::with_capacity(affected.len());
268        for plugin in affected {
269            match reloader.reload(ctx, &plugin) {
270                Ok(()) => reloaded.push(plugin),
271                Err(err) => {
272                    let rollback_err = reloader.rollback(ctx, &plugin).err();
273                    let error = match rollback_err {
274                        Some(rb) => format!(
275                            "{err}; ROLLBACK ALSO FAILED for {plugin}: {rb}"
276                        ),
277                        None => format!("{err}; rolled back {plugin} to its previous state"),
278                    };
279                    tracing::error!(
280                        plugin = %plugin,
281                        applied = ?reloaded,
282                        %error,
283                        "module-graph change_many aborted"
284                    );
285                    return ChangeOutcome::RolledBack {
286                        reloaded,
287                        failed_plugin: plugin,
288                        error,
289                    };
290                }
291            }
292        }
293        ChangeOutcome::Reloaded(reloaded)
294    }
295}
296
297/// Modules whose `dependencies` contain `target`, in ascending key order
298/// (deterministic because storage is a `BTreeMap`).
299fn reverse_edges(
300    modules: &BTreeMap<String, ModuleEntry>,
301    target: &str,
302) -> Vec<String> {
303    modules
304        .iter()
305        .filter(|(_, entry)| entry.dependencies.iter().any(|d| d == target))
306        .map(|(key, _)| key.clone())
307        .collect()
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313    use crate::Context;
314
315    /// Counting fake: records every op, optionally fails named plugins.
316    struct FakeReload {
317        fail_on: Vec<String>,
318        ops: parking_lot::Mutex<Vec<String>>,
319    }
320
321    impl FakeReload {
322        fn new(fail_on: &[&str]) -> Self {
323            Self {
324                fail_on: fail_on.iter().map(|s| s.to_string()).collect(),
325                ops: parking_lot::Mutex::new(Vec::new()),
326            }
327        }
328
329        fn ops(&self) -> Vec<String> {
330            self.ops.lock().clone()
331        }
332    }
333
334    impl ModuleReload for FakeReload {
335        fn reload(&self, _ctx: &Arc<Context>, plugin: &str) -> Result<(), CordisError> {
336            self.ops.lock().push(format!("reload:{plugin}"));
337            if self.fail_on.iter().any(|f| f == plugin) {
338                Err(CordisError::Fiber(format!("{plugin} failed to rebuild")))
339            } else {
340                Ok(())
341            }
342        }
343
344        fn rollback(&self, _ctx: &Arc<Context>, plugin: &str) -> Result<(), CordisError> {
345            self.ops.lock().push(format!("rollback:{plugin}"));
346            Ok(())
347        }
348    }
349
350    fn ctx() -> Arc<Context> {
351        Context::new_root()
352    }
353
354    #[tokio::test]
355    async fn dependency_change_reloads_dependents_transitively() {
356        // chain: a <- b <- c (c depends on b, b depends on a)
357        let fake = Arc::new(FakeReload::new(&[]));
358        let graph = ModuleGraph::with_reloader(fake.clone());
359        graph.register_module("a", vec![], "P.a");
360        graph.register_module("b", vec!["a".into()], "P.b");
361        graph.register_module("c", vec!["b".into()], "P.c");
362
363        assert_eq!(graph.depends_on("a"), vec!["a", "b", "c"]);
364
365        let outcome = graph.change_many(&ctx(), &["a".to_string()]);
366        assert_eq!(
367            outcome,
368            ChangeOutcome::Reloaded(s(&["P.a", "P.b", "P.c"]))
369        );
370        // Exactly one reload per plugin, propagation order.
371        assert_eq!(
372            fake.ops(),
373            vec!["reload:P.a", "reload:P.b", "reload:P.c"]
374        );
375    }
376
377    #[tokio::test]
378    async fn cycles_terminate_and_still_propagate() {
379        // m1 <-> m2 cycle, with m3 hanging off m1 outside the cycle.
380        let fake = Arc::new(FakeReload::new(&[]));
381        let graph = ModuleGraph::with_reloader(fake.clone());
382        graph.register_module("m1", vec!["m2".into()], "P.1");
383        graph.register_module("m2", vec!["m1".into()], "P.2");
384        graph.register_module("m3", vec!["m1".into()], "P.3");
385
386        // DFS terminates despite the cycle and still reaches m3.
387        assert_eq!(graph.depends_on("m2"), vec!["m2", "m1", "m3"]);
388
389        let outcome = graph.change_many(&ctx(), &["m2".to_string()]);
390        assert_eq!(
391            outcome,
392            ChangeOutcome::Reloaded(s(&["P.2", "P.1", "P.3"]))
393        );
394        // Each cycle member reloaded exactly once — no infinite loop, no dupes.
395        assert_eq!(
396            fake.ops(),
397            vec!["reload:P.2", "reload:P.1", "reload:P.3"]
398        );
399    }
400
401    #[tokio::test]
402    async fn batched_changes_reload_each_plugin_once() {
403        // x feeds y and z; y also feeds z; w unrelated. Batch repeats inputs.
404        let fake = Arc::new(FakeReload::new(&[]));
405        let graph = ModuleGraph::with_reloader(fake.clone());
406        graph.register_module("x", vec![], "P.x");
407        graph.register_module("y", vec!["x".into()], "P.y");
408        graph.register_module("z", vec!["x".into(), "y".into()], "P.z");
409        graph.register_module("w", vec![], "P.w");
410
411        let keys = vec![
412            "x".to_string(),
413            "x".to_string(),
414            "y".to_string(),
415        ];
416        let outcome = graph.change_many(&ctx(), &keys);
417        assert_eq!(outcome, ChangeOutcome::Reloaded(s(&["P.x", "P.y", "P.z"])));
418        // z reachable from BOTH x and y reloads EXACTLY ONCE; w untouched.
419        assert_eq!(
420            fake.ops(),
421            vec!["reload:P.x", "reload:P.y", "reload:P.z"]
422        );
423    }
424
425    #[tokio::test]
426    async fn rollback_keeps_successful_siblings_active() {
427        let fake = Arc::new(FakeReload::new(&["P.b"]));
428        let graph = ModuleGraph::with_reloader(fake.clone());
429        graph.register_module("a", vec![], "P.a");
430        graph.register_module("b", vec!["a".into()], "P.b");
431        graph.register_module("c", vec!["b".into()], "P.c");
432
433        let outcome = graph.change_many(&ctx(), &["a".to_string()]);
434        match outcome {
435            ChangeOutcome::RolledBack {
436                reloaded,
437                failed_plugin,
438                error,
439            } => {
440                // Successful sibling stayed applied (never rolled back).
441                assert_eq!(reloaded, vec!["P.a"]);
442                assert_eq!(failed_plugin, "P.b");
443                assert!(error.contains("P.b failed to rebuild"));
444                assert!(error.contains("rolled back"));
445            }
446            other => panic!("expected RolledBack, got {other:?}"),
447        }
448        // Sequence proves: A applied and left alone, B attempted then restored,
449        // C never attempted (first-failure stop).
450        assert_eq!(
451            fake.ops(),
452            vec![
453                "reload:P.a",
454                "reload:P.b",
455                "rollback:P.b",
456            ]
457        );
458    }
459
460    #[tokio::test]
461    async fn external_key_classified_ignored() {
462        let fake = Arc::new(FakeReload::new(&[]));
463        let graph = ModuleGraph::with_reloader(fake.clone());
464        graph.register_module("known", vec![], "P.known");
465
466        // Pure-external batch: nothing matches a registered module.
467        let keys = vec!["external-thing".to_string(), "also-unknown".to_string()];
468        assert_eq!(graph.change_many(&ctx(), &keys), ChangeOutcome::Ignored);
469        assert!(fake.ops().is_empty());
470
471        // Empty batch likewise touches nothing.
472        assert_eq!(graph.change_many(&ctx(), &[]), ChangeOutcome::Ignored);
473
474        // Mixed batch: the known key still drives its transaction…
475        let mixed = vec!["external-thing".to_string(), "known".to_string()];
476        assert_eq!(
477            graph.change_many(&ctx(), &mixed),
478            ChangeOutcome::Reloaded(s(&["P.known"]))
479        );
480    }
481
482    fn s(items: &[&str]) -> Vec<String> {
483        items.iter().map(|i| i.to_string()).collect()
484    }
485}