Skip to main content

bacnet_server/server/
request_admission.rs

1//! Local fail-fast handler policy, not a protocol or throughput guarantee.
2use std::collections::HashMap;
3use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
4use std::sync::{Arc, Mutex};
5
6use super::request_peer::CanonicalRequester;
7
8use bacnet_types::error::Error;
9use bacnet_types::map_hash::BuildMapHasher;
10use tokio::sync::Semaphore;
11
12#[path = "recovery_classifier.rs"]
13mod recovery_classifier;
14#[cfg(test)]
15#[path = "request_admission_slot_tests.rs"]
16mod slot_tests;
17pub(super) use recovery_classifier::confirmed_class;
18
19/// Global and logical-peer top-level handler limits with a strict recovery reserve.
20/// Defaults are provisional owner policy, not benchmark-derived values.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub struct RequestAdmissionPolicy {
23    /// Maximum concurrent confirmed handlers (default 64).
24    pub max_confirmed_in_flight: usize,
25    /// Maximum concurrent unconfirmed handlers (default 32).
26    pub max_unconfirmed_in_flight: usize,
27    /// Maximum concurrent ordinary confirmed handlers per logical peer (default 16).
28    /// Clamped to global capacity minus the reserve; independent of recovery.
29    pub max_confirmed_in_flight_per_peer: usize,
30    /// Maximum concurrent unconfirmed handlers per logical peer (default 8).
31    /// This partitions capacity by identity; it does not guarantee fairness.
32    pub max_unconfirmed_in_flight_per_peer: usize,
33    /// Strict DCC ENABLE partition inside the confirmed global limit (default 4).
34    /// Zero disables protection; otherwise must be smaller than the global limit.
35    pub confirmed_recovery_reserve: usize,
36    /// Independent protected per-peer limit (default 1), always positive.
37    /// Clamped only to the reserve; zero reserve uses ordinary accounting instead.
38    pub max_recovery_in_flight_per_peer: usize,
39}
40
41impl Default for RequestAdmissionPolicy {
42    fn default() -> Self {
43        Self {
44            max_confirmed_in_flight: 64,
45            max_unconfirmed_in_flight: 32,
46            max_confirmed_in_flight_per_peer: 16,
47            max_unconfirmed_in_flight_per_peer: 8,
48            confirmed_recovery_reserve: 4,
49            max_recovery_in_flight_per_peer: 1,
50        }
51    }
52}
53
54impl RequestAdmissionPolicy {
55    /// Reject zero and limits above tokio's `Semaphore::MAX_PERMITS`, the
56    /// bound these limits have always had.
57    pub fn validate(&self) -> Result<(), Error> {
58        for (name, value) in [
59            ("max_confirmed_in_flight", self.max_confirmed_in_flight),
60            ("max_unconfirmed_in_flight", self.max_unconfirmed_in_flight),
61            (
62                "max_recovery_in_flight_per_peer",
63                self.max_recovery_in_flight_per_peer,
64            ),
65            (
66                "max_confirmed_in_flight_per_peer",
67                self.max_confirmed_in_flight_per_peer,
68            ),
69            (
70                "max_unconfirmed_in_flight_per_peer",
71                self.max_unconfirmed_in_flight_per_peer,
72            ),
73        ] {
74            if value == 0 || value > Semaphore::MAX_PERMITS {
75                return Err(Error::Encoding(format!(
76                    "{name} must be in 1..={}",
77                    Semaphore::MAX_PERMITS
78                )));
79            }
80        }
81        if self.confirmed_recovery_reserve >= self.max_confirmed_in_flight {
82            return Err(Error::Encoding(
83                "confirmed_recovery_reserve must be smaller than max_confirmed_in_flight".into(),
84            ));
85        }
86        Ok(())
87    }
88}
89
90/// Independently sampled counters for one server lifetime, not an atomic aggregate.
91/// Admitted totals count registrations, not successful responses. Active counts
92/// include registered futures not yet polled and exclude completed unreaped tasks.
93#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
94pub struct RequestAdmissionCounters {
95    /// Live protected handlers, also included in confirmed_active.
96    pub recovery_active: usize,
97    /// Protected registrations, also included in confirmed_admitted_total.
98    pub recovery_admitted_total: u64,
99    /// Protected capacity rejections, also included in confirmed_overloaded_total.
100    pub recovery_overloaded_total: u64,
101    /// Live confirmed handlers.
102    pub confirmed_active: usize,
103    /// Confirmed handlers registered since startup.
104    pub confirmed_admitted_total: u64,
105    /// Confirmed requests rejected for exhausted handler capacity.
106    pub confirmed_overloaded_total: u64,
107    /// Confirmed capacity rejections classified partition-first (ordinary or protected).
108    pub confirmed_global_overloaded_total: u64,
109    /// Confirmed rejections with a partition permit but no slot in that peer partition.
110    pub confirmed_peer_overloaded_total: u64,
111    /// Confirmed registrations rejected after shutdown sealed the owner.
112    pub confirmed_shutdown_rejected_total: u64,
113    /// Live unconfirmed handlers.
114    pub unconfirmed_active: usize,
115    /// Unconfirmed handlers registered since startup.
116    pub unconfirmed_admitted_total: u64,
117    /// Unconfirmed requests dropped for exhausted handler capacity.
118    pub unconfirmed_overloaded_total: u64,
119    /// Unconfirmed capacity rejections classified global-first.
120    pub unconfirmed_global_overloaded_total: u64,
121    /// Unconfirmed capacity rejections with a global permit but no peer slot.
122    pub unconfirmed_peer_overloaded_total: u64,
123    /// Unconfirmed registrations rejected after shutdown sealed the owner.
124    pub unconfirmed_shutdown_rejected_total: u64,
125    /// Live overload Abort send workers (private fixed capacity 8).
126    pub abort_active: usize,
127    /// Overload Abort workers registered, not necessarily successfully sent.
128    pub abort_admitted_total: u64,
129    /// Confirmed overloads silently dropped because all Abort workers were busy.
130    pub confirmed_fallback_dropped_total: u64,
131    /// Abort registrations rejected after shutdown sealed the owner.
132    pub abort_shutdown_rejected_total: u64,
133}
134
135#[derive(Clone, Copy)]
136pub(super) enum Class {
137    Confirmed = 0,
138    Unconfirmed = 1,
139    Abort = 2,
140    Recovery = 3,
141}
142
143#[derive(Debug, PartialEq, Eq)]
144pub(super) enum Rejection {
145    Closed,
146    Overloaded,
147}
148
149pub(super) struct Admission {
150    pools: [Arc<Pool>; 3],
151    recovery: Arc<Pool>,
152    recovery_enabled: bool,
153}
154
155/// One class's capacity. `held` counts the handlers that hold a slot here;
156/// [`Admission::try_enter`] takes one with a compare-and-swap, so a full
157/// pool rejects at once and nothing ever waits for a slot. A semaphore did
158/// this before (#1713): its release took a lock on every request.
159struct Pool {
160    limit: usize,
161    held: AtomicUsize,
162    peer_limit: usize,
163    // Holds only requests running here, so the pool's limit bounds it and
164    // the cheap hasher is safe (#1713; see bacnet_types::map_hash).
165    peers: Mutex<HashMap<CanonicalRequester, usize, BuildMapHasher>>,
166    active: AtomicUsize,
167    admitted: AtomicU64,
168    overloaded: AtomicU64,
169    global_overloaded: AtomicU64,
170    peer_overloaded: AtomicU64,
171    closed: AtomicU64,
172}
173
174impl Pool {
175    fn new(limit: usize, peer_limit: usize) -> Arc<Self> {
176        Arc::new(Self {
177            limit,
178            held: AtomicUsize::new(0),
179            peer_limit,
180            peers: Mutex::new(HashMap::default()),
181            active: AtomicUsize::new(0),
182            admitted: AtomicU64::new(0),
183            overloaded: AtomicU64::new(0),
184            global_overloaded: AtomicU64::new(0),
185            peer_overloaded: AtomicU64::new(0),
186            closed: AtomicU64::new(0),
187        })
188    }
189
190    /// Take a slot unless all `limit` are held.
191    fn take_slot(&self) -> bool {
192        let mut held = self.held.load(Ordering::Relaxed);
193        while held < self.limit {
194            match self.held.compare_exchange_weak(
195                held,
196                held + 1,
197                Ordering::Acquire,
198                Ordering::Relaxed,
199            ) {
200                Ok(_) => return true,
201                Err(now) => held = now,
202            }
203        }
204        false
205    }
206
207    #[cfg(test)]
208    fn free_slots(&self) -> usize {
209        self.limit - self.held.load(Ordering::Relaxed)
210    }
211}
212
213pub(super) struct Guard {
214    pool: Arc<Pool>,
215    // Counters only: recovery never registers in the ordinary peer map.
216    aggregate: Option<Arc<Pool>>,
217    peer: Option<CanonicalRequester>,
218}
219
220impl Drop for Guard {
221    fn drop(&mut self) {
222        // Remove the peer registration and the active counts first; the
223        // `held.fetch_sub` at the end then releases the pool slot. This lock
224        // never acquires the task owner's lock.
225        if let Some(peer) = &self.peer {
226            let mut peers = self.pool.peers.lock().unwrap_or_else(|e| e.into_inner());
227            let count = peers.get_mut(peer).expect("live peer registration");
228            *count -= 1;
229            if *count == 0 {
230                peers.remove(peer);
231            }
232        }
233        for pool in std::iter::once(&self.pool).chain(self.aggregate.iter()) {
234            pool.active.fetch_sub(1, Ordering::Relaxed);
235        }
236        self.pool.held.fetch_sub(1, Ordering::Release);
237    }
238}
239
240impl Admission {
241    #[cfg(test)]
242    pub(super) fn peer_entries(&self) -> [usize; 3] {
243        let mut entries = std::array::from_fn(|i| self.pools[i].peers.lock().unwrap().len());
244        // Count entries in both confirmed maps, not distinct confirmed identities.
245        entries[0] += self.recovery.peers.lock().unwrap().len();
246        entries
247    }
248
249    pub(super) fn new(policy: RequestAdmissionPolicy) -> Result<Self, Error> {
250        policy.validate()?;
251        Ok(Self {
252            pools: [
253                Pool::new(
254                    policy.max_confirmed_in_flight - policy.confirmed_recovery_reserve,
255                    policy
256                        .max_confirmed_in_flight_per_peer
257                        .min(policy.max_confirmed_in_flight - policy.confirmed_recovery_reserve),
258                ),
259                Pool::new(
260                    policy.max_unconfirmed_in_flight,
261                    policy
262                        .max_unconfirmed_in_flight_per_peer
263                        .min(policy.max_unconfirmed_in_flight),
264                ),
265                Pool::new(8, 8),
266            ],
267            recovery: Pool::new(
268                policy.confirmed_recovery_reserve,
269                policy
270                    .max_recovery_in_flight_per_peer
271                    .min(policy.confirmed_recovery_reserve),
272            ),
273            recovery_enabled: policy.confirmed_recovery_reserve != 0,
274        })
275    }
276
277    // Called under the task owner's spawn/close lock. No capacity waiters exist.
278    pub(super) fn try_enter(
279        &self,
280        class: Class,
281        peer: CanonicalRequester,
282        closed: bool,
283    ) -> Result<Guard, Rejection> {
284        let (pool, aggregate) = if matches!(class, Class::Recovery) {
285            if self.recovery_enabled {
286                (&self.recovery, Some(&self.pools[0]))
287            } else {
288                (&self.pools[0], None)
289            }
290        } else {
291            (&self.pools[class as usize], None)
292        };
293        if closed {
294            pool.closed.fetch_add(1, Ordering::Relaxed);
295            if let Some(total) = aggregate {
296                total.closed.fetch_add(1, Ordering::Relaxed);
297            }
298            return Err(Rejection::Closed);
299        }
300        if !pool.take_slot() {
301            pool.overloaded.fetch_add(1, Ordering::Relaxed);
302            pool.global_overloaded.fetch_add(1, Ordering::Relaxed);
303            if let Some(total) = aggregate {
304                total.overloaded.fetch_add(1, Ordering::Relaxed);
305                total.global_overloaded.fetch_add(1, Ordering::Relaxed);
306            }
307            return Err(Rejection::Overloaded);
308        }
309        // Spawn serialization belongs to RequestTasks. Peer registrations are
310        // partition-local, while confirmed counters remain inclusive. Only one
311        // map is locked, so no cross-partition registration or rollback is needed.
312        // Abort workers are global-only, with no peer registration or charge.
313        let peer = if matches!(class, Class::Abort) {
314            None
315        } else {
316            let mut peers = pool.peers.lock().unwrap_or_else(|e| e.into_inner());
317            // One lookup for the check and the registration.
318            let count = peers.entry(peer.clone()).or_insert(0);
319            if *count >= pool.peer_limit {
320                // Only a zero limit refuses a new entry; leave none behind.
321                if *count == 0 {
322                    peers.remove(&peer);
323                }
324                pool.overloaded.fetch_add(1, Ordering::Relaxed);
325                pool.peer_overloaded.fetch_add(1, Ordering::Relaxed);
326                if let Some(total) = aggregate {
327                    total.overloaded.fetch_add(1, Ordering::Relaxed);
328                    total.peer_overloaded.fetch_add(1, Ordering::Relaxed);
329                }
330                pool.held.fetch_sub(1, Ordering::Release);
331                return Err(Rejection::Overloaded);
332            }
333            *count += 1;
334            Some(peer)
335        };
336        pool.active.fetch_add(1, Ordering::Relaxed);
337        pool.admitted.fetch_add(1, Ordering::Relaxed);
338        if let Some(total) = aggregate {
339            total.active.fetch_add(1, Ordering::Relaxed);
340            total.admitted.fetch_add(1, Ordering::Relaxed);
341        }
342        Ok(Guard {
343            pool: Arc::clone(pool),
344            aggregate: aggregate.map(Arc::clone),
345            peer,
346        })
347    }
348
349    pub(super) fn snapshot(&self) -> RequestAdmissionCounters {
350        let [c, u, a] = &self.pools;
351        RequestAdmissionCounters {
352            recovery_active: self.recovery.active.load(Ordering::Relaxed),
353            recovery_admitted_total: self.recovery.admitted.load(Ordering::Relaxed),
354            recovery_overloaded_total: self.recovery.overloaded.load(Ordering::Relaxed),
355            confirmed_active: c.active.load(Ordering::Relaxed),
356            confirmed_admitted_total: c.admitted.load(Ordering::Relaxed),
357            confirmed_overloaded_total: c.overloaded.load(Ordering::Relaxed),
358            confirmed_global_overloaded_total: c.global_overloaded.load(Ordering::Relaxed),
359            confirmed_peer_overloaded_total: c.peer_overloaded.load(Ordering::Relaxed),
360            confirmed_shutdown_rejected_total: c.closed.load(Ordering::Relaxed),
361            unconfirmed_active: u.active.load(Ordering::Relaxed),
362            unconfirmed_admitted_total: u.admitted.load(Ordering::Relaxed),
363            unconfirmed_overloaded_total: u.overloaded.load(Ordering::Relaxed),
364            unconfirmed_global_overloaded_total: u.global_overloaded.load(Ordering::Relaxed),
365            unconfirmed_peer_overloaded_total: u.peer_overloaded.load(Ordering::Relaxed),
366            unconfirmed_shutdown_rejected_total: u.closed.load(Ordering::Relaxed),
367            abort_active: a.active.load(Ordering::Relaxed),
368            abort_admitted_total: a.admitted.load(Ordering::Relaxed),
369            confirmed_fallback_dropped_total: a.overloaded.load(Ordering::Relaxed),
370            abort_shutdown_rejected_total: a.closed.load(Ordering::Relaxed),
371        }
372    }
373}
374
375impl<T: super::TransportPort + 'static> super::ServerBuilder<T> {
376    /// Set positive global and peer handler limits, validated before startup.
377    pub fn request_admission_policy(mut self, policy: RequestAdmissionPolicy) -> Self {
378        self.config.request_admission_policy = policy;
379        self
380    }
381}
382
383impl super::BipServerBuilder {
384    /// Set positive global and peer handler limits, validated before startup.
385    pub fn request_admission_policy(mut self, policy: RequestAdmissionPolicy) -> Self {
386        self.config.request_admission_policy = policy;
387        self
388    }
389}
390
391#[cfg(feature = "sc-tls")]
392impl super::ScServerBuilder {
393    /// Set positive global and peer handler limits, validated before TLS dialing.
394    pub fn request_admission_policy(mut self, policy: RequestAdmissionPolicy) -> Self {
395        self.config.request_admission_policy = policy;
396        self
397    }
398}
399
400impl<T: super::TransportPort + 'static> super::BACnetServer<T> {
401    /// Sample admission counters. Available after stop; active counts then are zero.
402    pub fn request_admission_counters(&self) -> RequestAdmissionCounters {
403        self.request_tasks.counters()
404    }
405}