use std::collections::{BTreeMap, HashSet, VecDeque};
use std::sync::Arc;
use crate::service::CordisError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModuleEntry {
pub dependencies: Vec<String>,
pub plugin_name: String,
}
pub trait ModuleReload: Send + Sync + 'static {
fn reload(&self, ctx: &Arc<crate::Context>, plugin: &str) -> Result<(), CordisError>;
fn rollback(&self, ctx: &Arc<crate::Context>, plugin: &str) -> Result<(), CordisError>;
}
pub struct NoopReload;
impl ModuleReload for NoopReload {
fn reload(&self, _ctx: &Arc<crate::Context>, plugin: &str) -> Result<(), CordisError> {
tracing::debug!(plugin = %plugin, "module-graph noop reload");
Ok(())
}
fn rollback(&self, _ctx: &Arc<crate::Context>, plugin: &str) -> Result<(), CordisError> {
tracing::debug!(plugin = %plugin, "module-graph noop rollback");
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChangeOutcome {
Ignored,
Reloaded(Vec<String>),
RolledBack {
reloaded: Vec<String>,
failed_plugin: String,
error: String,
},
}
impl ChangeOutcome {
pub fn summary(&self) -> String {
match self {
ChangeOutcome::Ignored => "ignored (no registered module matched)".to_string(),
ChangeOutcome::Reloaded(plugins) => format!("reloaded [{}]", plugins.join(", ")),
ChangeOutcome::RolledBack {
reloaded,
failed_plugin,
error,
} => format!(
"rolled back {} after [{}] applied: {error}",
failed_plugin,
reloaded.join(", ")
),
}
}
}
impl std::fmt::Display for ChangeOutcome {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.summary())
}
}
pub struct ModuleGraph {
modules: parking_lot::RwLock<BTreeMap<String, ModuleEntry>>,
reloader: parking_lot::RwLock<Arc<dyn ModuleReload>>,
}
impl Default for ModuleGraph {
fn default() -> Self {
Self::new()
}
}
impl crate::Service for ModuleGraph {}
impl ModuleGraph {
pub fn new() -> Self {
Self::with_reloader(Arc::new(NoopReload))
}
pub fn with_reloader(reloader: Arc<dyn ModuleReload>) -> Self {
Self {
modules: parking_lot::RwLock::new(BTreeMap::new()),
reloader: parking_lot::RwLock::new(reloader),
}
}
pub fn set_reloader(&self, reloader: Arc<dyn ModuleReload>) {
*self.reloader.write() = reloader;
}
pub fn register_module(
&self,
key: impl Into<String>,
dependencies: Vec<String>,
plugin_name: impl Into<String>,
) {
self.modules.write().insert(
key.into(),
ModuleEntry {
dependencies,
plugin_name: plugin_name.into(),
},
);
}
pub fn get(&self, key: &str) -> Option<ModuleEntry> {
self.modules.read().get(key).cloned()
}
pub fn module_keys(&self) -> Vec<String> {
self.modules.read().keys().cloned().collect()
}
pub fn len(&self) -> usize {
self.modules.read().len()
}
pub fn is_empty(&self) -> bool {
self.modules.read().is_empty()
}
pub fn depends_on(&self, key: &str) -> Vec<String> {
let modules = self.modules.read();
let mut visited = HashSet::new();
let mut order = Vec::new();
let mut queue = VecDeque::new();
if modules.contains_key(key) {
visited.insert(key.to_string());
queue.push_back(key.to_string());
}
while let Some(current) = queue.pop_front() {
order.push(current.clone());
for dependent in reverse_edges(&modules, ¤t) {
if visited.insert(dependent.clone()) {
queue.push_back(dependent);
}
}
}
order
}
pub fn change_many(&self, ctx: &Arc<crate::Context>, keys: &[String]) -> ChangeOutcome {
let affected = {
let modules = self.modules.read();
if !keys.iter().any(|k| modules.contains_key(k)) {
return ChangeOutcome::Ignored;
}
let mut visited: HashSet<String> = HashSet::new();
let mut queue: VecDeque<String> = VecDeque::new();
for key in keys {
if modules.contains_key(key) && visited.insert(key.clone()) {
queue.push_back(key.clone());
}
}
let mut plugins: Vec<String> = Vec::new();
let mut seen_plugins: HashSet<String> = HashSet::new();
while let Some(current) = queue.pop_front() {
if let Some(entry) = modules.get(¤t) {
if seen_plugins.insert(entry.plugin_name.clone()) {
plugins.push(entry.plugin_name.clone());
}
}
for dependent in reverse_edges(&modules, ¤t) {
if visited.insert(dependent.clone()) {
queue.push_back(dependent);
}
}
}
plugins
};
let reloader = self.reloader.read().clone();
let mut reloaded: Vec<String> = Vec::with_capacity(affected.len());
for plugin in affected {
match reloader.reload(ctx, &plugin) {
Ok(()) => reloaded.push(plugin),
Err(err) => {
let rollback_err = reloader.rollback(ctx, &plugin).err();
let error = match rollback_err {
Some(rb) => format!(
"{err}; ROLLBACK ALSO FAILED for {plugin}: {rb}"
),
None => format!("{err}; rolled back {plugin} to its previous state"),
};
tracing::error!(
plugin = %plugin,
applied = ?reloaded,
%error,
"module-graph change_many aborted"
);
return ChangeOutcome::RolledBack {
reloaded,
failed_plugin: plugin,
error,
};
}
}
}
ChangeOutcome::Reloaded(reloaded)
}
}
fn reverse_edges(
modules: &BTreeMap<String, ModuleEntry>,
target: &str,
) -> Vec<String> {
modules
.iter()
.filter(|(_, entry)| entry.dependencies.iter().any(|d| d == target))
.map(|(key, _)| key.clone())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Context;
struct FakeReload {
fail_on: Vec<String>,
ops: parking_lot::Mutex<Vec<String>>,
}
impl FakeReload {
fn new(fail_on: &[&str]) -> Self {
Self {
fail_on: fail_on.iter().map(|s| s.to_string()).collect(),
ops: parking_lot::Mutex::new(Vec::new()),
}
}
fn ops(&self) -> Vec<String> {
self.ops.lock().clone()
}
}
impl ModuleReload for FakeReload {
fn reload(&self, _ctx: &Arc<Context>, plugin: &str) -> Result<(), CordisError> {
self.ops.lock().push(format!("reload:{plugin}"));
if self.fail_on.iter().any(|f| f == plugin) {
Err(CordisError::Fiber(format!("{plugin} failed to rebuild")))
} else {
Ok(())
}
}
fn rollback(&self, _ctx: &Arc<Context>, plugin: &str) -> Result<(), CordisError> {
self.ops.lock().push(format!("rollback:{plugin}"));
Ok(())
}
}
fn ctx() -> Arc<Context> {
Context::new_root()
}
#[tokio::test]
async fn dependency_change_reloads_dependents_transitively() {
let fake = Arc::new(FakeReload::new(&[]));
let graph = ModuleGraph::with_reloader(fake.clone());
graph.register_module("a", vec![], "P.a");
graph.register_module("b", vec!["a".into()], "P.b");
graph.register_module("c", vec!["b".into()], "P.c");
assert_eq!(graph.depends_on("a"), vec!["a", "b", "c"]);
let outcome = graph.change_many(&ctx(), &["a".to_string()]);
assert_eq!(
outcome,
ChangeOutcome::Reloaded(s(&["P.a", "P.b", "P.c"]))
);
assert_eq!(
fake.ops(),
vec!["reload:P.a", "reload:P.b", "reload:P.c"]
);
}
#[tokio::test]
async fn cycles_terminate_and_still_propagate() {
let fake = Arc::new(FakeReload::new(&[]));
let graph = ModuleGraph::with_reloader(fake.clone());
graph.register_module("m1", vec!["m2".into()], "P.1");
graph.register_module("m2", vec!["m1".into()], "P.2");
graph.register_module("m3", vec!["m1".into()], "P.3");
assert_eq!(graph.depends_on("m2"), vec!["m2", "m1", "m3"]);
let outcome = graph.change_many(&ctx(), &["m2".to_string()]);
assert_eq!(
outcome,
ChangeOutcome::Reloaded(s(&["P.2", "P.1", "P.3"]))
);
assert_eq!(
fake.ops(),
vec!["reload:P.2", "reload:P.1", "reload:P.3"]
);
}
#[tokio::test]
async fn batched_changes_reload_each_plugin_once() {
let fake = Arc::new(FakeReload::new(&[]));
let graph = ModuleGraph::with_reloader(fake.clone());
graph.register_module("x", vec![], "P.x");
graph.register_module("y", vec!["x".into()], "P.y");
graph.register_module("z", vec!["x".into(), "y".into()], "P.z");
graph.register_module("w", vec![], "P.w");
let keys = vec![
"x".to_string(),
"x".to_string(),
"y".to_string(),
];
let outcome = graph.change_many(&ctx(), &keys);
assert_eq!(outcome, ChangeOutcome::Reloaded(s(&["P.x", "P.y", "P.z"])));
assert_eq!(
fake.ops(),
vec!["reload:P.x", "reload:P.y", "reload:P.z"]
);
}
#[tokio::test]
async fn rollback_keeps_successful_siblings_active() {
let fake = Arc::new(FakeReload::new(&["P.b"]));
let graph = ModuleGraph::with_reloader(fake.clone());
graph.register_module("a", vec![], "P.a");
graph.register_module("b", vec!["a".into()], "P.b");
graph.register_module("c", vec!["b".into()], "P.c");
let outcome = graph.change_many(&ctx(), &["a".to_string()]);
match outcome {
ChangeOutcome::RolledBack {
reloaded,
failed_plugin,
error,
} => {
assert_eq!(reloaded, vec!["P.a"]);
assert_eq!(failed_plugin, "P.b");
assert!(error.contains("P.b failed to rebuild"));
assert!(error.contains("rolled back"));
}
other => panic!("expected RolledBack, got {other:?}"),
}
assert_eq!(
fake.ops(),
vec![
"reload:P.a",
"reload:P.b",
"rollback:P.b",
]
);
}
#[tokio::test]
async fn external_key_classified_ignored() {
let fake = Arc::new(FakeReload::new(&[]));
let graph = ModuleGraph::with_reloader(fake.clone());
graph.register_module("known", vec![], "P.known");
let keys = vec!["external-thing".to_string(), "also-unknown".to_string()];
assert_eq!(graph.change_many(&ctx(), &keys), ChangeOutcome::Ignored);
assert!(fake.ops().is_empty());
assert_eq!(graph.change_many(&ctx(), &[]), ChangeOutcome::Ignored);
let mixed = vec!["external-thing".to_string(), "known".to_string()];
assert_eq!(
graph.change_many(&ctx(), &mixed),
ChangeOutcome::Reloaded(s(&["P.known"]))
);
}
fn s(items: &[&str]) -> Vec<String> {
items.iter().map(|i| i.to_string()).collect()
}
}