# zinit-server - Dependency Graph
Dependency graph structure using petgraph.
## Dependency Types
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DepType {
/// Ordering only: start this after dependency has STARTED (not necessarily ready)
/// Does not fail if dependency fails.
After,
/// Hard dependency: dependency must be RUNNING (satisfied).
/// Cannot start if dependency not running.
Requires,
/// Soft dependency: try to start, but ok if fails.
Wants,
/// Mutual exclusion: cannot run at same time.
Conflicts,
}
```
## Semantics
| `after` | Waits for dep to START | No | Ordering preference |
| `requires` | Waits for dep to be RUNNING | Can be configured | Hard dependency |
| `wants` | No | No | Nice-to-have |
| `conflicts` | Waits for dep to STOP | N/A | Mutual exclusion |
## Graph Structure
```rust
use petgraph::graph::{DiGraph, NodeIndex};
use std::collections::HashMap;
pub type ServiceId = NodeIndex;
pub struct ServiceGraph {
/// Edge direction: dependency -> dependent
/// "sshd requires network" = edge from network to sshd
graph: DiGraph<Service, DepType>,
/// Name -> ID lookup
by_name: HashMap<String, ServiceId>,
}
```
## Construction
```rust
impl ServiceGraph {
pub fn new() -> Self {
Self {
graph: DiGraph::new(),
by_name: HashMap::new(),
}
}
pub fn add_service(&mut self, service: Service) -> ServiceId {
let name = service.name.clone();
let id = self.graph.add_node(service);
self.by_name.insert(name, id);
id
}
pub fn add_dependency(
&mut self,
dependent: &str, // the service that HAS the dependency
dependency: &str, // the service it depends ON
dep_type: DepType,
) -> Result<(), GraphError> {
let dependent_id = self.by_name.get(dependent)
.ok_or_else(|| GraphError::UnknownService(dependent.to_string()))?;
let dependency_id = self.by_name.get(dependency)
.ok_or_else(|| GraphError::UnknownService(dependency.to_string()))?;
// Edge goes FROM dependency TO dependent
self.graph.add_edge(*dependency_id, *dependent_id, dep_type);
Ok(())
}
}
```
## Validation
```rust
impl ServiceGraph {
pub fn validate(&self) -> Result<(), GraphError> {
use petgraph::algo::is_cyclic_directed;
if is_cyclic_directed(&self.graph) {
return Err(GraphError::CyclicDependency(self.find_cycle()));
}
Ok(())
}
fn find_cycle(&self) -> Vec<String> {
use petgraph::algo::kosaraju_scc;
for scc in kosaraju_scc(&self.graph) {
if scc.len() > 1 {
return scc.iter()
.map(|id| self.graph[*id].name.clone())
.collect();
}
}
vec![]
}
}
#[derive(Debug, thiserror::Error)]
pub enum GraphError {
#[error("Unknown service: {0}")]
UnknownService(String),
#[error("Cyclic dependency: {}", .0.join(" -> "))]
CyclicDependency(Vec<String>),
#[error("Duplicate service name: {0}")]
DuplicateName(String),
}
```
## Queries
```rust
impl ServiceGraph {
pub fn get(&self, id: ServiceId) -> Option<&Service> {
self.graph.node_weight(id)
}
pub fn get_mut(&mut self, id: ServiceId) -> Option<&mut Service> {
self.graph.node_weight_mut(id)
}
pub fn get_by_name(&self, name: &str) -> Option<ServiceId> {
self.by_name.get(name).copied()
}
pub fn get_state(&self, id: ServiceId) -> &ServiceState {
&self.graph[id].state
}
pub fn set_state(&mut self, id: ServiceId, state: ServiceState) {
self.graph[id].state = state;
}
pub fn all_services(&self) -> impl Iterator<Item = ServiceId> + '_ {
self.graph.node_indices()
}
/// Get topologically sorted start order
pub fn start_order(&self) -> Vec<ServiceId> {
use petgraph::algo::toposort;
toposort(&self.graph, None).unwrap_or_default()
}
}
```
## Dependency Queries
```rust
impl ServiceGraph {
/// What does this service depend on?
pub fn dependencies(&self, id: ServiceId) -> Vec<(ServiceId, DepType)> {
use petgraph::Direction;
self.graph
.edges_directed(id, Direction::Incoming)
.map(|e| (e.source(), *e.weight()))
.collect()
}
/// What depends on this service?
pub fn dependents(&self, id: ServiceId) -> Vec<ServiceId> {
use petgraph::Direction;
self.graph
.edges_directed(id, Direction::Outgoing)
.map(|e| e.target())
.collect()
}
/// Can this service start right now?
pub fn can_start(&self, id: ServiceId) -> Result<(), BlockedReason> {
let mut waiting_on = Vec::new();
let mut conflicts_with = Vec::new();
for (dep_id, dep_type) in self.dependencies(id) {
let dep_state = &self.graph[dep_id].state;
let dep_name = self.graph[dep_id].name.clone();
match dep_type {
DepType::Requires => {
if !dep_state.is_satisfied() {
waiting_on.push(dep_name);
}
}
DepType::After => {
// Just needs to have started (not still Inactive/Blocked)
if matches!(dep_state, ServiceState::Inactive | ServiceState::Blocked { .. }) {
waiting_on.push(dep_name);
}
}
DepType::Wants => {
// Soft dep - don't block
}
DepType::Conflicts => {
if dep_state.is_active() {
conflicts_with.push(dep_name);
}
}
}
}
if !conflicts_with.is_empty() {
return Err(BlockedReason::ConflictsWith(conflicts_with));
}
if !waiting_on.is_empty() {
return Err(BlockedReason::WaitingOn(waiting_on));
}
Ok(())
}
/// Check if all "requires" deps are satisfied
pub fn all_requires_satisfied(&self, id: ServiceId) -> bool {
self.dependencies(id)
.iter()
.filter(|(_, dt)| *dt == DepType::Requires)
.all(|(dep_id, _)| self.graph[*dep_id].state.is_satisfied())
}
}
#[derive(Debug, Clone)]
pub enum BlockedReason {
WaitingOn(Vec<String>),
ConflictsWith(Vec<String>),
}
```
## Hot Reload
```rust
impl ServiceGraph {
/// Reload configuration from disk.
/// Validates before applying. Running states preserved.
pub fn reload_from_directory(&mut self, path: &Path) -> Result<ReloadDiff, ReloadError> {
// 1. Load candidate graph from disk
let candidate = ServiceGraph::load_from_directory(path)?;
// 2. Validate candidate
candidate.validate()?;
// 3. Compute diff
let diff = self.compute_diff(&candidate);
// 4. Check if removals are safe
for name in &diff.removed {
let id = self.get_by_name(name).unwrap();
let running_deps = self.dependents(id)
.into_iter()
.filter(|d| self.graph[*d].state.is_active())
.map(|d| self.graph[d].name.clone())
.collect::<Vec<_>>();
if !running_deps.is_empty() {
return Err(ReloadError::UnsafeRemoval {
service: name.clone(),
running_dependents: running_deps,
});
}
}
// 5. Preserve states for services that still exist
let mut preserved_states = HashMap::new();
for name in diff.unchanged.iter().chain(&diff.changed) {
if let Some(id) = self.get_by_name(name) {
preserved_states.insert(name.clone(), self.graph[id].state.clone());
}
}
// 6. Swap graphs
*self = candidate;
// 7. Restore preserved states
for (name, state) in preserved_states {
if let Some(id) = self.get_by_name(&name) {
self.graph[id].state = state;
}
}
Ok(diff)
}
fn compute_diff(&self, candidate: &ServiceGraph) -> ReloadDiff {
let old_names: HashSet<_> = self.by_name.keys().cloned().collect();
let new_names: HashSet<_> = candidate.by_name.keys().cloned().collect();
ReloadDiff {
added: new_names.difference(&old_names).cloned().collect(),
removed: old_names.difference(&new_names).cloned().collect(),
changed: vec![], // TODO: compare configs
unchanged: old_names.intersection(&new_names).cloned().collect(),
}
}
}
#[derive(Debug)]
pub struct ReloadDiff {
pub added: Vec<String>,
pub removed: Vec<String>,
pub changed: Vec<String>,
pub unchanged: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum ReloadError {
#[error("Config error: {0}")]
Config(#[from] ConfigError),
#[error("Graph error: {0}")]
Graph(#[from] GraphError),
#[error("Cannot remove {service}: running dependents: {}", running_dependents.join(", "))]
UnsafeRemoval {
service: String,
running_dependents: Vec<String>,
},
}
```
## ASCII Visualization
```rust
impl ServiceGraph {
/// Explain why a service is blocked
pub fn format_why_blocked(&self, name: &str) -> String {
let Some(id) = self.get_by_name(name) else {
return format!("Unknown service: {name}");
};
let service = &self.graph[id];
let mut out = String::new();
match &service.state {
ServiceState::Blocked { waiting_on, conflicts_with } => {
out.push_str(&format!("{} {} (blocked)\n",
service.state.symbol(), name));
if !waiting_on.is_empty() {
for (i, dep_id) in waiting_on.iter().enumerate() {
let dep = &self.graph[*dep_id];
let is_last = i == waiting_on.len() - 1 && conflicts_with.is_empty();
let prefix = if is_last { "└──" } else { "├──" };
out.push_str(&format!(
"{} requires: {} ({}) {}\n",
prefix,
dep.name,
dep.state.name(),
if dep.state.is_satisfied() { "" } else { "<- waiting" }
));
}
}
for (i, dep_id) in conflicts_with.iter().enumerate() {
let dep = &self.graph[*dep_id];
let is_last = i == conflicts_with.len() - 1;
let prefix = if is_last { "└──" } else { "├──" };
out.push_str(&format!(
"{} conflicts: {} ({}) <- must stop\n",
prefix,
dep.name,
dep.state.name(),
));
}
}
_ => {
out.push_str(&format!("{} {} ({})",
service.state.symbol(), name, service.state.name()));
}
}
out
}
/// Full dependency tree
pub fn format_tree(&self) -> String {
let mut out = String::new();
let order = self.start_order();
// Find root services (nothing depends on them)
let roots: Vec<_> = order.iter()
.filter(|id| self.dependents(**id).is_empty())
.copied()
.collect();
for (i, root) in roots.iter().enumerate() {
self.format_tree_node(*root, &mut out, "", i == roots.len() - 1);
}
out.push_str("\n[-]=inactive [?]=blocked [>]=starting [+]=running [!]=stopping [.]=exited [X]=failed\n");
out
}
fn format_tree_node(&self, id: ServiceId, out: &mut String, prefix: &str, is_last: bool) {
let service = &self.graph[id];
let connector = if is_last { "└── " } else { "├── " };
let child_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
let kind = if service.is_target { " [target]" } else { "" };
out.push_str(&format!(
"{}{}{} {}{} ({})\n",
prefix, connector,
service.state.symbol(),
service.name,
kind,
service.state.name()
));
let deps = self.dependencies(id);
for (i, (dep_id, _)) in deps.iter().enumerate() {
self.format_tree_node(*dep_id, out, &child_prefix, i == deps.len() - 1);
}
}
/// Simple list
pub fn format_list(&self) -> String {
let mut out = String::new();
for id in self.all_services() {
let service = &self.graph[id];
let pid_str = service.state.pid()
.map(|p| format!(" (pid: {})", p))
.unwrap_or_default();
out.push_str(&format!(
"{} {:20} {}{}\n",
service.state.symbol(),
service.name,
service.state.name(),
pid_str
));
}
out
}
}
```
## Example Output
```
$ zinit list
[+] network-ready running
[+] sshd running
[>] my-app starting
[?] worker blocked
$ zinit why worker
[?] worker (blocked)
├── requires: database (starting) <- waiting
└── requires: redis (inactive) <- waiting
$ zinit tree
[+] system [target] (running)
├── [+] network-ready [target] (running)
│ ├── [+] dhcp (running)
│ └── [+] dns (running)
└── [>] my-app (starting)
└── [+] network-ready [target] (running)
```