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