Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/hypothesis/internal/cache.py: 64%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

213 statements  

1# This file is part of Hypothesis, which may be found at 

2# https://github.com/HypothesisWorks/hypothesis/ 

3# 

4# Copyright the Hypothesis Authors. 

5# Individual contributors are listed in AUTHORS.rst and the git log. 

6# 

7# This Source Code Form is subject to the terms of the Mozilla Public License, 

8# v. 2.0. If a copy of the MPL was not distributed with this file, You can 

9# obtain one at https://mozilla.org/MPL/2.0/. 

10 

11import threading 

12from collections import OrderedDict 

13from dataclasses import dataclass 

14from typing import Any, Generic, TypeVar 

15 

16from hypothesis.errors import InvalidArgument 

17 

18K = TypeVar("K") 

19V = TypeVar("V") 

20 

21 

22@dataclass(slots=True, frozen=False) 

23class Entry(Generic[K, V]): 

24 key: K 

25 value: V 

26 score: int 

27 pins: int = 0 

28 

29 @property 

30 def sort_key(self) -> tuple[int, ...]: 

31 if self.pins == 0: 

32 # Unpinned entries are sorted by score. 

33 return (0, self.score) 

34 else: 

35 # Pinned entries sort after unpinned ones. Beyond that, we don't 

36 # worry about their relative order. 

37 return (1,) 

38 

39 

40class GenericCache(Generic[K, V]): 

41 """Generic supertype for cache implementations. 

42 

43 Defines a dict-like mapping with a maximum size, where as well as mapping 

44 to a value, each key also maps to a score. When a write would cause the 

45 dict to exceed its maximum size, it first evicts the existing key with 

46 the smallest score, then adds the new key to the map. If due to pinning 

47 no key can be evicted, ValueError is raised. 

48 

49 A key has the following lifecycle: 

50 

51 1. key is written for the first time, the key is given the score 

52 self.new_entry(key, value) 

53 2. whenever an existing key is read or written, self.on_access(key, value, 

54 score) is called. This returns a new score for the key. 

55 3. After a key is evicted, self.on_evict(key, value, score) is called. 

56 

57 The cache will be in a valid state in all of these cases. 

58 

59 Implementations are expected to implement new_entry and optionally 

60 on_access and on_evict to implement a specific scoring strategy. 

61 """ 

62 

63 __slots__ = ("_threadlocal", "max_size") 

64 

65 def __init__(self, max_size: int): 

66 if max_size <= 0: 

67 raise InvalidArgument("Cache size must be at least one.") 

68 

69 self.max_size = max_size 

70 

71 # Implementation: We store a binary heap of Entry objects in self.data, 

72 # with the heap property requiring that a parent's score is <= that of 

73 # its children. keys_to_index then maps keys to their index in the 

74 # heap. We keep these two in sync automatically - the heap is never 

75 # reordered without updating the index. 

76 self._threadlocal = threading.local() 

77 

78 @property 

79 def keys_to_indices(self) -> dict[K, int]: 

80 try: 

81 return self._threadlocal.keys_to_indices 

82 except AttributeError: 

83 self._threadlocal.keys_to_indices = {} 

84 return self._threadlocal.keys_to_indices 

85 

86 @property 

87 def data(self) -> list[Entry[K, V]]: 

88 try: 

89 return self._threadlocal.data 

90 except AttributeError: 

91 self._threadlocal.data = [] 

92 return self._threadlocal.data 

93 

94 def _repair_if_interrupted(self) -> None: 

95 """An exception raised partway through a mutation - most plausibly a 

96 RecursionError below a deeply recursive strategy - can leave the heap 

97 property or the key index broken. We note the interruption and 

98 rebuild both here, on the next operation, from the surviving entries 

99 (a sorted list is heap-ordered), rather than attempting a repair at 

100 the moment the stack overflowed. 

101 """ 

102 if getattr(self._threadlocal, "interrupted", False): 

103 self.data.sort(key=lambda e: e.sort_key) 

104 self.keys_to_indices.clear() 

105 for i, e in enumerate(self.data): 

106 self.keys_to_indices[e.key] = i 

107 self._threadlocal.interrupted = False 

108 

109 def __len__(self) -> int: 

110 self._repair_if_interrupted() 

111 assert len(self.keys_to_indices) == len(self.data) 

112 return len(self.data) 

113 

114 def __contains__(self, key: K) -> bool: 

115 self._repair_if_interrupted() 

116 return key in self.keys_to_indices 

117 

118 def __getitem__(self, key: K) -> V: 

119 self._repair_if_interrupted() 

120 i = self.keys_to_indices[key] 

121 result = self.data[i] 

122 try: 

123 self.__entry_was_accessed(i) 

124 except BaseException: 

125 self._threadlocal.interrupted = True 

126 raise 

127 return result.value 

128 

129 def __setitem__(self, key: K, value: V) -> None: 

130 self._repair_if_interrupted() 

131 evicted = None 

132 try: 

133 i = self.keys_to_indices[key] 

134 except KeyError: 

135 entry = Entry(key, value, self.new_entry(key, value)) 

136 if len(self.data) >= self.max_size: 

137 evicted = self.data[0] 

138 if evicted.pins > 0: 

139 raise ValueError( 

140 "Cannot increase size of cache where all keys have been pinned." 

141 ) from None 

142 del self.keys_to_indices[evicted.key] 

143 i = 0 

144 self.data[0] = entry 

145 else: 

146 i = len(self.data) 

147 self.data.append(entry) 

148 try: 

149 self.keys_to_indices[key] = i 

150 self.__balance(i) 

151 except BaseException: 

152 self._threadlocal.interrupted = True 

153 raise 

154 else: 

155 entry = self.data[i] 

156 assert entry.key == key 

157 entry.value = value 

158 try: 

159 self.__entry_was_accessed(i) 

160 except BaseException: 

161 self._threadlocal.interrupted = True 

162 raise 

163 

164 if evicted is not None: 

165 if self.data[0] is not entry: 

166 assert evicted.sort_key <= self.data[0].sort_key 

167 self.on_evict(evicted.key, evicted.value, evicted.score) 

168 

169 def __iter__(self): 

170 return iter(self.keys_to_indices) 

171 

172 def pin(self, key: K, value: V) -> None: 

173 """Mark ``key`` as pinned (with the given value). That is, it may not 

174 be evicted until ``unpin(key)`` has been called. The same key may be 

175 pinned multiple times, possibly changing its value, and will not be 

176 unpinned until the same number of calls to unpin have been made. 

177 """ 

178 self[key] = value 

179 

180 i = self.keys_to_indices[key] 

181 entry = self.data[i] 

182 entry.pins += 1 

183 if entry.pins == 1: 

184 try: 

185 self.__balance(i) 

186 except BaseException: 

187 self._threadlocal.interrupted = True 

188 raise 

189 

190 def unpin(self, key: K) -> None: 

191 """Undo one previous call to ``pin(key)``. The value stays the same. 

192 Once all calls are undone this key may be evicted as normal.""" 

193 self._repair_if_interrupted() 

194 i = self.keys_to_indices[key] 

195 entry = self.data[i] 

196 if entry.pins == 0: 

197 raise ValueError(f"Key {key!r} has not been pinned") 

198 entry.pins -= 1 

199 if entry.pins == 0: 

200 try: 

201 self.__balance(i) 

202 except BaseException: 

203 self._threadlocal.interrupted = True 

204 raise 

205 

206 def is_pinned(self, key: K) -> bool: 

207 """Returns True if the key is currently pinned.""" 

208 i = self.keys_to_indices[key] 

209 return self.data[i].pins > 0 

210 

211 def clear(self) -> None: 

212 """Remove all keys, regardless of their pinned status.""" 

213 del self.data[:] 

214 self.keys_to_indices.clear() 

215 

216 def __repr__(self) -> str: 

217 return "{" + ", ".join(f"{e.key!r}: {e.value!r}" for e in self.data) + "}" 

218 

219 def new_entry(self, key: K, value: V) -> int: 

220 """Called when a key is written that does not currently appear in the 

221 map. 

222 

223 Returns the score to associate with the key. 

224 """ 

225 raise NotImplementedError 

226 

227 def on_access(self, key: K, value: V, score: Any) -> Any: 

228 """Called every time a key that is already in the map is read or 

229 written. 

230 

231 Returns the new score for the key. 

232 """ 

233 return score 

234 

235 def on_evict(self, key: K, value: V, score: Any) -> Any: 

236 """Called after a key has been evicted, with the score it had had at 

237 the point of eviction.""" 

238 

239 def check_valid(self) -> None: 

240 """Debugging method for use in tests. 

241 

242 Asserts that all of the cache's invariants hold. When everything 

243 is working correctly this should be an expensive no-op. 

244 """ 

245 assert len(self.keys_to_indices) == len(self.data) 

246 for i, e in enumerate(self.data): 

247 assert self.keys_to_indices[e.key] == i 

248 for j in [i * 2 + 1, i * 2 + 2]: 

249 if j < len(self.data): 

250 assert e.sort_key <= self.data[j].sort_key, self.data 

251 

252 def __entry_was_accessed(self, i: int) -> None: 

253 entry = self.data[i] 

254 new_score = self.on_access(entry.key, entry.value, entry.score) 

255 if new_score != entry.score: 

256 entry.score = new_score 

257 # changing the score of a pinned entry cannot unbalance the heap, as 

258 # we place all pinned entries after unpinned ones, regardless of score. 

259 if entry.pins == 0: 

260 self.__balance(i) 

261 

262 def __swap(self, i: int, j: int) -> None: 

263 assert i < j 

264 assert self.data[j].sort_key < self.data[i].sort_key 

265 self.data[i], self.data[j] = self.data[j], self.data[i] 

266 self.keys_to_indices[self.data[i].key] = i 

267 self.keys_to_indices[self.data[j].key] = j 

268 

269 def __balance(self, i: int) -> None: 

270 """When we have made a modification to the heap such that 

271 the heap property has been violated locally around i but previously 

272 held for all other indexes (and no other values have been modified), 

273 this fixes the heap so that the heap property holds everywhere.""" 

274 # bubble up (if score is too low for current position) 

275 while (parent := (i - 1) // 2) >= 0: 

276 if self.__out_of_order(parent, i): 

277 self.__swap(parent, i) 

278 i = parent 

279 else: 

280 break 

281 # or bubble down (if score is too high for current position) 

282 while children := [j for j in (2 * i + 1, 2 * i + 2) if j < len(self.data)]: 

283 smallest_child = min(children, key=lambda j: self.data[j].sort_key) 

284 if self.__out_of_order(i, smallest_child): 

285 self.__swap(i, smallest_child) 

286 i = smallest_child 

287 else: 

288 break 

289 

290 def __out_of_order(self, i: int, j: int) -> bool: 

291 """Returns True if the indices i, j are in the wrong order. 

292 

293 i must be the parent of j. 

294 """ 

295 assert i == (j - 1) // 2 

296 return self.data[j].sort_key < self.data[i].sort_key 

297 

298 

299class LRUReusedCache(GenericCache[K, V]): 

300 """The only concrete implementation of GenericCache we use outside of tests 

301 currently. 

302 

303 Adopts a modified least-recently used eviction policy: It evicts the key 

304 that has been used least recently, but it will always preferentially evict 

305 keys that have never been accessed after insertion. Among keys that have been 

306 accessed, it ignores the number of accesses. 

307 

308 This retains most of the benefits of an LRU cache, but adds an element of 

309 scan-resistance to the process: If we end up scanning through a large 

310 number of keys without reusing them, this does not evict the existing 

311 entries in preference for the new ones. 

312 """ 

313 

314 __slots__ = ("__tick",) 

315 

316 def __init__(self, max_size: int): 

317 super().__init__(max_size) 

318 self.__tick: int = 0 

319 

320 def tick(self) -> int: 

321 self.__tick += 1 

322 return self.__tick 

323 

324 def new_entry(self, key: K, value: V) -> Any: 

325 return (1, self.tick()) 

326 

327 def on_access(self, key: K, value: V, score: Any) -> Any: 

328 return (2, self.tick()) 

329 

330 

331class LRUCache(Generic[K, V]): 

332 """ 

333 This is a drop-in replacement for a GenericCache (despite the lack of inheritance) 

334 in performance critical environments. It turns out that GenericCache's heap 

335 balancing for arbitrary scores can be quite expensive compared to the doubly 

336 linked list approach of lru_cache or OrderedDict. 

337 

338 This class is a pure LRU and does not provide any sort of affininty towards 

339 the number of accesses beyond recency. If soft-pinning entries which have been 

340 accessed at least once is important, use LRUReusedCache. 

341 """ 

342 

343 # Here are some nice performance references for lru_cache vs OrderedDict: 

344 # https://github.com/python/cpython/issues/72426#issuecomment-1093727671 

345 # https://discuss.python.org/t/simplify-lru-cache/18192/6 

346 # 

347 # We use OrderedDict here because it is unclear to me we can provide the same 

348 # api as GenericCache using @lru_cache without messing with lru_cache internals. 

349 # 

350 # Anecdotally, OrderedDict seems quite competitive with lru_cache, but perhaps 

351 # that is localized to our access patterns. 

352 

353 def __init__(self, max_size: int) -> None: 

354 assert max_size > 0 

355 self.max_size = max_size 

356 self._threadlocal = threading.local() 

357 

358 @property 

359 def cache(self) -> OrderedDict[K, V]: 

360 try: 

361 return self._threadlocal.cache 

362 except AttributeError: 

363 self._threadlocal.cache = OrderedDict() 

364 return self._threadlocal.cache 

365 

366 def __setitem__(self, key: K, value: V) -> None: 

367 self.cache[key] = value 

368 self.cache.move_to_end(key) 

369 

370 while len(self.cache) > self.max_size: 

371 self.cache.popitem(last=False) 

372 

373 def __getitem__(self, key: K) -> V: 

374 val = self.cache[key] 

375 self.cache.move_to_end(key) 

376 return val 

377 

378 def __iter__(self): 

379 return iter(self.cache) 

380 

381 def __len__(self) -> int: 

382 return len(self.cache) 

383 

384 def __contains__(self, key: K) -> bool: 

385 return key in self.cache 

386 

387 # implement GenericCache interface, for tests 

388 def check_valid(self) -> None: 

389 pass