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;