diff --git a/hive-jobq/src/guard.rs b/hive-jobq/src/guard.rs index ddd29bf3..f3018856 100644 --- a/hive-jobq/src/guard.rs +++ b/hive-jobq/src/guard.rs @@ -18,18 +18,25 @@ use std::cell::RefCell; use std::rc::Rc; -use crate::ResourceName; +use std::hash::Hash; + use crate::resources::ResourceTable; /// A [`ResourceTable`] shared between the scheduler and the live guards that /// release back into it on drop. Cheap to clone — an `Rc` refcount bump. -#[derive(Debug, Clone, Default)] -pub struct SharedResources(Rc>); +#[derive(Debug, Clone)] +pub struct SharedResources(Rc>>); -impl SharedResources { +impl Default for SharedResources { + fn default() -> Self { + Self::new(ResourceTable::new()) + } +} + +impl SharedResources { /// Wrap an existing table so guards can release into it. #[must_use] - pub fn new(table: ResourceTable) -> Self { + pub fn new(table: ResourceTable) -> Self { Self(Rc::new(RefCell::new(table))) } @@ -41,7 +48,7 @@ impl SharedResources { /// an over-capacity request can never succeed — same all-or-nothing /// semantics as [`ResourceTable::try_acquire_all`]. #[must_use] - pub fn acquire(&self, reqs: Vec<(ResourceName, u32)>) -> Option { + pub fn acquire(&self, reqs: Vec<(R, u32)>) -> Option> { if self.0.borrow_mut().try_acquire_all(&reqs) { Some(ResourceGuard(Acq::Owned { table: self.clone(), @@ -57,18 +64,18 @@ impl SharedResources { /// Keep the closure short: it holds a shared borrow, so calling /// [`SharedResources::acquire`] (a mutable borrow) from inside it would /// panic on the overlapping `RefCell` borrow. - pub fn with(&self, f: impl FnOnce(&ResourceTable) -> R) -> R { + pub fn with(&self, f: impl FnOnce(&ResourceTable) -> T) -> T { f(&self.0.borrow()) } } /// How a [`ResourceGuard`] relates to the units it represents. #[derive(Debug)] -enum Acq { +enum Acq { /// Owns real units; drop releases them back into the shared table. Owned { - table: SharedResources, - reqs: Vec<(ResourceName, u32)>, + table: SharedResources, + reqs: Vec<(R, u32)>, }, /// Re-entrant reuse of a resource an ancestor group already holds; drop /// releases nothing. @@ -78,9 +85,9 @@ enum Acq { /// An RAII grant of resources. Dropping it releases exactly what was acquired /// (nothing, for a borrowed re-entrant guard). #[derive(Debug)] -pub struct ResourceGuard(Acq); +pub struct ResourceGuard(Acq); -impl ResourceGuard { +impl ResourceGuard { /// A borrowed (re-entrant) guard that owns no units and releases nothing on /// drop. The scheduler hands one to a sub-node that depends on a resource /// its ancestor group already holds, so the shared unit is released once — @@ -93,7 +100,7 @@ impl ResourceGuard { /// The `(name, count)` units this guard releases on drop — empty when it is /// a borrowed re-entrant guard. #[must_use] - pub fn held(&self) -> &[(ResourceName, u32)] { + pub fn held(&self) -> &[(R, u32)] { match &self.0 { Acq::Owned { reqs, .. } => reqs, Acq::Borrowed => &[], @@ -108,7 +115,7 @@ impl ResourceGuard { } } -impl Drop for ResourceGuard { +impl Drop for ResourceGuard { fn drop(&mut self) { if let Acq::Owned { table, reqs } = &self.0 { table.0.borrow_mut().release_all(reqs); @@ -120,11 +127,11 @@ impl Drop for ResourceGuard { mod tests { use super::*; - fn res(name: &str) -> ResourceName { - ResourceName(name.to_owned()) + fn res(name: &str) -> String { + name.to_owned() } - fn shared_with(slots: u32) -> SharedResources { + fn shared_with(slots: u32) -> SharedResources { let mut t = ResourceTable::new(); t.set_capacity(res("build-slot"), slots); SharedResources::new(t) @@ -161,7 +168,7 @@ mod tests { let _owner = sr.acquire(vec![(agent.clone(), 1)]).expect("fits"); sr.with(|t| assert_eq!(t.available(&agent), 0)); { - let b = ResourceGuard::borrowed(); + let b = ResourceGuard::::borrowed(); assert!(!b.is_owning()); assert!(b.held().is_empty()); } // borrowed drop is a no-op @@ -179,7 +186,7 @@ mod tests { .expect("group takes the lock"); { // A sub-node reuses the group's lock: borrowed, no re-acquire. - let _sub = ResourceGuard::borrowed(); + let _sub = ResourceGuard::::borrowed(); sr.with(|t| assert_eq!(t.available(&agent), 0)); } // sub-node done — must NOT free the shared lock sr.with(|t| assert_eq!(t.available(&agent), 0)); diff --git a/hive-jobq/src/lib.rs b/hive-jobq/src/lib.rs index 98138622..68f4de71 100644 --- a/hive-jobq/src/lib.rs +++ b/hive-jobq/src/lib.rs @@ -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/` (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/` +//! (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/` (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 { /// 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 { +pub struct Node { /// 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 { /// 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, + pub deps: Vec>, /// 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")] -pub struct Graph { - nodes: Vec>, +#[serde( + try_from = "GraphData", + bound( + serialize = "N: serde::Serialize, R: serde::Serialize", + deserialize = "N: serde::Deserialize<'de>, R: serde::Deserialize<'de>" + ) +)] +pub struct Graph { + nodes: Vec>, next_id: u64, } @@ -198,15 +194,16 @@ pub struct Graph { // 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 { - nodes: Vec>, +#[serde(bound(deserialize = "N: serde::Deserialize<'de>, R: serde::Deserialize<'de>"))] +struct GraphData { + nodes: Vec>, next_id: u64, } -impl TryFrom> for Graph { +impl TryFrom> for Graph { type Error = GraphError; - fn try_from(data: GraphData) -> Result { + fn try_from(data: GraphData) -> Result { let graph = Graph { nodes: data.nodes, next_id: data.next_id, @@ -218,13 +215,13 @@ impl TryFrom> for Graph { // A `derive(Default)` would wrongly require `N: Default` (an empty graph holds // no payload); an empty `Vec>` needs no such bound, so impl it directly. -impl Default for Graph { +impl Default for Graph { fn default() -> Self { Self::new() } } -impl Graph { +impl Graph { /// An empty graph. #[must_use] pub fn new() -> Self { @@ -255,7 +252,7 @@ impl Graph { pub fn insert( &mut self, payload: N, - deps: Vec, + deps: Vec>, parent: Option, ) -> Result { if let Some(parent_id) = parent @@ -283,18 +280,18 @@ impl Graph { /// Borrow a node by id. #[must_use] - pub fn node(&self, id: NodeId) -> Option<&Node> { + pub fn node(&self, id: NodeId) -> Option<&Node> { 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> { + pub fn children(&self, id: NodeId) -> impl Iterator> { 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> { + pub fn nodes(&self) -> impl Iterator> { 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(g: &mut Graph, id: NodeId, state: State) { + fn set_state(g: &mut Graph, 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 = Graph::new(); + let mut g: Graph = 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 = serde_json::from_str(&json).unwrap(); + let back: Graph = 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:: { + let bad = Graph:: { 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::>(&json).unwrap_err(); + let err = serde_json::from_str::>(&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, diff --git a/hive-jobq/src/resources.rs b/hive-jobq/src/resources.rs index bf215fe7..418909a8 100644 --- a/hive-jobq/src/resources.rs +++ b/hive-jobq/src/resources.rs @@ -20,8 +20,8 @@ //! //! [`Dep::Resource`]: crate::Dep::Resource -use crate::ResourceName; use std::collections::HashMap; +use std::hash::Hash; /// A set of named counting semaphores. /// @@ -30,22 +30,22 @@ use std::collections::HashMap; /// units atomically via [`ResourceTable::try_acquire_all`] / /// [`ResourceTable::release_all`]. #[derive(Debug, Clone)] -pub struct ResourceTable { - /// Configured capacities, keyed by name. Missing ⇒ `default_capacity`. - capacities: HashMap, - /// Units currently held, keyed by name. Missing ⇒ 0. - held: HashMap, - /// Capacity assumed for a name with no configured entry. +pub struct ResourceTable { + /// Configured capacities, keyed by resource. Missing ⇒ `default_capacity`. + capacities: HashMap, + /// Units currently held, keyed by resource. Missing ⇒ 0. + held: HashMap, + /// Capacity assumed for a resource with no configured entry. default_capacity: u32, } -impl Default for ResourceTable { +impl Default for ResourceTable { fn default() -> Self { Self::new() } } -impl ResourceTable { +impl ResourceTable { /// An empty table whose unconfigured names default to capacity 1. #[must_use] pub fn new() -> Self { @@ -61,14 +61,14 @@ impl ResourceTable { /// Overwrites any previous capacity for that name. Lowering capacity below /// the currently-held count is allowed — the table simply reports zero /// available until enough is released; it never rejects a config change. - pub fn set_capacity(&mut self, name: ResourceName, capacity: u32) { + pub fn set_capacity(&mut self, name: R, capacity: u32) { self.capacities.insert(name, capacity); } /// The capacity of a name — its configured value, or the default (1) if it /// was never configured. #[must_use] - pub fn capacity(&self, name: &ResourceName) -> u32 { + pub fn capacity(&self, name: &R) -> u32 { self.capacities .get(name) .copied() @@ -77,13 +77,13 @@ impl ResourceTable { /// Units of a name currently held (0 if none). #[must_use] - pub fn held(&self, name: &ResourceName) -> u32 { + pub fn held(&self, name: &R) -> u32 { self.held.get(name).copied().unwrap_or(0) } /// Units of a name available to acquire right now (`capacity - held`). #[must_use] - pub fn available(&self, name: &ResourceName) -> u32 { + pub fn available(&self, name: &R) -> u32 { self.capacity(name).saturating_sub(self.held(name)) } @@ -95,7 +95,7 @@ impl ResourceTable { /// `false` and leaves the table completely untouched. A request for more /// units than a name's capacity can therefore never succeed — the scheduler /// should reject such a node at insert time so it does not wait forever. - pub fn try_acquire_all(&mut self, reqs: &[(ResourceName, u32)]) -> bool { + pub fn try_acquire_all(&mut self, reqs: &[(R, u32)]) -> bool { let wanted = aggregate(reqs); // All-or-nothing: bail before mutating if any request cannot be met. for (name, &count) in &wanted { @@ -113,7 +113,7 @@ impl ResourceTable { /// /// Duplicate names are summed. Releasing more than is held saturates at zero /// rather than underflowing, so a double release is harmless. - pub fn release_all(&mut self, reqs: &[(ResourceName, u32)]) { + pub fn release_all(&mut self, reqs: &[(R, u32)]) { for (name, count) in aggregate(reqs) { if let Some(h) = self.held.get_mut(name) { *h = h.saturating_sub(count); @@ -123,8 +123,8 @@ impl ResourceTable { } /// Sum a request list into per-name totals so duplicate names are one entry. -fn aggregate(reqs: &[(ResourceName, u32)]) -> HashMap<&ResourceName, u32> { - let mut wanted: HashMap<&ResourceName, u32> = HashMap::new(); +fn aggregate(reqs: &[(R, u32)]) -> HashMap<&R, u32> { + let mut wanted: HashMap<&R, u32> = HashMap::new(); for (name, count) in reqs { *wanted.entry(name).or_insert(0) += *count; } @@ -135,8 +135,8 @@ fn aggregate(reqs: &[(ResourceName, u32)]) -> HashMap<&ResourceName, u32> { mod tests { use super::*; - fn res(name: &str) -> ResourceName { - ResourceName(name.to_owned()) + fn res(name: &str) -> String { + name.to_owned() } #[test] diff --git a/hive-jobq/src/scheduler.rs b/hive-jobq/src/scheduler.rs index 761ed2fc..497d406f 100644 --- a/hive-jobq/src/scheduler.rs +++ b/hive-jobq/src/scheduler.rs @@ -18,10 +18,11 @@ //! the eager `AfterOk` failure cascade are layered on top of this owned core. use std::collections::HashMap; +use std::hash::Hash; use crate::guard::{ResourceGuard, SharedResources}; use crate::resources::ResourceTable; -use crate::{Dep, DepWhen, Graph, GraphError, NodeId, ResourceName, State}; +use crate::{Dep, DepWhen, Graph, GraphError, NodeId, State}; /// The result of a node's own execution, reported to [`Scheduler::complete`]. /// @@ -37,23 +38,23 @@ pub enum Outcome { /// Drives a [`Graph`] over a shared resource pool: claim runnable nodes, hold /// their resources for the subtree's lifetime, release on subtree-terminal. -pub struct Scheduler { - graph: Graph, - resources: SharedResources, +pub struct Scheduler { + graph: Graph, + resources: SharedResources, /// Owned resource guards, keyed by the node that acquired them. Dropped /// (releasing the units) when that node's whole subtree is terminal. - owned: HashMap>, + owned: HashMap>>, /// The single re-entrancy slot per `(ancestor-holder, resource)`: the id of /// the descendant currently *borrowing* that ancestor's lock. Present ⇒ the /// slot is taken, so no other descendant may re-enter the same lock until /// the borrower's subtree is terminal — "only one node at a time within". - borrow_slots: HashMap<(NodeId, ResourceName), NodeId>, + borrow_slots: HashMap<(NodeId, R), NodeId>, } -impl Scheduler { +impl Scheduler { /// A scheduler over `graph` with `resources` as the capacity pool. #[must_use] - pub fn new(graph: Graph, resources: ResourceTable) -> Self { + pub fn new(graph: Graph, resources: ResourceTable) -> Self { Self { graph, resources: SharedResources::new(resources), @@ -64,7 +65,7 @@ impl Scheduler { /// The graph, for inspection (state, hierarchy, UI rendering). #[must_use] - pub fn graph(&self) -> &Graph { + pub fn graph(&self) -> &Graph { &self.graph } @@ -77,7 +78,7 @@ impl Scheduler { pub fn append( &mut self, payload: N, - deps: Vec, + deps: Vec>, parent: Option, ) -> Result { self.graph.insert(payload, deps, parent) @@ -138,7 +139,7 @@ impl Scheduler { /// The nearest ancestor of `id` that *owns* (holds real units of) `name`, /// or `None` if no ancestor holds it (⇒ `id` must own-acquire it itself). - fn ancestor_owning(&self, id: NodeId, name: &ResourceName) -> Option { + fn ancestor_owning(&self, id: NodeId, name: &R) -> Option { let mut cursor = self.graph.node(id)?.parent; while let Some(ancestor) = cursor { if self.node_owns(ancestor, name) { @@ -150,7 +151,7 @@ impl Scheduler { } /// Whether node `holder` holds an owned guard covering resource `name`. - fn node_owns(&self, holder: NodeId, name: &ResourceName) -> bool { + fn node_owns(&self, holder: NodeId, name: &R) -> bool { self.owned.get(&holder).is_some_and(|guards| { guards .iter() @@ -246,7 +247,7 @@ impl Scheduler { } /// The `(name, count)` resource units `id` must hold to run. - fn resource_reqs(&self, id: NodeId) -> Vec<(ResourceName, u32)> { + fn resource_reqs(&self, id: NodeId) -> Vec<(R, u32)> { let Some(node) = self.graph.node(id) else { return Vec::new(); }; @@ -264,25 +265,25 @@ impl Scheduler { mod tests { use super::*; - fn res(name: &str) -> ResourceName { - ResourceName(name.to_owned()) + fn res(name: &str) -> String { + name.to_owned() } /// A graph + a resource table with `build-slot` set to `slots`. - fn scheduler_with_slots(slots: u32) -> Scheduler<&'static str> { + fn scheduler_with_slots(slots: u32) -> Scheduler<&'static str, String> { let mut table = ResourceTable::new(); table.set_capacity(res("build-slot"), slots); Scheduler::new(Graph::new(), table) } - fn slot_dep() -> Vec { + fn slot_dep() -> Vec> { vec![Dep::Resource { name: res("build-slot"), count: 1, }] } - fn resource_dep(name: &str) -> Vec { + fn resource_dep(name: &str) -> Vec> { vec![Dep::Resource { name: res(name), count: 1, @@ -379,7 +380,7 @@ mod tests { #[test] fn failed_after_ok_dep_cancels_dependents_but_after_any_still_runs() { - let mut s = Scheduler::new(Graph::new(), ResourceTable::new()); + let mut s: Scheduler<&str, String> = Scheduler::new(Graph::new(), ResourceTable::new()); let root = s.append("root", vec![], None).expect("root"); let strong1 = s .append(