1use std::collections::{BTreeMap, HashSet, VecDeque};
35use std::sync::Arc;
36
37use crate::service::CordisError;
38
39#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct ModuleEntry {
43 pub dependencies: Vec<String>,
46 pub plugin_name: String,
48}
49
50pub trait ModuleReload: Send + Sync + 'static {
58 fn reload(&self, ctx: &Arc<crate::Context>, plugin: &str) -> Result<(), CordisError>;
60
61 fn rollback(&self, ctx: &Arc<crate::Context>, plugin: &str) -> Result<(), CordisError>;
63}
64
65pub 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#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum ChangeOutcome {
84 Ignored,
87 Reloaded(Vec<String>),
90 RolledBack {
95 reloaded: Vec<String>,
96 failed_plugin: String,
97 error: String,
98 },
99}
100
101impl ChangeOutcome {
102 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
126pub 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 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 pub fn set_reloader(&self, reloader: Arc<dyn ModuleReload>) {
160 *self.reloader.write() = reloader;
161 }
162
163 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 pub fn get(&self, key: &str) -> Option<ModuleEntry> {
182 self.modules.read().get(key).cloned()
183 }
184
185 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 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, ¤t) {
216 if visited.insert(dependent.clone()) {
217 queue.push_back(dependent);
218 }
219 }
220 }
221 order
222 }
223
224 pub fn change_many(&self, ctx: &Arc<crate::Context>, keys: &[String]) -> ChangeOutcome {
235 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(¤t) {
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, ¤t) {
257 if visited.insert(dependent.clone()) {
258 queue.push_back(dependent);
259 }
260 }
261 }
262 plugins
263 };
264
265 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
297fn 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(graph.change_many(&ctx(), &[]), ChangeOutcome::Ignored);
473
474 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}