diff --git a/hive-jobq/src/guard.rs b/hive-jobq/src/guard.rs new file mode 100644 index 00000000..ddd29bf3 --- /dev/null +++ b/hive-jobq/src/guard.rs @@ -0,0 +1,189 @@ +//! RAII guard objects over [`ResourceTable`] — the recursive-lock layer. +//! +//! A running node acquires its resources through [`SharedResources::acquire`], +//! which hands back a [`ResourceGuard`]. Dropping the guard releases exactly +//! what it acquired, so a node's resources are freed when its grant goes out of +//! scope — there is no explicit release call to forget. +//! +//! Re-entrancy within a node group is expressed with +//! [`ResourceGuard::borrowed`]: a sub-node that reuses a resource its ancestor +//! group already holds gets a guard that owns no units and releases nothing on +//! drop, so the shared unit is released exactly once — when the owning group's +//! guard drops — never double-counted or freed early. +//! +//! Single-owner by design: the scheduler drives one settle loop, so the shared +//! table is `Rc>` (single-threaded interior mutability), not +//! `Arc>` — there is no cross-thread contention to guard against. + +use std::cell::RefCell; +use std::rc::Rc; + +use crate::ResourceName; +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>); + +impl SharedResources { + /// Wrap an existing table so guards can release into it. + #[must_use] + pub fn new(table: ResourceTable) -> Self { + Self(Rc::new(RefCell::new(table))) + } + + /// Atomically acquire every requested `(name, count)` or none of them. + /// + /// Returns an owning [`ResourceGuard`] (releases on drop) when the whole + /// request fits in what is available right now; returns `None` and leaves + /// the table completely untouched otherwise. Duplicate names are summed and + /// 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 { + if self.0.borrow_mut().try_acquire_all(&reqs) { + Some(ResourceGuard(Acq::Owned { + table: self.clone(), + reqs, + })) + } else { + None + } + } + + /// Compute something from the underlying table (e.g. query `available`). + /// + /// 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 { + f(&self.0.borrow()) + } +} + +/// How a [`ResourceGuard`] relates to the units it represents. +#[derive(Debug)] +enum Acq { + /// Owns real units; drop releases them back into the shared table. + Owned { + table: SharedResources, + reqs: Vec<(ResourceName, u32)>, + }, + /// Re-entrant reuse of a resource an ancestor group already holds; drop + /// releases nothing. + Borrowed, +} + +/// 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); + +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 — + /// when the owning group's guard drops — never twice or early. + #[must_use] + pub fn borrowed() -> Self { + Self(Acq::Borrowed) + } + + /// 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)] { + match &self.0 { + Acq::Owned { reqs, .. } => reqs, + Acq::Borrowed => &[], + } + } + + /// Whether this guard owns real units (`true`) or is a borrowed re-entrant + /// guard (`false`). + #[must_use] + pub fn is_owning(&self) -> bool { + matches!(self.0, Acq::Owned { .. }) + } +} + +impl Drop for ResourceGuard { + fn drop(&mut self) { + if let Acq::Owned { table, reqs } = &self.0 { + table.0.borrow_mut().release_all(reqs); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn res(name: &str) -> ResourceName { + ResourceName(name.to_owned()) + } + + fn shared_with(slots: u32) -> SharedResources { + let mut t = ResourceTable::new(); + t.set_capacity(res("build-slot"), slots); + SharedResources::new(t) + } + + #[test] + fn owning_guard_releases_on_drop() { + let sr = shared_with(2); + let slot = res("build-slot"); + { + let g = sr.acquire(vec![(slot.clone(), 2)]).expect("fits"); + assert!(g.is_owning()); + assert_eq!(g.held(), &[(slot.clone(), 2)]); + sr.with(|t| assert_eq!(t.available(&slot), 0)); + } // guard dropped here + sr.with(|t| assert_eq!(t.available(&slot), 2)); + } + + #[test] + fn acquire_returns_none_and_leaves_table_untouched_when_it_does_not_fit() { + let sr = shared_with(1); + let slot = res("build-slot"); + let _held = sr.acquire(vec![(slot.clone(), 1)]).expect("first fits"); + assert!(sr.acquire(vec![(slot.clone(), 1)]).is_none()); + // The failed acquire took nothing extra. + sr.with(|t| assert_eq!(t.held(&slot), 1)); + } + + #[test] + fn borrowed_guard_releases_nothing_on_drop() { + let sr = shared_with(1); + let agent = res("agent/foo"); + // An owning guard holds the single agent-lock unit. + let _owner = sr.acquire(vec![(agent.clone(), 1)]).expect("fits"); + sr.with(|t| assert_eq!(t.available(&agent), 0)); + { + let b = ResourceGuard::borrowed(); + assert!(!b.is_owning()); + assert!(b.held().is_empty()); + } // borrowed drop is a no-op + // Still held by the owner — the borrow did not release it. + sr.with(|t| assert_eq!(t.available(&agent), 0)); + } + + #[test] + fn nested_group_lock_released_once_when_owner_drops() { + let sr = shared_with(1); + let agent = res("agent/foo"); + { + let _group = sr + .acquire(vec![(agent.clone(), 1)]) + .expect("group takes the lock"); + { + // A sub-node reuses the group's lock: borrowed, no re-acquire. + 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)); + } // group done — frees it exactly once + sr.with(|t| assert_eq!(t.available(&agent), 1)); + } +} diff --git a/hive-jobq/src/lib.rs b/hive-jobq/src/lib.rs index ee669f1e..c3c9111a 100644 --- a/hive-jobq/src/lib.rs +++ b/hive-jobq/src/lib.rs @@ -26,9 +26,10 @@ //! node kind. Resources are held by the acquiring node and released on //! completion via guard objects, recursive within a group. //! -//! The guards and the scheduler loop are follow-ups; the named-counter resource -//! machinery lives in [`resources`], the base this data model builds on. +//! The scheduler loop is a follow-up; the resource machinery lives in +//! [`resources`] and the RAII lock guards over it in [`guard`]. +pub mod guard; pub mod resources; /// Opaque, stable, monotonic node identifier.