bacnet_server/cov/policy.rs
1//! COV policies, canonical-recipient accounting, and telemetry counters.
2
3use super::CovRecipient;
4
5use std::collections::HashMap;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, Mutex};
8
9use bacnet_types::MacAddr;
10use bacnet_types::constructed::BACnetAddress;
11use bacnet_types::error::Error;
12use tokio::sync::{OwnedSemaphorePermit, Semaphore};
13
14/// Configuration policy for COV subscriptions and notification work budgets.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct CovPolicy {
17 /// Global maximum number of active COV subscriptions across all peers.
18 pub max_subscriptions_global: usize,
19 /// Maximum number of active COV subscriptions allowed for a single peer.
20 pub max_subscriptions_per_peer: usize,
21 /// Number of subscription slots reserved for reserved peers.
22 pub reserved_capacity: usize,
23 /// List of MAC addresses of direct peers permitted to use reserved subscription capacity.
24 pub reserved_peers: Vec<MacAddr>,
25 /// List of canonical peer keys permitted to use reserved subscription capacity.
26 pub reserved_recipients: Vec<CovRecipient>,
27 /// Whether indefinite (infinite lifetime) subscriptions are permitted.
28 pub allow_indefinite_subscriptions: bool,
29 /// Maximum number of indefinite subscriptions allowed for a single peer.
30 pub max_indefinite_per_peer: usize,
31 /// Maximum notifications emitted per single COV event (fanout budget).
32 pub max_notifications_per_event: usize,
33 /// Maximum bytes across all notifications emitted per single COV event.
34 pub max_notification_bytes_per_event: usize,
35 /// Maximum in-flight confirmed COV notifications per peer.
36 pub max_confirmed_in_flight_per_peer: usize,
37}
38
39impl Default for CovPolicy {
40 fn default() -> Self {
41 Self {
42 max_subscriptions_global: 1024,
43 max_subscriptions_per_peer: 64,
44 reserved_capacity: 64,
45 reserved_peers: Vec::new(),
46 reserved_recipients: Vec::new(),
47 allow_indefinite_subscriptions: true,
48 max_indefinite_per_peer: 16,
49 max_notifications_per_event: 64,
50 max_notification_bytes_per_event: 65_536,
51 max_confirmed_in_flight_per_peer: 16,
52 }
53 }
54}
55
56impl CovPolicy {
57 /// Return an unlimited policy with maximum quotas and no restrictions.
58 pub fn unlimited() -> Self {
59 Self {
60 max_subscriptions_global: usize::MAX,
61 max_subscriptions_per_peer: usize::MAX,
62 reserved_capacity: 0,
63 reserved_peers: Vec::new(),
64 reserved_recipients: Vec::new(),
65 allow_indefinite_subscriptions: true,
66 max_indefinite_per_peer: usize::MAX,
67 max_notifications_per_event: usize::MAX,
68 max_notification_bytes_per_event: usize::MAX,
69 max_confirmed_in_flight_per_peer: usize::MAX,
70 }
71 }
72
73 /// Reject a policy that would refuse all COV work or reserve capacity for
74 /// a peer that can never be matched. The server runs this before it
75 /// starts a transport.
76 ///
77 /// The subscription caps, the per-event notification and byte budgets
78 /// and the confirmed in-flight limit must be positive. `reserved_capacity`
79 /// and `max_indefinite_per_peer` may be zero, and a value larger than the
80 /// cap it shares is clamped by [`sanitized`](Self::sanitized), not
81 /// refused. Reserved entries follow the DCC source restriction rule: a
82 /// MAC of 1 to [`BACnetAddress::MAX_MAC_LEN`] (18) octets, and for a
83 /// routed recipient a network in 1..=65534. The network layer drops any
84 /// other source, direct or routed (#1141, #1198), so such an entry could
85 /// never match a subscriber (#1199).
86 pub fn validate(&self) -> Result<(), Error> {
87 for (name, value) in [
88 ("max_subscriptions_global", self.max_subscriptions_global),
89 (
90 "max_subscriptions_per_peer",
91 self.max_subscriptions_per_peer,
92 ),
93 (
94 "max_notifications_per_event",
95 self.max_notifications_per_event,
96 ),
97 (
98 "max_notification_bytes_per_event",
99 self.max_notification_bytes_per_event,
100 ),
101 (
102 "max_confirmed_in_flight_per_peer",
103 self.max_confirmed_in_flight_per_peer,
104 ),
105 ] {
106 if value == 0 {
107 return Err(Error::Encoding(format!(
108 "COV policy {name} must be positive"
109 )));
110 }
111 }
112 let mac_length = |field: &str, mac: &MacAddr| {
113 if (1..=BACnetAddress::MAX_MAC_LEN).contains(&mac.len()) {
114 Ok(())
115 } else {
116 Err(Error::Encoding(format!(
117 "COV policy {field} entries need a MAC of 1..={} octets",
118 BACnetAddress::MAX_MAC_LEN
119 )))
120 }
121 };
122 for mac in &self.reserved_peers {
123 mac_length("reserved_peers", mac)?;
124 }
125 for recipient in &self.reserved_recipients {
126 match recipient {
127 CovRecipient::Direct(mac) => mac_length("reserved_recipients", mac)?,
128 CovRecipient::Routed(source) => {
129 if !(1..=65534).contains(&source.network) {
130 return Err(Error::Encoding(
131 "COV policy reserved_recipients networks must be 1..=65534".into(),
132 ));
133 }
134 mac_length("reserved_recipients", &source.mac_address)?;
135 }
136 }
137 }
138 Ok(())
139 }
140
141 /// Return a sanitized copy with valid bounds.
142 pub fn sanitized(&self) -> Self {
143 let mut policy = self.clone();
144 policy.reserved_capacity = policy
145 .reserved_capacity
146 .min(policy.max_subscriptions_global);
147 policy.max_indefinite_per_peer = policy
148 .max_indefinite_per_peer
149 .min(policy.max_subscriptions_per_peer);
150 policy
151 }
152
153 /// Check if a peer is in the reserved peers list.
154 pub fn is_peer_reserved(&self, peer: &CovRecipient) -> bool {
155 if self.reserved_recipients.contains(peer) {
156 return true;
157 }
158 match peer {
159 CovRecipient::Direct(mac) => self.reserved_peers.contains(mac),
160 CovRecipient::Routed(_) => false,
161 }
162 }
163
164 /// Return the effective unreserved capacity available to unreserved peers.
165 ///
166 /// When neither `reserved_peers` nor `reserved_recipients` is configured or
167 /// `reserved_capacity` is 0, no capacity is set aside, and the entire global
168 /// capacity is available to unreserved peers.
169 pub fn effective_unreserved_capacity(&self) -> usize {
170 if (self.reserved_peers.is_empty() && self.reserved_recipients.is_empty())
171 || self.reserved_capacity == 0
172 {
173 self.max_subscriptions_global
174 } else {
175 self.max_subscriptions_global
176 .saturating_sub(self.reserved_capacity)
177 }
178 }
179}
180
181/// Telemetry counters for COV operations.
182#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
183pub struct CovCounters {
184 /// Number of currently active subscriptions.
185 pub subscriptions_active: u64,
186 /// Total number of subscriptions created.
187 pub subscriptions_created: u64,
188 /// Total number of subscriptions rejected due to per-peer quota.
189 pub subscriptions_rejected_quota: u64,
190 /// Total number of subscriptions rejected due to global or reserved capacity.
191 pub subscriptions_rejected_capacity: u64,
192 /// Total number of subscriptions rejected due to indefinite policy or quota.
193 pub subscriptions_rejected_indefinite: u64,
194 /// Total number of subscriptions explicitly cancelled.
195 pub subscriptions_cancelled: u64,
196 /// Total number of expired subscriptions purged.
197 pub subscriptions_purged: u64,
198 /// Total number of COV notifications sent (confirmed + unconfirmed).
199 pub notifications_sent: u64,
200 /// Total number of confirmed COV notifications sent.
201 pub notifications_confirmed: u64,
202 /// Total number of unconfirmed COV notifications sent.
203 pub notifications_unconfirmed: u64,
204 /// Total number of notification payload bytes sent.
205 pub notification_bytes_sent: u64,
206 /// Total number of notifications throttled due to per-event fanout or byte budget.
207 pub notifications_throttled_fanout: u64,
208 /// Total number of notifications throttled due to peer in-flight confirmed limit.
209 pub notifications_throttled_peer: u64,
210 /// Total number of pending timestamped COV-multiple changes discarded while
211 /// their reference stayed subscribed: evicted on overflow of the
212 /// per-context history bound, superseded by a newer delivered change when
213 /// an older notification failed, or, counted once per change, losing a
214 /// value too large for any notification to the subscriber even alone (the
215 /// rest of such a change still goes out). Changes split across several
216 /// notifications, one value per notification included, are not counted.
217 /// Only the first drop of each cause in a context logs a warning until the
218 /// context is admitted afresh, so this count is the running signal, for
219 /// instance for a subscriber whose maximum APDU cannot hold one timestamped
220 /// value.
221 pub timed_changes_dropped: u64,
222 /// Total number of times a COV-multiple report left out an untimestamped
223 /// reference because its values alone exceed one notification to the
224 /// subscriber. The value is not queued: the reference is evaluated again
225 /// at its next fanout, and each report that leaves it out counts once.
226 pub untimed_references_oversized: u64,
227}
228
229/// Atomic storage for COV telemetry counters.
230#[derive(Debug, Default)]
231pub struct AtomicCovCounters {
232 /// Number of currently active subscriptions.
233 pub subscriptions_active: AtomicU64,
234 /// Total number of subscriptions created.
235 pub subscriptions_created: AtomicU64,
236 /// Total number of subscriptions rejected due to per-peer quota.
237 pub subscriptions_rejected_quota: AtomicU64,
238 /// Total number of subscriptions rejected due to global or reserved capacity.
239 pub subscriptions_rejected_capacity: AtomicU64,
240 /// Total number of subscriptions rejected due to indefinite policy or quota.
241 pub subscriptions_rejected_indefinite: AtomicU64,
242 /// Total number of subscriptions explicitly cancelled.
243 pub subscriptions_cancelled: AtomicU64,
244 /// Total number of expired subscriptions purged.
245 pub subscriptions_purged: AtomicU64,
246 /// Total number of COV notifications sent (confirmed + unconfirmed).
247 pub notifications_sent: AtomicU64,
248 /// Total number of confirmed COV notifications sent.
249 pub notifications_confirmed: AtomicU64,
250 /// Total number of unconfirmed COV notifications sent.
251 pub notifications_unconfirmed: AtomicU64,
252 /// Total number of notification payload bytes sent.
253 pub notification_bytes_sent: AtomicU64,
254 /// Total number of notifications throttled due to per-event fanout or byte budget.
255 pub notifications_throttled_fanout: AtomicU64,
256 /// Total number of notifications throttled due to peer in-flight confirmed limit.
257 pub notifications_throttled_peer: AtomicU64,
258 /// Total number of pending timestamped COV-multiple changes discarded while
259 /// their reference stayed subscribed: evicted on overflow of the
260 /// per-context history bound, superseded by a newer delivered change when
261 /// an older notification failed, or, counted once per change, losing a
262 /// value too large for any notification to the subscriber even alone (the
263 /// rest of such a change still goes out). Changes split across several
264 /// notifications, one value per notification included, are not counted.
265 /// Only the first drop of each cause in a context logs a warning until the
266 /// context is admitted afresh, so this count is the running signal, for
267 /// instance for a subscriber whose maximum APDU cannot hold one timestamped
268 /// value.
269 pub timed_changes_dropped: AtomicU64,
270 /// Total number of times a COV-multiple report left out an untimestamped
271 /// reference because its values alone exceed one notification to the
272 /// subscriber. The value is not queued: the reference is evaluated again
273 /// at its next fanout, and each report that leaves it out counts once.
274 pub untimed_references_oversized: AtomicU64,
275}
276
277impl AtomicCovCounters {
278 /// Return an instantaneous snapshot of all counters.
279 pub fn snapshot(&self) -> CovCounters {
280 CovCounters {
281 subscriptions_active: self.subscriptions_active.load(Ordering::Relaxed),
282 subscriptions_created: self.subscriptions_created.load(Ordering::Relaxed),
283 subscriptions_rejected_quota: self.subscriptions_rejected_quota.load(Ordering::Relaxed),
284 subscriptions_rejected_capacity: self
285 .subscriptions_rejected_capacity
286 .load(Ordering::Relaxed),
287 subscriptions_rejected_indefinite: self
288 .subscriptions_rejected_indefinite
289 .load(Ordering::Relaxed),
290 subscriptions_cancelled: self.subscriptions_cancelled.load(Ordering::Relaxed),
291 subscriptions_purged: self.subscriptions_purged.load(Ordering::Relaxed),
292 notifications_sent: self.notifications_sent.load(Ordering::Relaxed),
293 notifications_confirmed: self.notifications_confirmed.load(Ordering::Relaxed),
294 notifications_unconfirmed: self.notifications_unconfirmed.load(Ordering::Relaxed),
295 notification_bytes_sent: self.notification_bytes_sent.load(Ordering::Relaxed),
296 notifications_throttled_fanout: self
297 .notifications_throttled_fanout
298 .load(Ordering::Relaxed),
299 notifications_throttled_peer: self.notifications_throttled_peer.load(Ordering::Relaxed),
300 timed_changes_dropped: self.timed_changes_dropped.load(Ordering::Relaxed),
301 untimed_references_oversized: self.untimed_references_oversized.load(Ordering::Relaxed),
302 }
303 }
304}
305
306/// Error returned when acquiring an in-flight confirmed notification slot fails.
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308pub enum InFlightAcquireError {
309 /// In-flight limit for this peer was exceeded.
310 PeerLimitExceeded,
311 /// Global pool of confirmed notification permits was exhausted.
312 GlobalPoolExhausted,
313}
314
315/// Tracker for concurrent in-flight confirmed notifications per peer.
316#[derive(Debug, Default)]
317pub struct CovInFlightTracker {
318 peer_in_flight: Mutex<HashMap<CovRecipient, usize>>,
319}
320
321/// RAII permit for an in-flight confirmed notification holding both a peer slot and a global permit.
322pub struct CovInFlightGuard {
323 peer: CovRecipient,
324 tracker: Arc<CovInFlightTracker>,
325 _global_permit: OwnedSemaphorePermit,
326}
327
328impl std::fmt::Debug for CovInFlightGuard {
329 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330 f.debug_struct("CovInFlightGuard")
331 .field("peer", &self.peer)
332 .finish()
333 }
334}
335
336impl Drop for CovInFlightGuard {
337 fn drop(&mut self) {
338 self.tracker.release(&self.peer);
339 }
340}
341
342impl CovInFlightTracker {
343 /// Attempt to acquire an in-flight confirmed notification slot.
344 pub fn try_acquire(
345 self: &Arc<Self>,
346 peer: CovRecipient,
347 max_per_peer: usize,
348 global_semaphore: &Arc<Semaphore>,
349 ) -> Result<CovInFlightGuard, InFlightAcquireError> {
350 let mut guard = self.peer_in_flight.lock().unwrap();
351 let current = guard.get(&peer).copied().unwrap_or(0);
352 if current >= max_per_peer {
353 return Err(InFlightAcquireError::PeerLimitExceeded);
354 }
355 let global_permit = match global_semaphore.clone().try_acquire_owned() {
356 Ok(permit) => permit,
357 Err(_) => return Err(InFlightAcquireError::GlobalPoolExhausted),
358 };
359 guard.insert(peer.clone(), current + 1);
360 Ok(CovInFlightGuard {
361 peer,
362 tracker: Arc::clone(self),
363 _global_permit: global_permit,
364 })
365 }
366
367 #[cfg(test)]
368 pub(crate) fn active_peer_count(&self) -> usize {
369 self.peer_in_flight.lock().unwrap().len()
370 }
371
372 fn release(&self, peer: &CovRecipient) {
373 let mut guard = self.peer_in_flight.lock().unwrap();
374 if let Some(count) = guard.get_mut(peer) {
375 *count = count.saturating_sub(1);
376 if *count == 0 {
377 guard.remove(peer);
378 }
379 }
380 }
381}