Skip to main content

decdn_node/
dispatch.rs

1//! Per-source connection rate limiting for all deCDN QUIC protocol handlers.
2//!
3//! # Problem
4//!
5//! iroh's `Router` spawns one task per accepted QUIC connection with no
6//! concurrency cap. Its `incoming_filter` hook can refuse a connection before
7//! the handshake, but it carries no rate state of its own. A single attacker
8//! can exhaust the node's task capacity by flooding connections faster than
9//! the idle timeout reaps them.
10//!
11//! # Solution
12//!
13//! [`ConnectionLimiter`] enforces two independent layers at the top of every
14//! deCDN-authored `ProtocolHandler::accept` implementation:
15//!
16//! 1. **Global semaphore** — hard cap on total in-flight handler tasks.
17//! 2. **Per-source rate limit** — bounds the connection rate from any one
18//!    source. Today the source axis is the remote `IpAddr` (`/64`-grouped
19//!    for IPv6); the layer's name is intentionally generic so future
20//!    keying axes (e.g. `NodeID`, ASN) can land without renaming the
21//!    operator-visible config and metrics surface.
22//!
23//! Relay connections that have no resolvable direct IP at accept time
24//! bypass the per-source layer (no key to charge); the
25//! `dispatch_per_source_skipped_no_addr` counter records the bypass so
26//! operators chasing rejection anomalies can distinguish "layer didn't
27//! fire" from "layer wasn't applicable".
28//!
29//! The keyed rate limiter is provided by the [`governor`] crate. Hot-
30//! reload of the rate/burst quota rebuilds the limiter from scratch and
31//! swaps the whole `PerSource` in through an [`ArcSwap`] — the acquire
32//! read path is lock-free, matching the rest of the reload surface
33//! (`semaphore` is an [`ArcSwapOption`]; the reload-fed serve structures
34//! are `ArcSwap`/atomic). Token-bucket state is *not* preserved across a
35//! reload (operators changing live quotas should expect the next acquire
36//! from each source to start with a fresh burst budget).
37//!
38//! The keyspace prune (`retain_recent`, an `O(n)` walk over the keyed map)
39//! never runs on the accept path. An accept does only the constant-time
40//! bucket check plus an `O(1)` keyspace-length comparison; when the
41//! keyspace grows past `cap + cap/10` the accept offloads one single-
42//! flighted `retain_recent` sweep to a background task, so connection
43//! admission stays constant-time regardless of keyspace size.
44
45use std::net::{IpAddr, Ipv6Addr};
46use std::num::NonZeroU32;
47use std::sync::Arc;
48use std::sync::atomic::{AtomicBool, Ordering};
49use std::time::Duration;
50
51use arc_swap::{ArcSwap, ArcSwapOption};
52use governor::{DefaultKeyedRateLimiter, Quota};
53use iroh::endpoint::Connection;
54use tokio::sync::{OwnedSemaphorePermit, Semaphore};
55
56use crate::metrics::Metrics;
57use crate::prune_guard::PruneGuard;
58use crate::rate_limit::peer_ip;
59use decdn_common::config::ResolvedSecurity;
60
61/// Bucket key for the per-source rate limit. IPv4 addresses are used as-is;
62/// IPv6 addresses are masked to their `/64` prefix.
63///
64/// Without the mask the per-source layer is trivially defeated: a
65/// customer-grade IPv6 allocation is typically `/64` (or larger),
66/// giving an attacker `2^64` distinct `IpAddr`s inside one allocation.
67/// Each unique address would be a separate map key, both bypassing the
68/// rate limit and churning the eviction path so legitimate IPv4
69/// victims' buckets get flushed.
70///
71/// Shared with the DHT per-IP limiter (`dht::rate_limit`), which had the
72/// same bypass (#841).
73pub(crate) fn source_key(ip: IpAddr) -> IpAddr {
74    match ip {
75        IpAddr::V4(_) => ip,
76        IpAddr::V6(v6) => {
77            let mut octets = v6.octets();
78            for b in &mut octets[8..] {
79                *b = 0;
80            }
81            IpAddr::V6(Ipv6Addr::from(octets))
82        }
83    }
84}
85
86/// Reason a connection was rejected.
87///
88/// Metrics-wise this is a **sibling-counter** split, per the convention settled
89/// in #1475: each variant increments its own unlabeled counter
90/// (`decdn_dispatch_rejected_{global,per_source}_total`) rather than a `reason`
91/// label. `decdn_probe_hold_unavailable_total` is the one labeled *reason
92/// split* — see [`crate::metrics::ProbeHoldUnavailableReason`].
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum RejectReason {
95    /// Global concurrency semaphore exhausted.
96    GlobalFull,
97    /// Per-source rate limit exhausted.
98    PerSource,
99}
100
101impl RejectReason {
102    /// Short, stable label suitable for log fields and the QUIC close-frame
103    /// reason bytes. Peers receiving the close can disambiguate the layer
104    /// (and pick an appropriate backoff strategy) without parsing free text.
105    #[must_use]
106    pub const fn as_str(self) -> &'static str {
107        match self {
108            Self::GlobalFull => "global-full",
109            Self::PerSource => "per-source",
110        }
111    }
112}
113
114/// RAII permit that holds a semaphore slot for the lifetime of a handler task.
115///
116/// On the success path of [`ConnectionLimiter::acquire`] (`acquire_inner`),
117/// the limiter calls `Metrics::dispatch_permit_acquired()` immediately before
118/// constructing this struct; `Drop` calls `Metrics::dispatch_permit_released()`
119/// unconditionally. The two calls must remain paired: every successful
120/// construction is matched by exactly one drop, and the gauge stays balanced.
121///
122/// `_sem` is `Option` so the `max_concurrent_handlers = 0` disabled-cap mode
123/// can return a `Permit` without holding a semaphore slot — the in-flight
124/// gauge still tracks the handler, and `Drop` releases nothing (the
125/// `OwnedSemaphorePermit` is `None`).
126///
127/// `#[must_use]`: dropping the permit immediately (`let _ = limiter.acquire(&conn);`)
128/// would defeat the purpose of acquiring it — the slot is freed before the
129/// handler runs, so the cap fails to bound concurrency.
130#[must_use = "drop the Permit at end of handler scope; otherwise the in-flight slot is released immediately"]
131pub struct Permit {
132    _sem: Option<OwnedSemaphorePermit>,
133    metrics: Arc<Metrics>,
134}
135
136impl std::fmt::Debug for Permit {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        f.debug_struct("Permit").finish_non_exhaustive()
139    }
140}
141
142impl Drop for Permit {
143    fn drop(&mut self) {
144        self.metrics.dispatch_permit_released();
145    }
146}
147
148/// Per-source keyed rate limiter plus its operator-configured cap on the
149/// number of distinct keys to track.
150///
151/// `limiter` is `None` when the per-source layer is disabled
152/// (`per_source_rate_per_sec == 0.0`). `cap == 0` means "unbounded —
153/// don't prune".
154struct PerSource {
155    /// `None` when the layer is disabled. `Some` when enabled — the
156    /// keyed governor limiter holds one bucket per source key.
157    limiter: Option<Arc<DefaultKeyedRateLimiter<IpAddr>>>,
158    /// `0` = unbounded. Otherwise: prune via `retain_recent` whenever
159    /// `limiter.len() > cap` so the keyspace stays bounded.
160    cap: usize,
161}
162
163impl PerSource {
164    fn new(rate_per_sec: f64, burst: u32, cap: usize) -> Self {
165        Self {
166            limiter: build_keyed_limiter(rate_per_sec, burst),
167            cap,
168        }
169    }
170}
171
172/// Construct a keyed governor limiter from a (rate, burst) pair, or
173/// return `None` when the layer is disabled.
174///
175/// `rate <= 0.0` short-circuits to `None`. `burst == 0` would deny every
176/// request after the first burst-many — `resolve_security` rejects that
177/// combination at config time, so this function treats it as a soft-
178/// disable belt-and-braces (returns `None`) rather than panicking on a
179/// `NonZeroU32::new(0).unwrap()`.
180fn build_keyed_limiter(
181    rate_per_sec: f64,
182    burst: u32,
183) -> Option<Arc<DefaultKeyedRateLimiter<IpAddr>>> {
184    if rate_per_sec <= 0.0 || burst == 0 {
185        return None;
186    }
187    // Guard against pathological inputs (NaN, Inf) that would have
188    // already been rejected by `resolve_security` — the function is
189    // also called from tests that build `ResolvedSecurity` directly,
190    // so a defensive bail-out keeps the limiter sane without panicking.
191    if !rate_per_sec.is_finite() {
192        return None;
193    }
194    // governor's quota uses "1 cell per period" + burst capacity. The
195    // period is 1.0 / rate_per_sec seconds. Saturate at the smallest
196    // representable nanosecond period (1ns) so very large rates still
197    // yield a valid `Duration` rather than wrapping or rounding to 0.
198    let period_secs = 1.0_f64 / rate_per_sec;
199    // Convert to nanoseconds with `as` (precision loss is fine for a
200    // rate-limit period). Clamp to at least 1ns so `Duration::from_nanos(0)`
201    // never reaches `Quota::with_period`, which returns `None` on a
202    // zero-length interval.
203    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
204    let period_ns = (period_secs * 1_000_000_000.0).max(1.0) as u64;
205    let period = Duration::from_nanos(period_ns);
206    let burst_nz = NonZeroU32::new(burst)?;
207    let quota = Quota::with_period(period)?.allow_burst(burst_nz);
208    Some(Arc::new(DefaultKeyedRateLimiter::keyed(quota)))
209}
210
211/// Build an `Arc<Semaphore>` of `cap` permits, or `None` when the global
212/// cap is disabled (`cap == 0`). Used both at construction and on every
213/// reload — the `Arc` is swapped wholesale so already-held permits keep
214/// the previous semaphore alive until they drop.
215fn build_semaphore(cap: u32) -> Option<Arc<Semaphore>> {
216    if cap == 0 {
217        return None;
218    }
219    Some(Arc::new(Semaphore::new(
220        usize::try_from(cap).unwrap_or(usize::MAX),
221    )))
222}
223
224/// Shared rate limiter used by all deCDN-authored `ProtocolHandler` implementations.
225///
226/// `reload(&self, &ResolvedSecurity)` applies a new resolved-security
227/// snapshot in place. The per-source [`governor`] limiter is rebuilt
228/// from the new quota and swapped in wholesale through an internal
229/// [`ArcSwap`]. The
230/// global semaphore is replaced wholesale: a fresh `Arc<Semaphore>` of
231/// the new capacity is built and atomically swapped into the
232/// [`ArcSwapOption`] cell, so the next acquire hits the new semaphore.
233/// Already-acquired `OwnedSemaphorePermit`s hold a clone of the old
234/// `Arc<Semaphore>` and continue to drain into it on `Drop` — they're
235/// dropped harmlessly once the last permit goes away.
236///
237/// `semaphore = None` encodes "global cap disabled"
238/// (`max_concurrent_handlers == 0`) and the acquire path skips the
239/// semaphore entirely.
240#[allow(missing_debug_implementations)]
241pub struct ConnectionLimiter {
242    /// `None` when the layer is disabled (`max_concurrent_handlers ==
243    /// 0`), `Some(arc)` otherwise. Reload swaps the whole `Arc` in;
244    /// the acquire path loads it lock-free.
245    semaphore: ArcSwapOption<Semaphore>,
246    /// Per-source keyed limiter published through an [`ArcSwap`] so the
247    /// acquire read path is lock-free: it loads the current [`PerSource`]
248    /// and clones the inner limiter `Arc` out. Reload swaps a fresh
249    /// `Arc<PerSource>` in wholesale; in-flight acquires keep operating on
250    /// the generation they loaded, which is indistinguishable from
251    /// arriving microseconds before the swap.
252    per_source: ArcSwap<PerSource>,
253    /// Mirrors `per_source.limiter.is_some()`. Read on the relay-only-
254    /// connection fast path so we can record
255    /// `dispatch_per_source_skipped_no_addr` without loading the whole
256    /// `PerSource` on every relay accept. Written on reload.
257    per_source_enabled: AtomicBool,
258    /// Single-flight guard for `retain_recent` pruning. Without it, a
259    /// flood of distinct-source connections that all observe an
260    /// over-cap keyspace simultaneously would each dispatch an `O(n)`
261    /// walk over the keyed state — one per acquire thread. The flag
262    /// ensures at most one sweep is in flight at a time; concurrent
263    /// over-cap observers skip and the next observer after the sweep
264    /// completes dispatches any remaining work. Held in an `Arc` so the
265    /// background sweep task owns a `'static` handle to reset it.
266    pruning_in_progress: Arc<AtomicBool>,
267    metrics: Arc<Metrics>,
268}
269
270impl ConnectionLimiter {
271    /// Construct a limiter from the resolved security configuration.
272    pub fn new(cfg: &ResolvedSecurity, metrics: Arc<Metrics>) -> Self {
273        let semaphore = ArcSwapOption::from(build_semaphore(cfg.max_concurrent_handlers));
274        let per_source = PerSource::new(
275            cfg.per_source_rate_per_sec,
276            cfg.per_source_burst,
277            cfg.max_tracked_sources,
278        );
279        let per_source_enabled = AtomicBool::new(per_source.limiter.is_some());
280        Self {
281            semaphore,
282            per_source: ArcSwap::from_pointee(per_source),
283            per_source_enabled,
284            pruning_in_progress: Arc::new(AtomicBool::new(false)),
285            metrics,
286        }
287    }
288
289    /// Apply a new `ResolvedSecurity` to the live limiter.
290    ///
291    /// - Per-source: rebuild the keyed [`governor`] limiter from the new
292    ///   quota and atomically swap the whole `PerSource` into the
293    ///   [`ArcSwap`] cell. **Token-bucket state is not preserved across the
294    ///   swap.**
295    /// - Global semaphore: build a fresh `Arc<Semaphore>` (or `None`
296    ///   when `max_concurrent_handlers == 0`) and atomically swap it
297    ///   into the [`ArcSwapOption`] cell. Already-acquired permits hold
298    ///   a clone of the previous `Arc<Semaphore>` and drop it harmlessly
299    ///   on permit release; new acquires hit the new cell.
300    ///
301    /// Infallible. Caller (`RuntimeReloadState::reload`) has already
302    /// validated the values via `resolve_security`.
303    pub fn reload(&self, cfg: &ResolvedSecurity) {
304        // 1. Per-source: rebuild the limiter from the new quota and swap the
305        //    whole `Arc<PerSource>` into the `ArcSwap` cell. In-flight acquires
306        //    keep the generation they loaded until they drop it.
307        let new_per_source = PerSource::new(
308            cfg.per_source_rate_per_sec,
309            cfg.per_source_burst,
310            cfg.max_tracked_sources,
311        );
312        let per_source_enabled = new_per_source.limiter.is_some();
313        self.per_source.store(Arc::new(new_per_source));
314        // Mirror the enabled state for the relay-only fast path.
315        // Stored under Relaxed because there's no happens-before
316        // relationship to the limiter's own state — the worst case
317        // across a swap is one accept observing the prior generation,
318        // operationally indistinguishable from arriving microseconds
319        // earlier.
320        self.per_source_enabled
321            .store(per_source_enabled, Ordering::Relaxed);
322
323        // 2. Global semaphore: swap the whole `Arc<Semaphore>`. Any
324        //    in-flight `OwnedSemaphorePermit` holds a clone of the
325        //    previous `Arc` and drops it harmlessly when released.
326        self.semaphore
327            .store(build_semaphore(cfg.max_concurrent_handlers));
328    }
329
330    /// Try to acquire a permit for `conn`.
331    ///
332    /// On success returns a [`Permit`] that holds the semaphore slot and
333    /// updates the in-flight gauge. On failure returns the [`RejectReason`],
334    /// records the appropriate metric counter, and emits a `tracing::debug!`
335    /// breadcrumb with `reason`/`ip` so operators investigating a counter
336    /// spike have something to grep for.
337    ///
338    /// **Cross-layer charge invariant:** layers are checked in order
339    /// global → per-source. The first layer to reject short-circuits;
340    /// later layers never see the connection, so no token is wasted on
341    /// a connection we already decided to drop.
342    ///
343    /// This method is synchronous and completes in microseconds — it never
344    /// awaits I/O.
345    pub fn acquire(&self, conn: &Connection) -> Result<Permit, RejectReason> {
346        self.acquire_inner(peer_ip(conn))
347    }
348
349    /// Cross-module test hook with the same behavior as `acquire`,
350    /// minus the iroh `Connection` argument. Exposed as `pub` so unit
351    /// tests in other modules and integration tests under `tests/` can
352    /// drive the limiter directly. Non-test callers must not invoke this
353    /// — `Self::acquire` is the only supported entry point.
354    /// `#[doc(hidden)]` keeps it out of the rendered public API surface.
355    #[doc(hidden)]
356    pub fn acquire_for_test(&self, peer_ip: Option<IpAddr>) -> Result<Permit, RejectReason> {
357        self.acquire_inner(peer_ip)
358    }
359
360    /// Shared implementation behind [`Self::acquire`] and
361    /// [`Self::acquire_for_test`].
362    fn acquire_inner(&self, peer_ip: Option<IpAddr>) -> Result<Permit, RejectReason> {
363        // Every inbound connection passes here once, admitted or not, so this
364        // is where its arrival path is counted (ADR 001 § Node Discovery).
365        if peer_ip.is_some() {
366            self.metrics.inbound_connection_direct();
367        } else {
368            self.metrics.inbound_connection_relayed();
369        }
370
371        // Load the current semaphore. `None` means the global cap is
372        // administratively disabled — skip the layer entirely.
373        let sem_permit = match self.semaphore.load_full() {
374            Some(sem) => Some(sem.try_acquire_owned().map_err(|_| {
375                self.metrics.dispatch_rejected_global();
376                tracing::debug!(
377                    reason = RejectReason::GlobalFull.as_str(),
378                    ip = ?peer_ip,
379                    "dispatch rejected: global semaphore exhausted"
380                );
381                RejectReason::GlobalFull
382            })?),
383            None => None,
384        };
385
386        // Per-source layer. Relay-only connections (no IP) bypass the
387        // layer entirely — there's no source key to charge — and bump
388        // the skip counter when the layer is enabled so operators can
389        // distinguish "layer didn't fire" from "layer wasn't applicable".
390        match peer_ip {
391            Some(ip) => {
392                if let Some(reason) = self.check_per_source(ip) {
393                    self.metrics.dispatch_rejected_per_source();
394                    tracing::debug!(
395                        reason = reason.as_str(),
396                        %ip,
397                        "dispatch rejected: per-source rate limit exhausted"
398                    );
399                    return Err(reason);
400                }
401            }
402            None => {
403                if self.per_source_enabled.load(Ordering::Relaxed) {
404                    self.metrics.dispatch_per_source_skipped_no_addr();
405                }
406            }
407        }
408
409        // Increment the in-flight gauge before constructing the Permit
410        // so the "incremented on success" / "Drop decrements
411        // unconditionally" pair stays balanced even if the constructor
412        // is split or the assignment is reordered.
413        self.metrics.dispatch_permit_acquired();
414        Ok(Permit {
415            _sem: sem_permit,
416            metrics: Arc::clone(&self.metrics),
417        })
418    }
419
420    /// Run the per-source layer for a known peer IP. Returns
421    /// `Some(RejectReason::PerSource)` to reject, `None` to allow.
422    ///
423    /// Disabled-layer fast path: if the inner limiter is `None` the
424    /// layer is administratively disabled and the call short-circuits
425    /// to `None`. Otherwise: bucket the address (`/64` for IPv6) and
426    /// call `check_key`. The `O(n)` keyspace prune never runs here —
427    /// when the keyspace has grown past `cap + cap/10` the accept
428    /// offloads one single-flighted `retain_recent` sweep to a
429    /// background task (#1788) so admission stays constant-time.
430    fn check_per_source(&self, ip: IpAddr) -> Option<RejectReason> {
431        let key = source_key(ip);
432        // Load the current `PerSource` lock-free and clone the limiter `Arc`
433        // out. The clone keeps the observed `KeyedRateLimiter` alive across a
434        // concurrent reload swap — operating on the prior generation is
435        // indistinguishable from arriving microseconds earlier.
436        let per_source = self.per_source.load();
437        let limiter = per_source.limiter.clone()?;
438        let cap = per_source.cap;
439        let result = limiter.check_key(&key);
440        // Best-effort cap enforcement, kept OFF the accept path (#1788 item 2).
441        // The accept does only two constant-time reads here — `check_key` above
442        // and `len()` below — and, when the keyspace has grown past `cap + cap/10`,
443        // dispatches ONE single-flighted `retain_recent` sweep to a background
444        // task rather than walking the `O(n)` keyed map inline:
445        //
446        // 1. 10% slack: dispatch only past cap + cap/10 so a sustained
447        //    1-key-over-cap fluctuation under flood doesn't dispatch a sweep on
448        //    every accept.
449        // 2. Single-flight: a flood from N distinct sources that all observe
450        //    over-cap simultaneously would otherwise dispatch N concurrent
451        //    `O(n)` sweeps. The `pruning_in_progress` flag bounds it to one in
452        //    flight at a time; concurrent observers skip and the next over-cap
453        //    observer after the sweep completes dispatches any remaining work.
454        //
455        // The map can briefly exceed cap by more than 10% while a sweep is in
456        // flight; on completion governor's `retain_recent` brings it back to cap
457        // (it drops keys whose state is indistinguishable from fresh). `len()` is
458        // `O(1)` in the keyspace size; only the `retain_recent` walk it gates is
459        // load-dependent, which is why the walk — and only the walk — runs off
460        // the accept path.
461        if cap > 0 && limiter.len() > cap.saturating_add(cap / 10) {
462            self.dispatch_prune(limiter);
463        }
464        match result {
465            Ok(()) => None,
466            Err(_) => Some(RejectReason::PerSource),
467        }
468    }
469
470    /// Run one single-flighted `retain_recent` sweep off the accept path.
471    ///
472    /// Wins the `false -> true` CAS or returns immediately (another sweep is in
473    /// flight). The winner offloads the `O(n)` walk to a `spawn_blocking` task so
474    /// the accepting task never blocks on it; the [`PruneGuard`] resets the
475    /// single-flight flag when the walk finishes, even on panic. When no tokio
476    /// runtime is present — synchronous unit tests drive `acquire` directly — the
477    /// sweep runs inline, since there is no accept task to keep responsive.
478    fn dispatch_prune(&self, limiter: Arc<DefaultKeyedRateLimiter<IpAddr>>) {
479        if self
480            .pruning_in_progress
481            .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
482            .is_err()
483        {
484            return;
485        }
486        let flag = Arc::clone(&self.pruning_in_progress);
487        let sweep = move || {
488            // RAII reset on drop: a panic inside `retain_recent` (third-party
489            // `governor` code, or an allocation failure during the walk) must not
490            // leave the flag stuck `true`, or both this path and the periodic GC
491            // task would be permanently disabled for the process lifetime — the
492            // unbounded-keyspace pathology #440 prevents. See `PruneGuard`.
493            let _guard = PruneGuard(&flag);
494            limiter.retain_recent();
495        };
496        match tokio::runtime::Handle::try_current() {
497            Ok(handle) => {
498                handle.spawn_blocking(sweep);
499            }
500            Err(_) => sweep(),
501        }
502    }
503
504    /// Drop per-source buckets whose state has refilled to the fresh
505    /// baseline (#440). The acquire path dispatches an opportunistic
506    /// background sweep when the keyspace exceeds `cap + cap/10`, but a node
507    /// whose connection rate falls below the over-cap threshold can carry
508    /// millions of stale buckets indefinitely. The runtime spawns a
509    /// periodic task that calls this method to bound steady-state
510    /// memory regardless of acquire-driven activity.
511    ///
512    /// Returns `Some((before, after))` keyspace counts when a prune
513    /// actually ran, or `None` when the per-source layer is disabled or
514    /// the call lost the single-flight CAS to a concurrent prune. The
515    /// runtime's periodic task uses this to emit a debug breadcrumb
516    /// only on real sweeps — silence is meaningful.
517    ///
518    /// Single-flight with the acquire-path prune via the same
519    /// `pruning_in_progress` flag, with `PruneGuard` ensuring the flag
520    /// is released even if `retain_recent` panics — concurrent
521    /// observers (the periodic task and a flood-driven acquire)
522    /// coexist without spawning duplicate `O(n)` walks.
523    pub fn gc_per_source(&self) -> Option<(usize, usize)> {
524        // Load the current `PerSource` lock-free and clone the limiter `Arc`
525        // out before the `O(n)` walk, so the walk operates on a stable handle
526        // even across a concurrent reload swap.
527        let limiter = self.per_source.load().limiter.clone()?;
528        if self
529            .pruning_in_progress
530            .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
531            .is_ok()
532        {
533            let _guard = PruneGuard(&self.pruning_in_progress);
534            let before = limiter.len();
535            limiter.retain_recent();
536            let after = limiter.len();
537            Some((before, after))
538        } else {
539            None
540        }
541    }
542
543    /// Number of per-source buckets currently tracked. Returns `0` when
544    /// the layer is disabled. Used by the periodic GC task in tests and
545    /// by the runtime's debug-log breadcrumb so operators can correlate
546    /// keyspace size with the bookkeeping cap.
547    #[must_use]
548    pub fn per_source_tracked(&self) -> usize {
549        self.per_source
550            .load()
551            .limiter
552            .as_ref()
553            .map_or(0, |l| l.len())
554    }
555}
556
557#[cfg(test)]
558#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
559mod tests;