Skip to content

Commit a2a2d34

Browse files
committed
test: pin counter drift, restructure at-cap bench, trim docstring
Three review follow-ups on #1718: **Bench actually measures eviction (Copilot inline at L43).** ``test_cache_add_at_cap_evicts`` previously created an empty cache and added ``_MAX_CACHE_RECORDS + 1000`` records, so ~91% of the measured time was the below-cap fill path and only the final 1000 inserts evicted — the CodSpeed number was not representative of the eviction performance the name implies. Restructure to pre-fill the cache to the cap *outside* the timed body and consume one fresh unique record per benchmark iteration. Each iteration measures exactly one ``_async_add`` + ``_async_evict_oldest`` cycle, with the cache permanently at the cap. ``_make_records`` gains a ``prefix=`` parameter so the fill records and the overflow pool don't collide on ``name``. **Defensive test pins counter state (Kōan suggestion #3).** ``test_cache_eviction_empty_heap_returns_without_evicting`` already asserts the new record lands; add the corresponding ``_total_records == _MAX_CACHE_RECORDS + 1`` assertion so a future change to the broken-state recovery (refuse the add, clamp the counter) fails this test explicitly rather than silently shifting behaviour. **One-line docstring (Kōan suggestion #2).** ``_maybe_rebuild_heap`` had an 11-line docstring restating the body and including amortized-cost analysis. Per CLAUDE.md the why lives at the call site comment in ``_async_add`` (already does); the function's contract is a single line. **No log on the empty-heap fall-through (Kōan suggestion #1).** Kōan suggested an assertion or debug log; the assertion would crash production on invariant drift, and even a guarded ``log.debug(...)`` adds Python attribute-lookup overhead in the function the cap overflow path runs through. Drift is already caught loudly by ``test_cache_total_records_invariant_under_mixed_ops``. Comment explains the deliberate silence.
1 parent 052b1e2 commit a2a2d34

3 files changed

Lines changed: 26 additions & 22 deletions

File tree

src/zeroconf/_cache.py

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -135,21 +135,12 @@ def _async_evict_oldest(self) -> None:
135135
continue
136136
self._async_remove(record)
137137
return
138+
# Heap-empty fall-through is unreachable by the cache invariant
139+
# (every counted record has a heap entry); accounting drift would
140+
# surface in tests via test_cache_total_records_invariant_under_mixed_ops.
138141

139142
def _maybe_rebuild_heap(self) -> None:
140-
"""Rebuild ``_expire_heap`` if stale entries dominate.
141-
142-
Re-adds of an existing record with a new TTL append a fresh
143-
``(when, record)`` and leave the prior tuple behind as stale;
144-
eviction's stale-skip loop and ``async_expire`` already absorb
145-
these, but unchecked accumulation lets a peer that just replays
146-
cached records grow the heap arbitrarily between cleanups.
147-
Same threshold as the long-standing rebuild in ``async_expire``:
148-
only fire when stale entries outweigh live ones (heap > 2 x
149-
expirations), and only above a minimum floor so a small cache
150-
isn't rebuilt for nothing. Amortized cost is O(1) per push;
151-
the O(N) rebuild fires at most once per N stale pushes.
152-
"""
143+
"""Rebuild ``_expire_heap`` when stale entries dominate live ones."""
153144
expire_heap_len = len(self._expire_heap)
154145
if (
155146
expire_heap_len > _MIN_SCHEDULED_RECORD_EXPIRATION

tests/benchmarks/test_cache_bound.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@
88
from zeroconf.const import _CLASS_IN, _MAX_CACHE_RECORDS, _TYPE_A
99

1010

11-
def _make_records(count: int, now: float) -> list[DNSAddress]:
11+
def _make_records(count: int, now: float, prefix: str = "bench") -> list[DNSAddress]:
1212
return [
1313
DNSAddress(
14-
f"bench-{i}.local.",
14+
f"{prefix}-{i}.local.",
1515
_TYPE_A,
1616
_CLASS_IN,
1717
120,
18-
bytes((i & 0xFF, (i >> 8) & 0xFF, 0, 1)),
18+
bytes(((i >> 24) & 0xFF, (i >> 16) & 0xFF, (i >> 8) & 0xFF, i & 0xFF)),
1919
created=now + i,
2020
)
2121
for i in range(count)
@@ -34,11 +34,20 @@ def _add() -> None:
3434

3535

3636
def test_cache_add_at_cap_evicts(benchmark: BenchmarkFixture) -> None:
37-
"""Steady-state add at the cap: every new record forces one eviction."""
37+
"""Steady-state add at the cap: every measured insert forces one eviction.
38+
39+
Pre-fills the cache to ``_MAX_CACHE_RECORDS`` outside the timed body so
40+
only the eviction-path adds are measured. Each benchmark iteration
41+
consumes one fresh unique record from a pre-built pool, keeping the
42+
cache permanently at the cap and the work per iteration to a single
43+
``_async_add`` + ``_async_evict_oldest`` cycle.
44+
"""
3845
now = current_time_millis()
39-
overflow_records = _make_records(_MAX_CACHE_RECORDS + 1000, now)
46+
cache = DNSCache()
47+
cache.async_add_records(_make_records(_MAX_CACHE_RECORDS, now, prefix="fill"))
48+
# Large pool so the iterator outlives any reasonable codspeed run count.
49+
pool = iter(_make_records(100_000, now + _MAX_CACHE_RECORDS, prefix="evict"))
4050

4151
@benchmark
42-
def _flood() -> None:
43-
cache = DNSCache()
44-
cache.async_add_records(overflow_records)
52+
def _evict_one() -> None:
53+
cache.async_add_records([next(pool)])

tests/test_cache.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,11 +525,15 @@ def test_cache_eviction_empty_heap_returns_without_evicting() -> None:
525525
# By the cache invariant every record in ``_total_records`` has a heap
526526
# entry, so eviction should never see an empty heap. Force the broken
527527
# state directly to pin the defensive behaviour: ``_async_evict_oldest``
528-
# returns without raising and the subsequent insert still lands.
528+
# returns without raising and the subsequent insert still lands. Since
529+
# eviction can't free space, the counter is allowed to drift past the
530+
# cap by exactly one — pinned so a future change to the recovery
531+
# semantics (e.g., refusing the add or clamping) fails this test.
529532
cache._total_records = const._MAX_CACHE_RECORDS
530533
cache._expire_heap = []
531534
cache.async_add_records([_addr("post-empty.local.", 0)])
532535
assert "post-empty.local." in cache.cache
536+
assert cache._total_records == const._MAX_CACHE_RECORDS + 1
533537

534538

535539
def test_cache_eviction_skips_stale_heap_entries() -> None:

0 commit comments

Comments
 (0)