Nothing in the gate read doc-comments: clippy doesn't check intra-doc links, cargo test doesn't, and no check built docs. So a [`Foo`] pointing at a renamed, moved or deleted item rendered as plain text and had no discoverer but a human happening to read the comment. That matters here more than in most repos, because the convention is to put a thing's authoritative description in one doc-comment and point at it from everywhere else -- the design leans on the pointers being real, and a dangling link is worse than no link since it names something and sends the reader looking. Adds `docs-rustdoc` to nix/checks.nix: craneLib.cargoDoc over --workspace --no-deps --document-private-items, denying six rustdoc lints. Listed explicitly rather than -D warnings so a new lint appearing upstream cannot red the build on a class nobody has triaged. --document-private-items is load-bearing rather than thoroughness for its own sake: most of this workspace's doc-comments live on private items and //! module headers, so without it rustdoc checks a small fraction of the links and the gate sits green while the rot continues. Then fixes every error it reports, 40 to 0 across nine crates. The classes differ and so do the fixes: - public item, wrong scope -> qualify. Node and Node::parent are both public; the link failed only because scheduler.rs does not import Node. Six sites become [`crate::Node::parent`]. - private item -> downgrade to backticks. Nothing was made public to satisfy a lint; changing API surface to appease a doc check would be the tail wagging the dog. - genuinely dead -> [`JobBuilder::insert_into`] names a method that does not exist. Insertion is Scheduler::insert_job. - prose that looks like markup -> argv[0] parsed as a link, and <args>/<hex>/<name> parsed as HTML tags. Note for future fixes: pub(crate) resolves in an intra-doc link, a plain private fn in a binary crate does not (wait_for_nodes resolved, connect_hint did not, same crate, same shape). The check does not ride the clippy/test artifact cache. It takes cargoArtifacts, but rustdoc needs its own flavour of dependency metadata, which cargo build does not produce, so a --no-deps docs build still compiles dependencies it never documents. Measured at 6m47s cold; that reasoning is recorded in the check's own comment so the next reader does not re-derive it. Verified by running the check's exact command against the pre-cleanup tree first: 40 errors, build failed. A gate that cannot fail is not evidence, and building it before the cleanup makes that proof free.
212 lines
7.9 KiB
Rust
212 lines
7.9 KiB
Rust
//! Named counting-semaphore resources — the [`Dep::Resource`] side of the model.
|
|
//!
|
|
//! A [`ResourceTable`] tracks a set of named counters. Each name has a
|
|
//! *capacity* (how many units exist) and a *held* count (how many are currently
|
|
//! in use); a name that was never configured is assumed to have capacity 1 and
|
|
//! is created lazily on first use — mara's "unknown resources assumed
|
|
//! available-once". Typical names: `build-slot` (capacity = number of build
|
|
//! slots), `agent/<name>` (capacity 1 — the per-agent lifecycle lock).
|
|
//!
|
|
//! The one operation that matters is `ResourceTable::try_acquire_all`: it
|
|
//! takes *all* of a node's resource requests and either grants every one or
|
|
//! grants none, touching nothing on failure. Because a node acquires all its
|
|
//! resources atomically at start (never holds one while waiting for another),
|
|
//! there is no hold-and-wait and therefore no deadlock and no cycle detection.
|
|
//!
|
|
//! This table is runtime scheduler state, not persisted: on restart the held
|
|
//! counts are rederived from the graph's currently-running nodes, which are the
|
|
//! source of truth. Guard objects (RAII release, recursive re-entrancy within a
|
|
//! group) wrap this table in a follow-up; here it is the raw counter machinery.
|
|
//!
|
|
//! [`Dep::Resource`]: crate::Dep::Resource
|
|
|
|
use std::collections::HashMap;
|
|
use std::hash::Hash;
|
|
|
|
/// A set of named counting semaphores.
|
|
///
|
|
/// Configure known capacities with [`ResourceTable::set_capacity`]; any name
|
|
/// left unconfigured has the default capacity (1). Acquire and release move
|
|
/// units atomically via `ResourceTable::try_acquire_all` /
|
|
/// `ResourceTable::release_all`.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ResourceTable<R> {
|
|
/// Configured capacities, keyed by resource. Missing ⇒ `default_capacity`.
|
|
capacities: HashMap<R, u32>,
|
|
/// Units currently held, keyed by resource. Missing ⇒ 0.
|
|
held: HashMap<R, u32>,
|
|
/// Capacity assumed for a resource with no configured entry.
|
|
default_capacity: u32,
|
|
}
|
|
|
|
impl<R: Clone + Eq + Hash> Default for ResourceTable<R> {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl<R: Clone + Eq + Hash> ResourceTable<R> {
|
|
/// An empty table whose unconfigured names default to capacity 1.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self {
|
|
capacities: HashMap::new(),
|
|
held: HashMap::new(),
|
|
default_capacity: 1,
|
|
}
|
|
}
|
|
|
|
/// Configure the capacity of a named resource (e.g. `build-slot` = N).
|
|
///
|
|
/// 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: 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: &R) -> u32 {
|
|
self.capacities
|
|
.get(name)
|
|
.copied()
|
|
.unwrap_or(self.default_capacity)
|
|
}
|
|
|
|
/// Units of a name currently held (0 if none).
|
|
#[must_use]
|
|
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: &R) -> u32 {
|
|
self.capacity(name).saturating_sub(self.held(name))
|
|
}
|
|
|
|
/// Atomically acquire every requested `(name, count)` or none of them.
|
|
///
|
|
/// Duplicate names in `reqs` are summed, so `[(a, 1), (a, 1)]` asks for two
|
|
/// units of `a`. Returns `true` and records the units as held only if every
|
|
/// aggregated request fits in what is available *now*; otherwise returns
|
|
/// `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(crate) 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 {
|
|
if count > self.available(name) {
|
|
return false;
|
|
}
|
|
}
|
|
for (name, count) in wanted {
|
|
*self.held.entry(name.clone()).or_insert(0) += count;
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Release every requested `(name, count)`, returning the units to the pool.
|
|
///
|
|
/// Duplicate names are summed. Releasing more than is held saturates at zero
|
|
/// rather than underflowing, so a double release is harmless.
|
|
pub(crate) 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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sum a request list into per-name totals so duplicate names are one entry.
|
|
fn aggregate<R: Eq + Hash>(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;
|
|
}
|
|
wanted
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn res(name: &str) -> String {
|
|
name.to_owned()
|
|
}
|
|
|
|
#[test]
|
|
fn unconfigured_name_defaults_to_capacity_one() {
|
|
let t = ResourceTable::new();
|
|
let a = res("agent/foo");
|
|
assert_eq!(t.capacity(&a), 1);
|
|
assert_eq!(t.available(&a), 1);
|
|
assert_eq!(t.held(&a), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn set_capacity_overrides_default() {
|
|
let mut t = ResourceTable::new();
|
|
let slot = res("build-slot");
|
|
t.set_capacity(slot.clone(), 3);
|
|
assert_eq!(t.capacity(&slot), 3);
|
|
assert_eq!(t.available(&slot), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn acquire_all_or_nothing_leaves_table_untouched_on_failure() {
|
|
let mut t = ResourceTable::new();
|
|
let slot = res("build-slot");
|
|
let agent = res("agent/foo");
|
|
t.set_capacity(slot.clone(), 2);
|
|
// First node grabs both slots and the agent lock — fits exactly.
|
|
assert!(t.try_acquire_all(&[(slot.clone(), 2), (agent.clone(), 1)]));
|
|
assert_eq!(t.held(&slot), 2);
|
|
assert_eq!(t.held(&agent), 1);
|
|
// Second node wants a slot (none free) + the agent lock (held): must
|
|
// fail AND must not have grabbed the agent lock on the way.
|
|
assert!(!t.try_acquire_all(&[(slot.clone(), 1), (agent.clone(), 1)]));
|
|
assert_eq!(t.held(&slot), 2);
|
|
assert_eq!(t.held(&agent), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn release_returns_units_to_the_pool() {
|
|
let mut t = ResourceTable::new();
|
|
let slot = res("build-slot");
|
|
t.set_capacity(slot.clone(), 2);
|
|
assert!(t.try_acquire_all(&[(slot.clone(), 2)]));
|
|
assert_eq!(t.available(&slot), 0);
|
|
t.release_all(&[(slot.clone(), 2)]);
|
|
assert_eq!(t.available(&slot), 2);
|
|
// Over-release saturates, never underflows.
|
|
t.release_all(&[(slot.clone(), 5)]);
|
|
assert_eq!(t.held(&slot), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn request_over_capacity_never_acquires() {
|
|
let mut t = ResourceTable::new();
|
|
let slot = res("build-slot");
|
|
t.set_capacity(slot.clone(), 2);
|
|
assert!(!t.try_acquire_all(&[(slot.clone(), 3)]));
|
|
assert_eq!(t.held(&slot), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_names_in_one_request_are_summed() {
|
|
let mut t = ResourceTable::new();
|
|
let a = res("thing");
|
|
// Default capacity 1: two units in one request cannot both fit.
|
|
assert!(!t.try_acquire_all(&[(a.clone(), 1), (a.clone(), 1)]));
|
|
assert_eq!(t.held(&a), 0);
|
|
// Raise capacity to 2 and the same aggregated request fits.
|
|
t.set_capacity(a.clone(), 2);
|
|
assert!(t.try_acquire_all(&[(a.clone(), 1), (a.clone(), 1)]));
|
|
assert_eq!(t.held(&a), 2);
|
|
}
|
|
}
|