refactor(jobq): make the crate generic over the resource type R

Replace the concrete ResourceName(String) with a type parameter
R: Clone + Eq + Hash threaded end-to-end (Dep<R>, Node<N,R>, Graph<N,R>,
ResourceTable<R>, ResourceGuard<R>/SharedResources<R>, Scheduler<N,R>).
The crate no longer hard-codes the resource identity; the consumer picks
the concrete type (a String, or an enum like BuildSlot/Agent(name)) at
the port. Tests use String as the concrete R. Pure type-parameter
thread-through, no logic change. 25 tests green, clippy pedantic clean.
This commit is contained in:
atlas 2026-07-18 21:49:27 +02:00 committed by mara
commit b4bcf8b6e4
4 changed files with 105 additions and 100 deletions

View file

@ -7,11 +7,11 @@
//! inserts a self-contained **node group** and returns its id; the scheduler
//! runs a continuous loop, starting every node whose [`Dep`]s are satisfied:
//!
//! - **Resource** deps are named counting semaphores ([`ResourceName`]):
//! `build-slot` (capacity N), `agent/<name>` (capacity 1), or any name
//! (capacity 1, created on use). A node acquires *all* its resource deps
//! atomically at start (all-or-nothing) — no hold-and-wait, so no deadlock
//! and no cycle detection needed.
//! - **Resource** deps are named counting semaphores over a caller-chosen
//! type `R` (a `String` or an enum): `build-slot` (cap N), `agent/<name>`
//! (cap 1), or any name (cap 1, created on use). A node acquires *all* its
//! resource deps atomically at start (all-or-nothing) — no hold-and-wait,
//! so no deadlock and no cycle detection needed.
//! - **Node** deps wait on a node/group per [`DepWhen`]: `AfterOk` needs
//! success (a failed dep cancels the dependent), `AfterAny` only terminal.
//!
@ -49,16 +49,6 @@ pub mod scheduler;
)]
pub struct NodeId(pub(crate) u64);
/// A named counting semaphore.
///
/// Examples: `build-slot` (capacity configured to the number of build slots),
/// `agent/<name>` (capacity 1 — the per-agent lifecycle lock), or any other
/// name, which is assumed to have capacity 1 and is created on first use.
#[derive(
Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
)]
pub struct ResourceName(pub String);
/// When a [`Dep::Node`] edge is satisfied — the strong/weak distinction the
/// current queue carries as `DepWhen`, load-bearing for failure safety.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@ -89,7 +79,7 @@ impl DepWhen {
/// edge it names is satisfied (per its [`DepWhen`]) *and* every [`Dep::Resource`]
/// it names can be acquired (all of them, atomically).
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum Dep {
pub enum Dep<R> {
/// Depend on another node (or a group, by its group node's id). Whether a
/// *failed* dependency satisfies the edge is decided by `when`: `AfterOk`
/// requires success (and cancels this node if the dep fails), `AfterAny`
@ -105,7 +95,7 @@ pub enum Dep {
/// released when the node completes.
Resource {
/// The resource to acquire.
name: ResourceName,
name: R,
/// How many units to hold (usually 1).
count: u32,
},
@ -143,7 +133,7 @@ impl State {
/// payload; the caller supplies `N` (its own node kind) and a runner to execute
/// a claimed node.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Node<N> {
pub struct Node<N, R> {
/// Stable identity, assigned on insert.
pub id: NodeId,
/// The group this node belongs to, if any. `None` for a top-level group
@ -152,7 +142,7 @@ pub struct Node<N> {
/// Caller-defined payload (the node's kind / work description).
pub payload: N,
/// What must hold before this node runs (other nodes + resources).
pub deps: Vec<Dep>,
pub deps: Vec<Dep<R>>,
/// Lifecycle state.
pub state: State,
}
@ -188,9 +178,15 @@ pub enum GraphError {
/// walks this graph filling open slots. Completed groups are retained (no
/// pruning in v1).
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(try_from = "GraphData<N>")]
pub struct Graph<N> {
nodes: Vec<Node<N>>,
#[serde(
try_from = "GraphData<N, R>",
bound(
serialize = "N: serde::Serialize, R: serde::Serialize",
deserialize = "N: serde::Deserialize<'de>, R: serde::Deserialize<'de>"
)
)]
pub struct Graph<N, R> {
nodes: Vec<Node<N, R>>,
next_id: u64,
}
@ -198,15 +194,16 @@ pub struct Graph<N> {
// below — which runs [`Graph::validate`], so a loaded graph can never carry a
// dangling id reference (Serialize does not validate; Deserialize always does).
#[derive(serde::Deserialize)]
struct GraphData<N> {
nodes: Vec<Node<N>>,
#[serde(bound(deserialize = "N: serde::Deserialize<'de>, R: serde::Deserialize<'de>"))]
struct GraphData<N, R> {
nodes: Vec<Node<N, R>>,
next_id: u64,
}
impl<N> TryFrom<GraphData<N>> for Graph<N> {
impl<N, R> TryFrom<GraphData<N, R>> for Graph<N, R> {
type Error = GraphError;
fn try_from(data: GraphData<N>) -> Result<Self, Self::Error> {
fn try_from(data: GraphData<N, R>) -> Result<Self, Self::Error> {
let graph = Graph {
nodes: data.nodes,
next_id: data.next_id,
@ -218,13 +215,13 @@ impl<N> TryFrom<GraphData<N>> for Graph<N> {
// A `derive(Default)` would wrongly require `N: Default` (an empty graph holds
// no payload); an empty `Vec<Node<N>>` needs no such bound, so impl it directly.
impl<N> Default for Graph<N> {
impl<N, R> Default for Graph<N, R> {
fn default() -> Self {
Self::new()
}
}
impl<N> Graph<N> {
impl<N, R> Graph<N, R> {
/// An empty graph.
#[must_use]
pub fn new() -> Self {
@ -255,7 +252,7 @@ impl<N> Graph<N> {
pub fn insert(
&mut self,
payload: N,
deps: Vec<Dep>,
deps: Vec<Dep<R>>,
parent: Option<NodeId>,
) -> Result<NodeId, GraphError> {
if let Some(parent_id) = parent
@ -283,18 +280,18 @@ impl<N> Graph<N> {
/// Borrow a node by id.
#[must_use]
pub fn node(&self, id: NodeId) -> Option<&Node<N>> {
pub fn node(&self, id: NodeId) -> Option<&Node<N, R>> {
self.nodes.iter().find(|n| n.id == id)
}
/// The direct children of a group node (nodes whose `parent` is `id`).
pub fn children(&self, id: NodeId) -> impl Iterator<Item = &Node<N>> {
pub fn children(&self, id: NodeId) -> impl Iterator<Item = &Node<N, R>> {
self.nodes.iter().filter(move |n| n.parent == Some(id))
}
/// Every node in the graph, in insertion order. The scheduler iterates
/// this to find runnable pending nodes.
pub fn nodes(&self) -> impl Iterator<Item = &Node<N>> {
pub fn nodes(&self) -> impl Iterator<Item = &Node<N, R>> {
self.nodes.iter()
}
@ -365,7 +362,7 @@ mod tests {
#[test]
fn insert_mints_stable_monotonic_ids() {
let mut g: Graph<&str> = Graph::new();
let mut g: Graph<&str, String> = Graph::new();
let a = g.insert("sweep", vec![], None).unwrap();
let b = g
.insert(
@ -384,14 +381,14 @@ mod tests {
assert_eq!(g.node(a).unwrap().parent, None);
}
fn set_state<N>(g: &mut Graph<N>, id: NodeId, state: State) {
fn set_state<N, R>(g: &mut Graph<N, R>, id: NodeId, state: State) {
let idx = g.nodes.iter().position(|n| n.id == id).unwrap();
g.nodes[idx].state = state;
}
#[test]
fn group_terminal_requires_the_group_node_and_all_children_terminal() {
let mut g: Graph<&str> = Graph::new();
let mut g: Graph<&str, String> = Graph::new();
let group = g.insert("group", vec![], None).unwrap();
let child = g.insert("child", vec![], Some(group)).unwrap();
// Both pending → not terminal.
@ -409,7 +406,7 @@ mod tests {
fn empty_running_group_is_not_terminal() {
// A running node with no children yet may still append some, so it must
// not read as terminal just because its child set is currently empty.
let mut g: Graph<&str> = Graph::new();
let mut g: Graph<&str, String> = Graph::new();
let group = g.insert("group", vec![], None).unwrap();
set_state(&mut g, group, State::Running);
assert!(!g.group_terminal(group));
@ -444,7 +441,7 @@ mod tests {
#[test]
fn insert_rejects_unknown_parent() {
let mut g: Graph<&str> = Graph::new();
let mut g: Graph<&str, String> = Graph::new();
let bogus = NodeId(7);
assert_eq!(
g.insert("x", vec![], Some(bogus)).unwrap_err(),
@ -454,7 +451,7 @@ mod tests {
#[test]
fn insert_rejects_unknown_dep() {
let mut g: Graph<&str> = Graph::new();
let mut g: Graph<&str, String> = Graph::new();
let bogus = NodeId(42);
let deps = vec![Dep::Node {
id: bogus,
@ -468,7 +465,7 @@ mod tests {
#[test]
fn valid_graph_round_trips_through_serde() {
let mut g: Graph<String> = Graph::new();
let mut g: Graph<String, String> = Graph::new();
let a = g.insert("a".to_owned(), vec![], None).unwrap();
g.insert(
"b".to_owned(),
@ -480,7 +477,7 @@ mod tests {
)
.unwrap();
let json = serde_json::to_string(&g).unwrap();
let back: Graph<String> = serde_json::from_str(&json).unwrap();
let back: Graph<String, String> = serde_json::from_str(&json).unwrap();
assert!(back.validate().is_ok());
assert_eq!(back.node(a).unwrap().payload, "a");
}
@ -489,7 +486,7 @@ mod tests {
fn deserialize_rejects_a_dangling_dependency() {
// Build a graph whose only node depends on a non-existent id, serialize
// it (Serialize does not validate), and confirm deserialize rejects it.
let bad = Graph::<String> {
let bad = Graph::<String, String> {
nodes: vec![Node {
id: NodeId(0),
parent: None,
@ -503,13 +500,13 @@ mod tests {
next_id: 1,
};
let json = serde_json::to_string(&bad).unwrap();
let err = serde_json::from_str::<Graph<String>>(&json).unwrap_err();
let err = serde_json::from_str::<Graph<String, String>>(&json).unwrap_err();
assert!(err.to_string().contains("unknown node"));
}
#[test]
fn validate_rejects_next_id_that_would_remint() {
let bad = Graph::<&str> {
let bad = Graph::<&str, String> {
nodes: vec![Node {
id: NodeId(5),
parent: None,