feat(#2500): add hive-jobq RAII resource guards with recursive re-entrancy

This commit is contained in:
atlas 2026-07-17 15:18:27 +02:00 committed by mara
commit 01ff8071f6
2 changed files with 192 additions and 2 deletions

189
hive-jobq/src/guard.rs Normal file
View file

@ -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<RefCell<…>>` (single-threaded interior mutability), not
//! `Arc<Mutex<…>>` — 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<RefCell<ResourceTable>>);
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<ResourceGuard> {
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<R>(&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));
}
}

View file

@ -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.