Skip to content

Commit fea3b18

Browse files
authored
Merge pull request #39 from stalkerg/memory-limit
Max memory limit
2 parents 7f5a8ae + 9ec7983 commit fea3b18

22 files changed

Lines changed: 1300 additions & 325 deletions

python/cachebox/_cachebox.py

Lines changed: 112 additions & 19 deletions
Large diffs are not rendered by default.

python/cachebox/_core.pyi

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,19 @@ class BaseCacheImpl(typing.Generic[KT, VT]):
2727
def __init__(
2828
self,
2929
maxsize: int,
30-
iterable: typing.Union[typing.Iterable[typing.Tuple[KT, VT]], typing.Dict[KT, VT]] = ...,
30+
iterable: typing.Union[
31+
typing.Iterable[typing.Tuple[KT, VT]], typing.Dict[KT, VT]
32+
] = ...,
3133
*,
3234
capacity: int = ...,
35+
maxmemory: int = ...,
3336
) -> None: ...
3437
@staticmethod
3538
def __class_getitem__(*args: typing.Any) -> None: ...
3639
@property
3740
def maxsize(self) -> int: ...
41+
@property
42+
def maxmemory(self) -> int: ...
3843
def __len__(self) -> int: ...
3944
def __sizeof__(self) -> int: ...
4045
def __bool__(self) -> bool: ...
@@ -47,29 +52,42 @@ class BaseCacheImpl(typing.Generic[KT, VT]):
4752
def __eq__(self, other: typing.Any) -> bool: ...
4853
def __ne__(self, other: typing.Any) -> bool: ...
4954
def capacity(self) -> int: ...
55+
def memory(self) -> int: ...
5056
def is_full(self) -> bool: ...
5157
def is_empty(self) -> bool: ...
5258
def insert(
5359
self, key: KT, value: VT, *args: typing.Any, **kwargs: typing.Any
5460
) -> typing.Optional[VT]: ...
55-
def get(self, key: KT, default: typing.Optional[DT] = None) -> typing.Union[VT, DT]: ...
56-
def pop(self, key: KT, default: typing.Optional[DT] = None) -> typing.Union[VT, DT]: ...
61+
def get(
62+
self, key: KT, default: typing.Optional[DT] = None
63+
) -> typing.Union[VT, DT]: ...
64+
def pop(
65+
self, key: KT, default: typing.Optional[DT] = None
66+
) -> typing.Union[VT, DT]: ...
5767
def setdefault(
58-
self, key: KT, default: typing.Optional[DT] = None, *args: typing.Any, **kwargs: typing.Any
68+
self,
69+
key: KT,
70+
default: typing.Optional[DT] = None,
71+
*args: typing.Any,
72+
**kwargs: typing.Any,
5973
) -> typing.Optional[VT | DT]: ...
6074
def popitem(self) -> typing.Tuple[KT, VT]: ...
6175
def drain(self, n: int) -> int: ...
6276
def clear(self, *, reuse: bool = False) -> None: ...
6377
def shrink_to_fit(self) -> None: ...
6478
def update(
6579
self,
66-
iterable: typing.Union[typing.Iterable[typing.Tuple[KT, VT]], typing.Dict[KT, VT]],
80+
iterable: typing.Union[
81+
typing.Iterable[typing.Tuple[KT, VT]], typing.Dict[KT, VT]
82+
],
6783
*args: typing.Any,
6884
**kwargs: typing.Any,
6985
) -> None: ...
7086
def keys(self) -> typing.Iterable[KT]: ...
7187
def values(self) -> typing.Iterable[VT]: ...
7288
def items(self) -> typing.Iterable[typing.Tuple[KT, VT]]: ...
7389
def __copy__(self) -> "BaseCacheImpl[KT, VT]": ...
74-
def __deepcopy__(self, memo: typing.Dict[str, object]) -> "BaseCacheImpl[KT, VT]": ...
90+
def __deepcopy__(
91+
self, memo: typing.Dict[str, object]
92+
) -> "BaseCacheImpl[KT, VT]": ...
7593
def copy(self) -> "BaseCacheImpl[KT, VT]": ...

python/cachebox/utils.py

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
from ._cachebox import BaseCacheImpl, FIFOCache
2-
from collections import namedtuple, defaultdict
3-
import functools
4-
import asyncio
51
import _thread
2+
import asyncio
3+
import functools
64
import inspect
75
import typing
6+
from collections import defaultdict, namedtuple
87

8+
from ._cachebox import BaseCacheImpl, FIFOCache
99

1010
KT = typing.TypeVar("KT")
1111
VT = typing.TypeVar("VT")
@@ -46,6 +46,10 @@ def cache(self) -> BaseCacheImpl[KT, VT]:
4646
def maxsize(self) -> int:
4747
return self.__cache.maxsize
4848

49+
@property
50+
def maxmemory(self) -> int:
51+
return self.__cache.maxmemory
52+
4953
def __len__(self) -> int:
5054
return len(self.__cache)
5155

@@ -85,6 +89,9 @@ def __richcmp__(self, other: typing.Any, op: int) -> bool:
8589
def capacity(self) -> int:
8690
return self.__cache.capacity()
8791

92+
def memory(self) -> int:
93+
return self.__cache.memory()
94+
8895
def is_full(self) -> bool:
8996
return self.__cache.is_full()
9097

@@ -140,7 +147,9 @@ def shrink_to_fit(self) -> None:
140147

141148
def update(
142149
self,
143-
iterable: typing.Union[typing.Iterable[typing.Tuple[KT, VT]], typing.Dict[KT, VT]],
150+
iterable: typing.Union[
151+
typing.Iterable[typing.Tuple[KT, VT]], typing.Dict[KT, VT]
152+
],
144153
*args,
145154
**kwargs,
146155
) -> None:
@@ -270,16 +279,22 @@ def _cached_wrapper(
270279
cache: typing.Union[BaseCacheImpl, typing.Callable],
271280
key_maker: typing.Callable[[tuple, dict], typing.Hashable],
272281
clear_reuse: bool,
273-
callback: typing.Optional[typing.Callable[[int, typing.Any, typing.Any], typing.Any]],
282+
callback: typing.Optional[
283+
typing.Callable[[int, typing.Any, typing.Any], typing.Any]
284+
],
274285
copy_level: int,
275286
is_method: bool,
276287
):
277288
is_method = cache_is_function = inspect.isfunction(cache)
278-
_key_maker = (lambda args, kwds: key_maker(args[1:], kwds)) if is_method else key_maker
289+
_key_maker = (
290+
(lambda args, kwds: key_maker(args[1:], kwds)) if is_method else key_maker
291+
)
279292

280293
hits = 0
281294
misses = 0
282-
locks: defaultdict[typing.Hashable, _LockWithCounter] = defaultdict(_LockWithCounter)
295+
locks: defaultdict[typing.Hashable, _LockWithCounter] = defaultdict(
296+
_LockWithCounter
297+
)
283298
exceptions: typing.Dict[typing.Hashable, BaseException] = {}
284299

285300
def _wrapped(*args, **kwds):
@@ -308,7 +323,9 @@ def _wrapped(*args, **kwds):
308323

309324
with locks[key]:
310325
if exceptions.get(key, None) is not None:
311-
cached_error = exceptions[key] if locks[key].waiters > 1 else exceptions.pop(key)
326+
cached_error = (
327+
exceptions[key] if locks[key].waiters > 1 else exceptions.pop(key)
328+
)
312329
raise cached_error
313330

314331
try:
@@ -337,7 +354,7 @@ def _wrapped(*args, **kwds):
337354
if not cache_is_function:
338355
_wrapped.cache = cache
339356
_wrapped.cache_info = lambda: CacheInfo(
340-
hits, misses, cache.maxsize, len(cache), cache.capacity()
357+
hits, misses, cache.maxsize, len(cache), cache.memory()
341358
)
342359

343360
_wrapped.callback = callback
@@ -362,12 +379,16 @@ def _async_cached_wrapper(
362379
cache: typing.Union[BaseCacheImpl, typing.Callable],
363380
key_maker: typing.Callable[[tuple, dict], typing.Hashable],
364381
clear_reuse: bool,
365-
callback: typing.Optional[typing.Callable[[int, typing.Any, typing.Any], typing.Any]],
382+
callback: typing.Optional[
383+
typing.Callable[[int, typing.Any, typing.Any], typing.Any]
384+
],
366385
copy_level: int,
367386
is_method: bool,
368387
):
369388
is_method = cache_is_function = inspect.isfunction(cache)
370-
_key_maker = (lambda args, kwds: key_maker(args[1:], kwds)) if is_method else key_maker
389+
_key_maker = (
390+
(lambda args, kwds: key_maker(args[1:], kwds)) if is_method else key_maker
391+
)
371392

372393
hits = 0
373394
misses = 0
@@ -404,7 +425,9 @@ async def _wrapped(*args, **kwds):
404425

405426
async with locks[key]:
406427
if exceptions.get(key, None) is not None:
407-
cached_error = exceptions[key] if locks[key].waiters > 1 else exceptions.pop(key)
428+
cached_error = (
429+
exceptions[key] if locks[key].waiters > 1 else exceptions.pop(key)
430+
)
408431
raise cached_error
409432

410433
try:
@@ -435,7 +458,7 @@ async def _wrapped(*args, **kwds):
435458
if not cache_is_function:
436459
_wrapped.cache = cache
437460
_wrapped.cache_info = lambda: CacheInfo(
438-
hits, misses, cache.maxsize, len(cache), cache.capacity()
461+
hits, misses, cache.maxsize, len(cache), cache.memory()
439462
)
440463

441464
_wrapped.callback = callback
@@ -459,7 +482,9 @@ def cached(
459482
cache: typing.Union[BaseCacheImpl, dict, None],
460483
key_maker: typing.Callable[[tuple, dict], typing.Hashable] = make_key,
461484
clear_reuse: bool = False,
462-
callback: typing.Optional[typing.Callable[[int, typing.Any, typing.Any], typing.Any]] = None,
485+
callback: typing.Optional[
486+
typing.Callable[[int, typing.Any, typing.Any], typing.Any]
487+
] = None,
463488
copy_level: int = 1,
464489
) -> typing.Callable[[FT], FT]:
465490
"""
@@ -532,7 +557,9 @@ def cachedmethod(
532557
cache: typing.Union[BaseCacheImpl, dict, None],
533558
key_maker: typing.Callable[[tuple, dict], typing.Hashable] = make_key,
534559
clear_reuse: bool = False,
535-
callback: typing.Optional[typing.Callable[[int, typing.Any, typing.Any], typing.Any]] = None,
560+
callback: typing.Optional[
561+
typing.Callable[[int, typing.Any, typing.Any], typing.Any]
562+
] = None,
536563
copy_level: int = 1,
537564
) -> typing.Callable[[FT], FT]:
538565
"""

python/tests/mixin.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
from cachebox import BaseCacheImpl, TTLCache
21
import dataclasses
3-
import pytest
4-
import typing
52
import sys
3+
import typing
4+
5+
import pytest
6+
from cachebox import BaseCacheImpl, TTLCache
67

78

89
@dataclasses.dataclass
@@ -26,6 +27,28 @@ def __hash__(self) -> int:
2627
return self.val
2728

2829

30+
@dataclasses.dataclass
31+
class Sized:
32+
size: int
33+
key: int
34+
35+
def __sizeof__(self) -> int:
36+
return self.size
37+
38+
def __hash__(self) -> int:
39+
return self.key
40+
41+
def __eq__(self, other: object) -> bool:
42+
if not isinstance(other, Sized):
43+
return False
44+
return self.key == other.key
45+
46+
47+
class SizeError:
48+
def __sizeof__(self) -> int:
49+
raise ValueError("boom")
50+
51+
2952
def getsizeof(obj, use_sys=True): # pragma: no cover
3053
try:
3154
if use_sys:
@@ -71,6 +94,56 @@ def test_overflow(self):
7194
with pytest.raises(OverflowError):
7295
cache["new-key"] = "new-value"
7396

97+
def test_maxmemory_config(self):
98+
cache = self.CACHE(10, **self.KWARGS, maxmemory=128)
99+
assert cache.maxmemory == 128
100+
assert cache.memory() == 0
101+
102+
def test_maxmemory_enforced(self):
103+
cache = self.CACHE(0, **self.KWARGS, maxmemory=100)
104+
105+
k1 = Sized(10, 1)
106+
v1 = Sized(80, 101)
107+
cache[k1] = v1
108+
109+
k2 = Sized(10, 2)
110+
v2 = Sized(80, 102)
111+
112+
if self.NO_POLICY:
113+
with pytest.raises(OverflowError):
114+
cache[k2] = v2
115+
assert k1 in cache
116+
else:
117+
cache[k2] = v2
118+
assert k2 in cache
119+
assert cache.memory() <= cache.maxmemory
120+
121+
def test_update_overflow_preserves_entry(self):
122+
cache = self.CACHE(0, **self.KWARGS, maxmemory=60)
123+
124+
key = Sized(10, 1)
125+
value = Sized(10, 101)
126+
cache[key] = value
127+
128+
too_big = Sized(100, 102)
129+
with pytest.raises(OverflowError):
130+
cache[key] = too_big
131+
132+
assert cache[key].key == 101
133+
assert cache.memory() <= cache.maxmemory
134+
135+
def test_update_sizeof_error_preserves_entry(self):
136+
cache = self.CACHE(0, **self.KWARGS, maxmemory=60)
137+
138+
key = Sized(10, 1)
139+
value = Sized(10, 101)
140+
cache[key] = value
141+
142+
with pytest.raises(ValueError):
143+
cache[key] = SizeError()
144+
145+
assert cache[key].key == 101
146+
74147
def test___len__(self):
75148
cache = self.CACHE(10, **self.KWARGS, capacity=10)
76149

python/tests/test_caches.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1+
import time
2+
from datetime import timedelta
3+
4+
import pytest
15
from cachebox import (
26
Cache,
37
FIFOCache,
4-
RRCache,
5-
LRUCache,
68
LFUCache,
9+
LRUCache,
10+
RRCache,
711
TTLCache,
812
VTTLCache,
913
)
10-
from datetime import timedelta
11-
import pytest
12-
from .mixin import _TestMixin
13-
import time
14+
15+
from .mixin import Sized, _TestMixin
1416

1517

1618
class TestCache(_TestMixin):
@@ -64,6 +66,23 @@ def test_policy(self):
6466

6567
assert cache.popitem() == (10, 10)
6668

69+
def test_update_can_evict_self_on_maxmemory(self):
70+
cache = FIFOCache(0, maxmemory=50)
71+
72+
k1 = Sized(10, 1)
73+
v1 = Sized(10, 101)
74+
k2 = Sized(10, 2)
75+
v2 = Sized(10, 102)
76+
77+
cache[k1] = v1
78+
cache[k2] = v2
79+
80+
cache[k1] = Sized(40, 103)
81+
82+
assert k1 not in cache
83+
assert k2 in cache
84+
assert cache.memory() <= cache.maxmemory
85+
6786
def test_ordered_iterators(self):
6887
obj = self.CACHE(100, **self.KWARGS, capacity=100)
6988

0 commit comments

Comments
 (0)