Skip to main content

veilid_tools/async_locks/
async_weighted_semaphore.rs

1//! AsyncWeightedSemaphore
2//!
3//! A semaphore whose capacity (the "limit") can be changed at any time, and
4//! whose permits carry a weight rather than counting as one. Acquisition waits
5//! until the in-flight weight plus the requested weight fits under the current
6//! limit. Unlike a fixed-permit semaphore, the limit can be lowered while
7//! permits are held: new acquisitions wait until enough weight drains.
8//!
9//! Used to bound total in-flight bytes across all concurrent DHT operations
10//! against an adaptively-estimated uplink budget.
11use super::*;
12
13use core::sync::atomic::{AtomicU64, Ordering};
14use event_listener::Event;
15
16/// A semaphore admitting weighted permits under a runtime-adjustable capacity limit.
17///
18/// Acquisition waits until the in-flight weight plus the requested weight fits under
19/// the current limit. The limit can be raised or lowered while permits are held; a
20/// single permit whose weight exceeds the limit is admitted when nothing else is in
21/// flight, so it cannot deadlock.
22#[derive(Debug)]
23pub struct AsyncWeightedSemaphore {
24    /// Total weight currently held by live permits
25    in_flight: AtomicU64,
26    /// Current capacity; acquisitions wait until in_flight + weight <= limit
27    limit: AtomicU64,
28    /// Wakes waiters when weight is released or the limit is raised
29    event: Event,
30}
31
32impl AsyncWeightedSemaphore {
33    /// Create a semaphore with the given initial capacity limit.
34    #[must_use]
35    pub fn new(limit: u64) -> Arc<Self> {
36        Arc::new(Self {
37            in_flight: AtomicU64::new(0),
38            limit: AtomicU64::new(limit),
39            event: Event::new(),
40        })
41    }
42
43    /// Current capacity limit
44    pub fn limit(&self) -> u64 {
45        self.limit.load(Ordering::Acquire)
46    }
47
48    /// Total weight currently held by live permits
49    pub fn in_flight(&self) -> u64 {
50        self.in_flight.load(Ordering::Acquire)
51    }
52
53    /// Change the capacity limit. Raising it wakes waiters to re-check; lowering
54    /// it applies backpressure, waiting acquisitions until in-flight weight drains.
55    pub fn set_limit(&self, new_limit: u64) {
56        let old = self.limit.swap(new_limit, Ordering::AcqRel);
57        if new_limit > old {
58            self.event.notify(usize::MAX);
59        }
60    }
61
62    /// Raise the limit to at least `candidate`, never lowering it; wakes waiters if raised.
63    /// Returns the previous limit so callers can detect/log an actual raise.
64    ///
65    /// Monotonic: a `candidate` at or below the current limit is a no-op.
66    pub fn raise_limit(&self, candidate: u64) -> u64 {
67        let prev = self.limit.fetch_max(candidate, Ordering::AcqRel);
68        if candidate > prev {
69            self.event.notify(usize::MAX);
70        }
71        prev
72    }
73
74    /// Try to acquire `weight` units without waiting.
75    ///
76    /// Never blocks. On success the guard holds the weight until dropped. A zero-weight
77    /// request always succeeds and accounts nothing.
78    #[must_use]
79    pub fn try_acquire(self: &Arc<Self>, weight: u64) -> Option<AsyncWeightedSemaphoreGuard> {
80        // Zero-weight permits always succeed and account nothing
81        if weight == 0 {
82            return Some(AsyncWeightedSemaphoreGuard {
83                sem: self.clone(),
84                weight: 0,
85            });
86        }
87        loop {
88            let cur = self.in_flight.load(Ordering::Acquire);
89            let lim = self.limit.load(Ordering::Acquire);
90            // Progress guarantee: admit one operation when nothing is in flight even
91            // if its weight exceeds the limit, so a single large op can't deadlock.
92            if cur != 0 && cur.saturating_add(weight) > lim {
93                return None;
94            }
95            if self
96                .in_flight
97                .compare_exchange_weak(
98                    cur,
99                    cur.saturating_add(weight),
100                    Ordering::AcqRel,
101                    Ordering::Acquire,
102                )
103                .is_ok()
104            {
105                return Some(AsyncWeightedSemaphoreGuard {
106                    sem: self.clone(),
107                    weight,
108                });
109            }
110            // CAS lost a race; retry
111        }
112    }
113
114    /// Acquire `weight` units, waiting until they fit under the current limit.
115    ///
116    /// Blocks until enough weight drains (or the limit is raised). The returned guard holds
117    /// the weight until dropped. No deadlock timeout: an over-limit request is admitted only
118    /// when nothing else is in flight.
119    #[must_use]
120    pub async fn acquire(self: &Arc<Self>, weight: u64) -> AsyncWeightedSemaphoreGuard {
121        loop {
122            if let Some(g) = self.try_acquire(weight) {
123                return g;
124            }
125            // Register before re-checking so a release/raise between the failed try
126            // and the await is not missed.
127            let listener = self.event.listen();
128            if let Some(g) = self.try_acquire(weight) {
129                return g;
130            }
131            listener.await;
132        }
133    }
134
135    fn release(&self, weight: u64) {
136        if weight == 0 {
137            return;
138        }
139        self.in_flight.fetch_sub(weight, Ordering::AcqRel);
140        self.event.notify(usize::MAX);
141    }
142}
143
144/// A guard holding `weight` units; releases them back to the semaphore on drop.
145#[clippy::has_significant_drop]
146#[derive(Debug)]
147pub struct AsyncWeightedSemaphoreGuard {
148    sem: Arc<AsyncWeightedSemaphore>,
149    weight: u64,
150}
151
152impl Drop for AsyncWeightedSemaphoreGuard {
153    fn drop(&mut self) {
154        self.sem.release(self.weight);
155    }
156}