Skip to content

Commit 17bf244

Browse files
committed
Update default params test
1 parent 5d379bf commit 17bf244

11 files changed

Lines changed: 119 additions & 9 deletions

File tree

README.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,19 @@ human readable string like ``"200MB"``.
286286
When ``cachier__verbose=True`` is passed to a call that returns a value
287287
exceeding the limit, an informative message is printed.
288288

289+
Cache Size Limit
290+
~~~~~~~~~~~~~~~~
291+
``cache_size_limit`` constrains the total size of the cache. When the
292+
limit is exceeded, entries are evicted according to the chosen
293+
``replacement_policy``. Currently only an ``"lru"`` policy is implemented
294+
for the in-memory backend.
295+
296+
.. code-block:: python
297+
298+
@cachier(cache_size_limit="100KB")
299+
def heavy(x):
300+
return x * 2
301+
289302
Ignore Cache
290303
~~~~~~~~~~~~
291304

src/cachier/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ class Params:
6666
cleanup_stale: bool = False
6767
cleanup_interval: timedelta = timedelta(days=1)
6868
entry_size_limit: Optional[int] = None
69+
cache_size_limit: Optional[int] = None
70+
replacement_policy: str = "lru"
6971

7072

7173
_global_params = Params()

src/cachier/core.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ def cachier(
124124
cleanup_stale: Optional[bool] = None,
125125
cleanup_interval: Optional[timedelta] = None,
126126
entry_size_limit: Optional[Union[int, str]] = None,
127+
cache_size_limit: Optional[Union[int, str]] = None,
128+
replacement_policy: str = "lru",
127129
):
128130
"""Wrap as a persistent, stale-free memoization decorator.
129131
@@ -196,6 +198,12 @@ def cachier(
196198
Maximum serialized size of a cached value. Values exceeding the limit
197199
are returned but not cached. Human readable strings like ``"10MB"`` are
198200
allowed.
201+
cache_size_limit: int or str, optional
202+
Maximum total size allowed for the cache. When exceeded, entries are
203+
evicted according to ``replacement_policy``.
204+
replacement_policy: str, optional
205+
Cache replacement policy used when trimming the cache. Currently only
206+
``"lru"`` is supported.
199207
200208
"""
201209
# Check for deprecated parameters
@@ -212,6 +220,10 @@ def cachier(
212220
size_limit_bytes = parse_bytes(
213221
_update_with_defaults(entry_size_limit, "entry_size_limit")
214222
)
223+
cache_limit_bytes = parse_bytes(
224+
_update_with_defaults(cache_size_limit, "cache_size_limit")
225+
)
226+
policy = _update_with_defaults(replacement_policy, "replacement_policy")
215227
# Override the backend parameter if a mongetter is provided.
216228
if callable(mongetter):
217229
backend = "mongo"
@@ -224,33 +236,43 @@ def cachier(
224236
separate_files=separate_files,
225237
wait_for_calc_timeout=wait_for_calc_timeout,
226238
entry_size_limit=size_limit_bytes,
239+
cache_size_limit=cache_limit_bytes,
240+
replacement_policy=policy,
227241
)
228242
elif backend == "mongo":
229243
core = _MongoCore(
230244
hash_func=hash_func,
231245
mongetter=mongetter,
232246
wait_for_calc_timeout=wait_for_calc_timeout,
233247
entry_size_limit=size_limit_bytes,
248+
cache_size_limit=cache_limit_bytes,
249+
replacement_policy=policy,
234250
)
235251
elif backend == "memory":
236252
core = _MemoryCore(
237253
hash_func=hash_func,
238254
wait_for_calc_timeout=wait_for_calc_timeout,
239255
entry_size_limit=size_limit_bytes,
256+
cache_size_limit=cache_limit_bytes,
257+
replacement_policy=policy,
240258
)
241259
elif backend == "sql":
242260
core = _SQLCore(
243261
hash_func=hash_func,
244262
sql_engine=sql_engine,
245263
wait_for_calc_timeout=wait_for_calc_timeout,
246264
entry_size_limit=size_limit_bytes,
265+
cache_size_limit=cache_limit_bytes,
266+
replacement_policy=policy,
247267
)
248268
elif backend == "redis":
249269
core = _RedisCore(
250270
hash_func=hash_func,
251271
redis_client=redis_client,
252272
wait_for_calc_timeout=wait_for_calc_timeout,
253273
entry_size_limit=size_limit_bytes,
274+
cache_size_limit=cache_limit_bytes,
275+
replacement_policy=policy,
254276
)
255277
else:
256278
raise ValueError("specified an invalid core: %s" % backend)

src/cachier/cores/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,15 @@ def __init__(
3838
hash_func: Optional[HashFunc],
3939
wait_for_calc_timeout: Optional[int],
4040
entry_size_limit: Optional[int] = None,
41+
cache_size_limit: Optional[int] = None,
42+
replacement_policy: str = "lru",
4143
):
4244
self.hash_func = _update_with_defaults(hash_func, "hash_func")
4345
self.wait_for_calc_timeout = wait_for_calc_timeout
4446
self.lock = threading.RLock()
4547
self.entry_size_limit = entry_size_limit
48+
self.cache_size_limit = cache_size_limit
49+
self.replacement_policy = replacement_policy
4650

4751
def set_func(self, func):
4852
"""Set the function this core will use.

src/cachier/cores/memory.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""A memory-based caching core for cachier."""
22

33
import threading
4+
from collections import OrderedDict
45
from datetime import datetime, timedelta
5-
from typing import Any, Dict, Optional, Tuple
6+
from typing import Any, Optional, Tuple
67

78
from .._types import HashFunc
89
from ..config import CacheEntry
@@ -17,9 +18,18 @@ def __init__(
1718
hash_func: Optional[HashFunc],
1819
wait_for_calc_timeout: Optional[int],
1920
entry_size_limit: Optional[int] = None,
21+
cache_size_limit: Optional[int] = None,
22+
replacement_policy: str = "lru",
2023
):
21-
super().__init__(hash_func, wait_for_calc_timeout, entry_size_limit)
22-
self.cache: Dict[str, CacheEntry] = {}
24+
super().__init__(
25+
hash_func,
26+
wait_for_calc_timeout,
27+
entry_size_limit,
28+
cache_size_limit,
29+
replacement_policy,
30+
)
31+
self.cache: "OrderedDict[str, CacheEntry]" = OrderedDict()
32+
self._cache_size = 0
2333

2434
def _hash_func_key(self, key: str) -> str:
2535
return f"{_get_func_str(self.func)}:{key}"
@@ -28,18 +38,22 @@ def get_entry_by_key(
2838
self, key: str, reload=False
2939
) -> Tuple[str, Optional[CacheEntry]]:
3040
with self.lock:
31-
return key, self.cache.get(self._hash_func_key(key), None)
41+
hkey = self._hash_func_key(key)
42+
entry = self.cache.get(hkey, None)
43+
if entry is not None:
44+
self.cache.move_to_end(hkey)
45+
return key, entry
3246

3347
def set_entry(self, key: str, func_res: Any) -> bool:
3448
if not self._should_store(func_res):
3549
return False
3650
hash_key = self._hash_func_key(key)
51+
size = self._estimate_size(func_res)
3752
with self.lock:
3853
try:
39-
# we need to retain the existing condition so that
40-
# mark_entry_not_calculated can notify all possibly-waiting
41-
# threads about it
4254
cond = self.cache[hash_key]._condition
55+
old_size = self._estimate_size(self.cache[hash_key].value)
56+
self._cache_size -= old_size
4357
except KeyError: # pragma: no cover
4458
cond = None
4559
self.cache[hash_key] = CacheEntry(
@@ -50,6 +64,12 @@ def set_entry(self, key: str, func_res: Any) -> bool:
5064
_condition=cond,
5165
_completed=True,
5266
)
67+
self.cache.move_to_end(hash_key)
68+
self._cache_size += size
69+
if self.cache_size_limit is not None:
70+
while self._cache_size > self.cache_size_limit and self.cache:
71+
old_key, old_entry = self.cache.popitem(last=False)
72+
self._cache_size -= self._estimate_size(old_entry.value)
5373
return True
5474

5575
def mark_entry_being_calculated(self, key: str) -> None:
@@ -101,6 +121,7 @@ def wait_on_entry_calc(self, key: str) -> Any:
101121
def clear_cache(self) -> None:
102122
with self.lock:
103123
self.cache.clear()
124+
self._cache_size = 0
104125

105126
def clear_being_calculated(self) -> None:
106127
with self.lock:
@@ -116,4 +137,5 @@ def delete_stale_entries(self, stale_after: timedelta) -> None:
116137
k for k, v in self.cache.items() if now - v.time > stale_after
117138
]
118139
for key in keys_to_delete:
119-
del self.cache[key]
140+
entry = self.cache.pop(key)
141+
self._cache_size -= self._estimate_size(entry.value)

src/cachier/cores/mongo.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ def __init__(
4141
mongetter: Optional[Mongetter],
4242
wait_for_calc_timeout: Optional[int],
4343
entry_size_limit: Optional[int] = None,
44+
cache_size_limit: Optional[int] = None,
45+
replacement_policy: str = "lru",
4446
):
4547
if "pymongo" not in sys.modules:
4648
warnings.warn(
@@ -53,6 +55,8 @@ def __init__(
5355
hash_func=hash_func,
5456
wait_for_calc_timeout=wait_for_calc_timeout,
5557
entry_size_limit=entry_size_limit,
58+
cache_size_limit=cache_size_limit,
59+
replacement_policy=replacement_policy,
5660
)
5761
if mongetter is None:
5862
raise MissingMongetter(

src/cachier/cores/pickle.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,16 @@ def __init__(
7979
separate_files: Optional[bool],
8080
wait_for_calc_timeout: Optional[int],
8181
entry_size_limit: Optional[int] = None,
82+
cache_size_limit: Optional[int] = None,
83+
replacement_policy: str = "lru",
8284
):
83-
super().__init__(hash_func, wait_for_calc_timeout, entry_size_limit)
85+
super().__init__(
86+
hash_func,
87+
wait_for_calc_timeout,
88+
entry_size_limit,
89+
cache_size_limit,
90+
replacement_policy,
91+
)
8492
self._cache_dict: Dict[str, CacheEntry] = {}
8593
self.reload = _update_with_defaults(pickle_reload, "pickle_reload")
8694
self.cache_dir = os.path.expanduser(

src/cachier/cores/redis.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ def __init__(
3636
wait_for_calc_timeout: Optional[int] = None,
3737
key_prefix: str = "cachier",
3838
entry_size_limit: Optional[int] = None,
39+
cache_size_limit: Optional[int] = None,
40+
replacement_policy: str = "lru",
3941
):
4042
if not REDIS_AVAILABLE:
4143
warnings.warn(
@@ -49,6 +51,8 @@ def __init__(
4951
hash_func=hash_func,
5052
wait_for_calc_timeout=wait_for_calc_timeout,
5153
entry_size_limit=entry_size_limit,
54+
cache_size_limit=cache_size_limit,
55+
replacement_policy=replacement_policy,
5256
)
5357
if redis_client is None:
5458
raise MissingRedisClient(

src/cachier/cores/sql.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ def __init__(
6464
sql_engine: Optional[Union[str, "Engine", Callable[[], "Engine"]]],
6565
wait_for_calc_timeout: Optional[int] = None,
6666
entry_size_limit: Optional[int] = None,
67+
cache_size_limit: Optional[int] = None,
68+
replacement_policy: str = "lru",
6769
):
6870
if not SQLALCHEMY_AVAILABLE:
6971
raise ImportError(
@@ -74,6 +76,8 @@ def __init__(
7476
hash_func=hash_func,
7577
wait_for_calc_timeout=wait_for_calc_timeout,
7678
entry_size_limit=entry_size_limit,
79+
cache_size_limit=cache_size_limit,
80+
replacement_policy=replacement_policy,
7781
)
7882
self._engine = self._resolve_engine(sql_engine)
7983
self._Session = sessionmaker(bind=self._engine)

tests/test_cache_size_limit.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import pytest
2+
3+
import cachier
4+
5+
6+
@pytest.mark.memory
7+
def test_cache_size_limit_lru_eviction():
8+
call_count = 0
9+
10+
@cachier.cachier(backend="memory", cache_size_limit="220B")
11+
def func(x):
12+
nonlocal call_count
13+
call_count += 1
14+
return "a" * 50
15+
16+
func.clear_cache()
17+
func(1)
18+
func(2)
19+
assert call_count == 2
20+
func(1) # access to update LRU order
21+
assert call_count == 2
22+
func(3) # should evict key 2
23+
assert call_count == 3
24+
func(2)
25+
assert call_count == 4

0 commit comments

Comments
 (0)