Skip to main content

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}