bacnet_router_core/table/claims.rs
1//! Learning: claims, convergence, flap detection and management writes.
2
3use core::fmt;
4
5use super::{Convergence, Pending, Route, Table, Timing, is_reserved};
6use crate::{Duration, Instant, Mac, PortId};
7
8/// A route that moves port again within this long of its last move counts
9/// toward the flap warning.
10const FLAP_WINDOW: Duration = Duration::from_secs(60);
11
12/// Moves within the window at which a route is reported as flapping.
13const FLAP_WARNING: u8 = 3;
14
15/// Why a learning claim changed nothing.
16///
17/// It is `#[non_exhaustive]`, so a new refusal isn't a breaking change: a
18/// `match` needs a wildcard arm.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[non_exhaustive]
21pub enum LearnRefusal {
22 /// Network 0 or 0xFFFF, which name no single network.
23 Reserved,
24 /// The network is directly attached, and learning never changes that.
25 Direct,
26 /// The network is new and the table is full.
27 Full,
28}
29
30impl fmt::Display for LearnRefusal {
31 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32 f.write_str(match self {
33 Self::Reserved => "network 0 and 0xFFFF name no single network",
34 Self::Direct => "the network is directly attached",
35 Self::Full => "the routing table is full",
36 })
37 }
38}
39
40impl core::error::Error for LearnRefusal {}
41
42/// What a learning claim did.
43///
44/// It is `#[non_exhaustive]`, so a new outcome isn't a breaking change: a
45/// `match` needs a wildcard arm.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47#[non_exhaustive]
48#[must_use]
49pub enum Learned {
50 /// Nothing, for the reason given.
51 Refused(LearnRefusal),
52 /// Hardened convergence holds the move until a second claim.
53 Held,
54 /// A new route, or a refresh through the same port.
55 Applied,
56 /// The route moved to another port.
57 Moved {
58 /// The port it was on.
59 from: PortId,
60 /// Moves within 60 s of the one before, this one included.
61 flaps: u8,
62 },
63}
64
65impl Learned {
66 /// Whether the route was installed or refreshed.
67 #[must_use]
68 pub const fn applied(self) -> bool {
69 matches!(self, Self::Applied | Self::Moved { .. })
70 }
71
72 /// Whether this move reached the flap warning.
73 #[must_use]
74 pub const fn flapping(self) -> bool {
75 matches!(self, Self::Moved { flaps, .. } if flaps >= FLAP_WARNING)
76 }
77}
78
79impl<const R: usize, const H: usize> Table<R, H> {
80 /// Apply a learning claim from an I-Am-Router or an Init-RT-Ack entry:
81 /// `network` is reachable through the router at `next_hop` on `port`.
82 ///
83 /// Under [`Convergence::Standard`] it is [`learn`](Self::learn), and any
84 /// pending claim for the network is dropped without counting. Under
85 /// [`Convergence::Hardened`], a claim that would move a learned route to
86 /// another port is held until a second claim for the same network and
87 /// port arrives within [`Timing::corroboration`], inclusive; the second
88 /// supplies the next hop. A claim for another port takes the pending
89 /// slot over, and one past the window starts it again. The held route
90 /// keeps forwarding as it was.
91 pub fn apply_claim(
92 &mut self,
93 network: u16,
94 port: PortId,
95 next_hop: Mac,
96 now: Instant,
97 timing: &Timing,
98 ) -> Learned {
99 // Only a stored route can have a pending claim.
100 let Ok(index) = self.find(network) else {
101 return self.learn(network, port, next_hop, now);
102 };
103 let pending = self.slots[index].pending.take();
104 if self.convergence == Convergence::Standard {
105 return self.learn(network, port, next_hop, now);
106 }
107 let counters = &mut self.counters;
108 let pending = match pending {
109 Some(pending)
110 if now.saturating_duration_since(pending.first_seen) > timing.corroboration =>
111 {
112 counters.pending_expired = counters.pending_expired.saturating_add(1);
113 None
114 }
115 live => live,
116 };
117 let route = self.slots[index].route;
118 if !route.direct && route.port != port {
119 if pending.is_some_and(|pending| pending.port == port) {
120 counters.corroborated_applied = counters.corroborated_applied.saturating_add(1);
121 } else {
122 self.slots[index].pending = Some(Pending {
123 port,
124 first_seen: now,
125 });
126 counters.pending_started = counters.pending_started.saturating_add(1);
127 return Learned::Held;
128 }
129 }
130 self.learn(network, port, next_hop, now)
131 }
132
133 /// Install or refresh a learned route at once, with flap detection.
134 ///
135 /// A move to another port sets `last_seen` and the move time to `now`,
136 /// clears `last_used`, any mark, the pending claim and the hold-down, and
137 /// counts the move toward the flap warning when it comes within 60 s of
138 /// the last. A new network or a refresh through the same port is
139 /// [`add_learned`](Self::add_learned).
140 pub fn learn(&mut self, network: u16, port: PortId, next_hop: Mac, now: Instant) -> Learned {
141 if is_reserved(network) {
142 return Learned::Refused(LearnRefusal::Reserved);
143 }
144 if let Some(existing) = self.lookup(network) {
145 if existing.direct {
146 return Learned::Refused(LearnRefusal::Direct);
147 }
148 if existing.port != port {
149 let flaps = match existing.last_port_change {
150 Some(changed) if now.saturating_duration_since(changed) < FLAP_WINDOW => {
151 existing.flap_count.saturating_add(1)
152 }
153 _ => 1,
154 };
155 if flaps >= FLAP_WARNING {
156 self.counters.flap_warned = self.counters.flap_warned.saturating_add(1);
157 }
158 let mut route = Route::learned(port, next_hop, now);
159 route.flap_count = flaps;
160 route.last_port_change = Some(now);
161 self.holds.forget(network);
162 self.put(network, route);
163 return Learned::Moved {
164 from: existing.port,
165 flaps,
166 };
167 }
168 }
169 // Reserved and direct are ruled out above, so only room is left.
170 if self.add_learned(network, port, next_hop, now) {
171 Learned::Applied
172 } else {
173 Learned::Refused(LearnRefusal::Full)
174 }
175 }
176
177 /// Drop every pending claim older than [`Timing::corroboration`] at
178 /// `now`, calling `expired` with each network in ascending order, and
179 /// return how many. Each counts as expired. A claim is dropped and
180 /// counted before its call, so an `expired` that panics leaves the table
181 /// consistent.
182 pub fn expire_pending(
183 &mut self,
184 now: Instant,
185 timing: &Timing,
186 mut expired: impl FnMut(u16),
187 ) -> usize {
188 let window = timing.corroboration;
189 let mut count = 0;
190 for slot in &mut self.slots[..self.len] {
191 if slot
192 .pending
193 .is_some_and(|pending| now.saturating_duration_since(pending.first_seen) > window)
194 {
195 slot.pending = None;
196 self.counters.pending_expired = self.counters.pending_expired.saturating_add(1);
197 expired(slot.network);
198 count += 1;
199 }
200 }
201 count
202 }
203
204 /// The claim pending for `network` under hardened convergence.
205 #[must_use]
206 pub fn pending(&self, network: u16) -> Option<Pending> {
207 let index = self.find(network).ok()?;
208 self.slots[index].pending
209 }
210
211 /// How many claims are pending: at most one per learned route.
212 #[must_use]
213 pub fn pending_count(&self) -> usize {
214 self.stored()
215 .iter()
216 .filter(|slot| slot.pending.is_some())
217 .count()
218 }
219
220 /// Apply one Initialize-Routing-Table entry with a nonzero Port ID:
221 /// install `network` as a learned route through `next_hop` on `port`,
222 /// replacing any learned route it had or adding a new one, at once and
223 /// without corroboration (Clause 6.4.7). A direct route, network 0 and
224 /// 0xFFFF are left alone. `false` when nothing was installed.
225 pub fn apply_management_update(
226 &mut self,
227 network: u16,
228 port: PortId,
229 next_hop: Mac,
230 now: Instant,
231 ) -> bool {
232 self.add_learned(network, port, next_hop, now)
233 }
234
235 /// Apply one Initialize-Routing-Table entry with Port ID 0: delete the
236 /// network's learned route. Direct routes stay. `true` only when a route
237 /// was removed.
238 pub fn apply_management_removal(&mut self, network: u16) -> bool {
239 match self.lookup(network) {
240 Some(route) if !route.direct => self.remove(network).is_some(),
241 _ => false,
242 }
243 }
244
245 // The router engine counts these three itself. They are the crate's
246 // own, so a caller with `Router::table_mut` can't skew the counts.
247
248 /// Count a learning claim the router applied.
249 pub(crate) fn record_learned(&mut self) {
250 self.counters.learned_ok = self.counters.learned_ok.saturating_add(1);
251 }
252
253 /// Count a learning claim that met the router's route cap.
254 pub(crate) fn record_learning_cap(&mut self) {
255 self.counters.learned_cap_ignored = self.counters.learned_cap_ignored.saturating_add(1);
256 }
257
258 /// Count a Disconnect-Connection removal the router ignored.
259 pub(crate) fn record_disconnect_removal_ignored(&mut self) {
260 self.counters.disconnect_removal_ignored =
261 self.counters.disconnect_removal_ignored.saturating_add(1);
262 }
263}