Skip to main content

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}