At the cap, Hold() swept the whole dictionary before rejecting each new
distinct address. Entries are held 15 minutes, so during a sustained
distinct-source flood -- the case the cap exists for -- almost every one
of those sweeps freed nothing and the next address swept again. _warnedFull
suppressed the log, not the work.
Measured on the same loop shape (Dictionary<UInt128,long>, iterate, Remove
in place, Release, best of 20):
entries 0% lapsed 50% 100%
1,024 0.001ms 0.003 0.005
8,192 0.006ms 0.060 0.095
65,536 0.112ms 0.735 1.053
262,144 0.558ms 4.116 5.233
At the shipped 65,536 cap that is 0.11ms per rejected address: 11ms/sec of
wasted loop at 100 new addresses/sec, 110ms/sec at 1000. The bound became a
cost multiplier under exactly the load it bounds.
A sweep that just ran cannot have freed more, so cap-triggered sweeps are
now throttled to one a second, taking the worst case to 0.11ms/sec. The
periodic sweep is untouched and was never the problem: bounded by the cap,
allocation-free, early-outs when empty, ~1ms once a minute at worst.
Also holds and stops the sweep timer. AutoDenylistFilter.Start registered a
recurring tokenless Timer.DelayCall while Stop() was empty, so a Stop/Start
cycle left the old sweep running and added another -- the same bug just
fixed in BlocklistFilter.
The new test fails without the throttle (5 sweeps instead of 1), verified
by disabling it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>