forked from iamsinghrajat/async-cache
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_cache_features.py
More file actions
executable file
·167 lines (146 loc) · 6.45 KB
/
test_cache_features.py
File metadata and controls
executable file
·167 lines (146 loc) · 6.45 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
import asyncio
import time
import unittest
from cache import AsyncCache
class TestCacheFeatures(unittest.TestCase):
def test_metrics(self):
async def _test():
cache = AsyncCache()
# miss + load
async def loader():
return 42
await cache.get('k1', loader=loader)
# hit
await cache.get('k1')
m = cache.get_metrics()
self.assertEqual(m['hits'], 1)
self.assertEqual(m['misses'], 1)
self.assertEqual(m['size'], 1)
self.assertEqual(m['hit_rate'], 0.5)
# clear resets? no, but size 0
cache.clear()
m = cache.get_metrics()
self.assertEqual(m['size'], 0)
asyncio.run(_test())
def test_herd_protection(self):
async def _test():
cache = AsyncCache()
calls = 0
async def loader():
nonlocal calls
calls += 1
await asyncio.sleep(0.1)
return 'result'
tasks = [cache.get('k', loader=loader) for _ in range(10)]
results = await asyncio.gather(*tasks)
self.assertEqual(len(set(results)), 1)
self.assertEqual(calls, 1) # herd protection: only 1 load despite 10 concurrent
m = cache.get_metrics()
# all 10 were cache misses (concurrent, no prior hit), but protected load
self.assertEqual(m['misses'], 10)
self.assertEqual(m['hits'], 0)
self.assertEqual(m['size'], 1)
asyncio.run(_test())
def test_batching(self):
async def _test():
cache = AsyncCache(batch_window_ms=10, max_batch_size=5)
calls = 0
async def batch_loader(keys):
nonlocal calls
calls += 1
await asyncio.sleep(0.05)
return [f'val-{k}' for k in keys]
tasks = [cache.get(k, batch_loader=batch_loader) for k in ['a', 'b', 'c']]
results = await asyncio.gather(*tasks)
self.assertEqual(results, ['val-a', 'val-b', 'val-c'])
self.assertEqual(calls, 1)
asyncio.run(_test())
def test_warmup(self):
async def _test():
cache = AsyncCache()
calls = 0
async def loader1():
nonlocal calls
calls += 1
return 'v1'
async def loader2():
nonlocal calls
calls += 1
return 'v2'
await cache.warmup({'k1': loader1, 'k2': loader2})
self.assertEqual(await cache.get('k1'), 'v1')
self.assertEqual(await cache.get('k2'), 'v2')
self.assertEqual(calls, 2)
asyncio.run(_test())
def test_key_stability(self):
"""Test KEY/make_key fixes for bugs:
- Pre-fix: __eq__ hash-only (violated contract), unstable hash (dict order/str, kwargs mut), non-robust objs.
- Now: stable eq/hash, no mutation, sorted recursive.
Catches e.g. dict order, obj vars, use_cache pop side-effect.
"""
from cache.key import KEY, make_key, _to_hashable
# eq/hash contract (pre-fix: a==b but hash differ possible)
k1 = KEY((1, 'a'), {'b': 2, 'use_cache': True})
k2 = KEY((1, 'a'), {'b': 2}) # equiv after pop/sort
self.assertEqual(k1, k2)
self.assertEqual(hash(k1), hash(k2)) # must hold
# dict stability (pre-fix: items() order unstable)
d1 = {'z': 1, 'a': 2}
d2 = {'a': 2, 'z': 1}
self.assertEqual(_to_hashable(d1), _to_hashable(d2))
# obj stability
class Obj:
def __init__(self, x): self.x = x
o1 = Obj(10)
self.assertEqual(_to_hashable(o1), _to_hashable(o1)) # sorted vars
# no mutation side-effect (pre-fix: pop on input kwargs)
kw = {'use_cache': False, 'x': 1}
KEY((), kw)
self.assertIn('use_cache', kw) # untouched
# make_key + skip, complex
async def dummy(a, b, use_cache=True): pass
key1 = make_key(dummy, (1, 2, 3), {'use_cache': True}, skip_args=1) # skips '1'
key2 = make_key(dummy, (99, 2, 3), {'use_cache': False}, skip_args=1) # same after skip
self.assertEqual(key1, key2) # func + KEY((2,3), ())
# different skip
key_diff = make_key(dummy, (1, 2, 3), {}, skip_args=0)
self.assertNotEqual(key1, key_diff)
# used in cache (e.g. herd/batch keys stable)
# (implicit via other tests)
def test_key_with_list_args_is_hashable(self):
"""Regression: list in args should not raise TypeError in KEY hash."""
from cache.key import KEY, make_key
k = KEY((['https://amzn.to/4rPPcFB'],), {})
h = hash(k)
self.assertIsInstance(h, int)
async def dummy(links, use_cache=True):
return links
mk = make_key(dummy, (['https://amzn.to/4rPPcFB'],), {'use_cache': True}, skip_args=0)
self.assertIsNotNone(mk)
def test_lru_concurrent_eviction(self):
"""Test for concurrency bug in LRU re-runs with unique keys near maxsize.
Pre-fix (race in move_to_end/evict): hits dropped weirdly (e.g., 3% for size=94 vs ~90% for 95).
Now: stable ~maxsize hits on re-run (eviction keeps most-recent; no race in parallel tasks).
Reproduces UI/direct issue with 100 unique keys retry.
"""
async def _test():
maxsize = 94
cache = AsyncCache(maxsize=maxsize)
unique_keys = [f"ukey-{i}" for i in range(100)]
# first parallel fill (misses; evict to maxsize)
tasks1 = [cache.get(k, loader=lambda k=k: asyncio.sleep(0, result=f"val-{k}")) for k in unique_keys]
await asyncio.gather(*tasks1)
m1 = cache.get_metrics()
self.assertEqual(m1["size"], maxsize) # evicted to maxsize
self.assertEqual(m1["misses"], 100)
# re-run same keys parallel (should ~maxsize hits; no race/drop)
tasks2 = [cache.get(k) for k in unique_keys]
await asyncio.gather(*tasks2)
m2 = cache.get_metrics()
# delta for this re-run
d_hits = m2["hits"] - m1["hits"]
self.assertGreaterEqual(d_hits, maxsize - 5, f"Expected ~{maxsize} hits on re-run for size={maxsize}, got {d_hits} (race fixed)")
self.assertEqual(m2["size"], maxsize)
asyncio.run(_test())
if __name__ == "__main__":
unittest.main()