3737 DNSText ,
3838)
3939from ._utils .time import current_time_millis
40- from .const import _ONE_SECOND , _TYPE_PTR
40+ from .const import _MAX_CACHE_RECORDS , _ONE_SECOND , _TYPE_PTR
4141
4242_UNIQUE_RECORD_TYPES = (DNSAddress , DNSHinfo , DNSPointer , DNSText , DNSService )
4343_UniqueRecordsType = DNSAddress | DNSHinfo | DNSPointer | DNSText | DNSService
@@ -72,6 +72,7 @@ def __init__(self) -> None:
7272 self ._expire_heap : list [tuple [float , DNSRecord ]] = []
7373 self ._expirations : dict [DNSRecord , float ] = {}
7474 self .service_cache : _DNSRecordCacheType = {}
75+ self ._total_records : int = 0
7576
7677 # Functions prefixed with async_ are NOT threadsafe and must
7778 # be run in the event loop.
@@ -89,15 +90,34 @@ def _async_add(self, record: _DNSRecord) -> bool:
8990 # replaces any existing records that are __eq__ to each other which
9091 # removes the risk that accessing the cache from the wrong
9192 # direction would return the old incorrect entry.
92- if (store := self .cache .get (record .key )) is None :
93+ store = self .cache .get (record .key )
94+ is_new = store is None or record not in store
95+ # Bound total cache size; evict closest-to-expiration entry to
96+ # make room before inserting a new record. Prevents a LAN-local
97+ # flood of unique-name records from growing the cache without
98+ # bound (RFC 6762 §10 advisory caching, defense-in-depth).
99+ if is_new and self ._total_records >= _MAX_CACHE_RECORDS :
100+ self ._async_evict_oldest ()
101+ # The victim may have been the last record under
102+ # ``record.key``, in which case ``_remove_key`` deleted
103+ # the bucket. Re-fetch before creating below.
104+ store = self .cache .get (record .key )
105+ if store is None :
93106 store = self .cache [record .key ] = {}
94- new = record not in store and not isinstance (record , DNSNsec )
107+ new = is_new and not isinstance (record , DNSNsec )
108+ if is_new :
109+ self ._total_records += 1
95110 store [record ] = record
96111 when = record .created + (record .ttl * 1000 )
97112 if self ._expirations .get (record ) != when :
98- # Avoid adding duplicates to the heap
99113 heappush (self ._expire_heap , (when , record ))
100114 self ._expirations [record ] = when
115+ # Re-adds of an existing record with a new TTL push a fresh
116+ # entry but leave the prior tuple behind as stale, so a peer
117+ # that just replays cached records can grow ``_expire_heap``
118+ # without ever tripping the cap. Rebuild when stale entries
119+ # dominate.
120+ self ._maybe_rebuild_heap ()
101121
102122 if isinstance (record , DNSService ):
103123 service_record = record
@@ -106,6 +126,28 @@ def _async_add(self, record: _DNSRecord) -> bool:
106126 service_store [service_record ] = service_record
107127 return new
108128
129+ def _async_evict_oldest (self ) -> None :
130+ """Drop the closest-to-expiration record to make room for a new one."""
131+ while self ._expire_heap :
132+ when_record = heappop (self ._expire_heap )
133+ record = when_record [1 ]
134+ if self ._expirations .get (record ) != when_record [0 ]:
135+ continue
136+ self ._async_remove (record )
137+ return
138+
139+ def _maybe_rebuild_heap (self ) -> None :
140+ """Rebuild ``_expire_heap`` when stale entries dominate live ones."""
141+ expire_heap_len = len (self ._expire_heap )
142+ if (
143+ expire_heap_len > _MIN_SCHEDULED_RECORD_EXPIRATION
144+ and expire_heap_len > len (self ._expirations ) * 2
145+ ):
146+ self ._expire_heap = [
147+ entry for entry in self ._expire_heap if self ._expirations .get (entry [1 ]) == entry [0 ]
148+ ]
149+ heapify (self ._expire_heap )
150+
109151 def async_add_records (self , entries : Iterable [DNSRecord ]) -> bool :
110152 """Add multiple records.
111153
@@ -129,6 +171,7 @@ def _async_remove(self, record: _DNSRecord) -> None:
129171 _remove_key (self .service_cache , service_record .server_key , service_record )
130172 _remove_key (self .cache , record .key , record )
131173 self ._expirations .pop (record , None )
174+ self ._total_records -= 1
132175
133176 def async_remove_records (self , entries : Iterable [DNSRecord ]) -> None :
134177 """Remove multiple records.
@@ -145,43 +188,23 @@ def async_expire(self, now: _float) -> list[DNSRecord]:
145188
146189 :param now: The current time in milliseconds.
147190 """
148- if not ( expire_heap_len := len ( self ._expire_heap )) :
191+ if not self ._expire_heap :
149192 return []
150193
151194 expired : list [DNSRecord ] = []
152- # Find any expired records and add them to the to-delete list
153195 while self ._expire_heap :
154196 when_record = self ._expire_heap [0 ]
155197 when = when_record [0 ]
156198 if when > now :
157199 break
158200 heappop (self ._expire_heap )
159- # Check if the record hasn't been re-added to the heap
160- # with a different expiration time as it will be removed
161- # later when it reaches the top of the heap and its
162- # expiration time is met.
201+ # Skip entries left behind by a TTL re-add; the live tuple is
202+ # later in the heap and will be removed when it reaches the top.
163203 record = when_record [1 ]
164204 if self ._expirations .get (record ) == when :
165205 expired .append (record )
166206
167- # If the expiration heap grows larger than the number expirations
168- # times two, we clean it up to avoid keeping expired entries in
169- # the heap and consuming memory. We guard this with a minimum
170- # threshold to avoid cleaning up the heap too often when there are
171- # only a few scheduled expirations.
172- if (
173- expire_heap_len > _MIN_SCHEDULED_RECORD_EXPIRATION
174- and expire_heap_len > len (self ._expirations ) * 2
175- ):
176- # Remove any expired entries from the expiration heap
177- # that do not match the expiration time in the expirations
178- # as it means the record has been re-added to the heap
179- # with a different expiration time.
180- self ._expire_heap = [
181- entry for entry in self ._expire_heap if self ._expirations .get (entry [1 ]) == entry [0 ]
182- ]
183- heapify (self ._expire_heap )
184-
207+ self ._maybe_rebuild_heap ()
185208 self .async_remove_records (expired )
186209 return expired
187210
0 commit comments