Skip to content

Commit 20f62e8

Browse files
Add atomic tag initialization to cache counters
1 parent ebfa37c commit 20f62e8

4 files changed

Lines changed: 122 additions & 13 deletions

File tree

diskcache/core.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1015,12 +1015,15 @@ def add(self, key, value, expire=None, read=False, tag=None, retry=False):
10151015

10161016
return True
10171017

1018-
def incr(self, key, delta=1, default=0, retry=False):
1018+
def incr(self, key, delta=1, default=0, retry=False, tag=None):
10191019
"""Increment value by delta for item with key.
10201020
10211021
If key is missing and default is None then raise KeyError. Else if key
10221022
is missing and default is not None then use default for value.
10231023
1024+
The tag is applied only when creating a missing or expired item.
1025+
Existing items retain their tag and expiration time.
1026+
10241027
Operation is atomic. All concurrent increment operations will be
10251028
counted individually.
10261029
@@ -1035,6 +1038,7 @@ def incr(self, key, delta=1, default=0, retry=False):
10351038
:param int delta: amount to increment (default 1)
10361039
:param int default: value if key is missing (default 0)
10371040
:param bool retry: retry if database timeout occurs (default False)
1041+
:param str tag: tag for a newly created item (default None)
10381042
:return: new value for item
10391043
:raises KeyError: if key is not found and default is None
10401044
:raises Timeout: if database timeout occurs
@@ -1055,9 +1059,7 @@ def incr(self, key, delta=1, default=0, retry=False):
10551059
raise KeyError(key)
10561060

10571061
value = default + delta
1058-
columns = (None, None) + self._disk.store(
1059-
value, False, key=key
1060-
)
1062+
columns = (None, tag) + self._disk.store(value, False, key=key)
10611063
self._row_insert(db_key, raw, now, columns)
10621064
self._cull(now, sql, cleanup)
10631065
return value
@@ -1069,9 +1071,7 @@ def incr(self, key, delta=1, default=0, retry=False):
10691071
raise KeyError(key)
10701072

10711073
value = default + delta
1072-
columns = (None, None) + self._disk.store(
1073-
value, False, key=key
1074-
)
1074+
columns = (None, tag) + self._disk.store(value, False, key=key)
10751075
self._row_update(rowid, now, columns)
10761076
self._cull(now, sql, cleanup)
10771077
cleanup(filename)
@@ -1090,12 +1090,15 @@ def incr(self, key, delta=1, default=0, retry=False):
10901090

10911091
return value
10921092

1093-
def decr(self, key, delta=1, default=0, retry=False):
1093+
def decr(self, key, delta=1, default=0, retry=False, tag=None):
10941094
"""Decrement value by delta for item with key.
10951095
10961096
If key is missing and default is None then raise KeyError. Else if key
10971097
is missing and default is not None then use default for value.
10981098
1099+
The tag is applied only when creating a missing or expired item.
1100+
Existing items retain their tag and expiration time.
1101+
10991102
Operation is atomic. All concurrent decrement operations will be
11001103
counted individually.
11011104
@@ -1113,12 +1116,13 @@ def decr(self, key, delta=1, default=0, retry=False):
11131116
:param int delta: amount to decrement (default 1)
11141117
:param int default: value if key is missing (default 0)
11151118
:param bool retry: retry if database timeout occurs (default False)
1119+
:param str tag: tag for a newly created item (default None)
11161120
:return: new value for item
11171121
:raises KeyError: if key is not found and default is None
11181122
:raises Timeout: if database timeout occurs
11191123
11201124
"""
1121-
return self.incr(key, -delta, default, retry)
1125+
return self.incr(key, -delta, default, retry, tag=tag)
11221126

11231127
def get(
11241128
self,

diskcache/fanout.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,12 +189,15 @@ def add(self, key, value, expire=None, read=False, tag=None, retry=False):
189189
except Timeout:
190190
return False
191191

192-
def incr(self, key, delta=1, default=0, retry=False):
192+
def incr(self, key, delta=1, default=0, retry=False, tag=None):
193193
"""Increment value by delta for item with key.
194194
195195
If key is missing and default is None then raise KeyError. Else if key
196196
is missing and default is not None then use default for value.
197197
198+
The tag is applied only when creating a missing or expired item.
199+
Existing items retain their tag and expiration time.
200+
198201
Operation is atomic. All concurrent increment operations will be
199202
counted individually.
200203
@@ -209,23 +212,27 @@ def incr(self, key, delta=1, default=0, retry=False):
209212
:param int delta: amount to increment (default 1)
210213
:param int default: value if key is missing (default 0)
211214
:param bool retry: retry if database timeout occurs (default False)
215+
:param str tag: tag for a newly created item (default None)
212216
:return: new value for item on success else None
213217
:raises KeyError: if key is not found and default is None
214218
215219
"""
216220
index = self._hash(key) % self._count
217221
shard = self._shards[index]
218222
try:
219-
return shard.incr(key, delta, default, retry)
223+
return shard.incr(key, delta, default, retry, tag=tag)
220224
except Timeout:
221225
return None
222226

223-
def decr(self, key, delta=1, default=0, retry=False):
227+
def decr(self, key, delta=1, default=0, retry=False, tag=None):
224228
"""Decrement value by delta for item with key.
225229
226230
If key is missing and default is None then raise KeyError. Else if key
227231
is missing and default is not None then use default for value.
228232
233+
The tag is applied only when creating a missing or expired item.
234+
Existing items retain their tag and expiration time.
235+
229236
Operation is atomic. All concurrent decrement operations will be
230237
counted individually.
231238
@@ -243,14 +250,15 @@ def decr(self, key, delta=1, default=0, retry=False):
243250
:param int delta: amount to decrement (default 1)
244251
:param int default: value if key is missing (default 0)
245252
:param bool retry: retry if database timeout occurs (default False)
253+
:param str tag: tag for a newly created item (default None)
246254
:return: new value for item on success else None
247255
:raises KeyError: if key is not found and default is None
248256
249257
"""
250258
index = self._hash(key) % self._count
251259
shard = self._shards[index]
252260
try:
253-
return shard.decr(key, delta, default, retry)
261+
return shard.decr(key, delta, default, retry, tag=tag)
254262
except Timeout:
255263
return None
256264

docs/tutorial.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,20 @@ decrementing a missing key will raise a :exc:`KeyError`.
173173
...
174174
KeyError: 'carol'
175175

176+
Use `tag` to associate a newly created counter with a group for eviction. The
177+
tag is applied atomically when the key is missing or expired. Incrementing or
178+
decrementing an existing item preserves its tag and expiration time, even if
179+
a different `tag` is supplied. :class:`FanoutCache` supports the same option.
180+
181+
>>> cache.incr('requests', tag='counters')
182+
1
183+
>>> cache.decr('requests', tag='another-group')
184+
0
185+
>>> cache.get('requests', tag=True)
186+
(0, 'counters')
187+
>>> cache.evict('counters')
188+
1
189+
176190
Increment and decrement operations are atomic and assume the value may be
177191
stored in a SQLite integer column. SQLite supports 64-bit signed integers.
178192

tests/test_counter_tags.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""Test atomic initialization of tagged counters."""
2+
3+
from concurrent.futures import ThreadPoolExecutor
4+
5+
import pytest
6+
7+
import diskcache as dc
8+
9+
10+
@pytest.fixture(params=[dc.Cache, dc.FanoutCache])
11+
def cache(request, tmp_path):
12+
with request.param(str(tmp_path)) as cache:
13+
yield cache
14+
15+
16+
@pytest.mark.parametrize('method,delta', [('incr', 3), ('decr', -3)])
17+
@pytest.mark.parametrize('tag', [None, '', 'counter'])
18+
@pytest.mark.parametrize('expired', [False, True])
19+
def test_counter_tag_creation(cache, method, delta, tag, expired):
20+
if expired:
21+
cache.set('count', 100, expire=-1, tag='old')
22+
23+
assert (
24+
getattr(cache, method)('count', 3, default=10, tag=tag) == 10 + delta
25+
)
26+
assert cache.get('count', expire_time=True, tag=True) == (
27+
10 + delta,
28+
None,
29+
tag,
30+
)
31+
if tag is not None:
32+
assert cache.evict(tag) == 1
33+
assert 'count' not in cache
34+
35+
36+
@pytest.mark.parametrize('method,delta', [('incr', 3), ('decr', -3)])
37+
@pytest.mark.parametrize('tag', [None, '', 'replacement'])
38+
def test_counter_tag_preserves_live_metadata(cache, method, delta, tag):
39+
cache.set('count', 10, expire=60, tag='original')
40+
_, expiry, _ = cache.get('count', expire_time=True, tag=True)
41+
42+
assert getattr(cache, method)('count', 3, tag=tag) == 10 + delta
43+
assert cache.get('count', expire_time=True, tag=True) == (
44+
10 + delta,
45+
expiry,
46+
'original',
47+
)
48+
49+
50+
@pytest.mark.parametrize('method', ['incr', 'decr'])
51+
@pytest.mark.parametrize('expired', [False, True])
52+
def test_counter_tag_missing_default_none(cache, method, expired):
53+
if expired:
54+
cache.set('count', 100, expire=-1, tag='old')
55+
56+
with pytest.raises(KeyError):
57+
getattr(cache, method)('count', default=None, tag='counter')
58+
assert 'count' not in cache
59+
60+
61+
@pytest.mark.parametrize('method,delta', [('incr', 3), ('decr', -3)])
62+
def test_counter_original_positional_arguments(cache, method, delta):
63+
assert getattr(cache, method)('count', 3, 10, True) == 10 + delta
64+
assert cache.get('count', tag=True) == (10 + delta, None)
65+
66+
67+
def test_counter_tag_concurrent_initialization(cache):
68+
def increment():
69+
try:
70+
return [
71+
cache.incr('count', retry=True, tag='counter')
72+
for _ in range(20)
73+
]
74+
finally:
75+
cache.close()
76+
77+
with ThreadPoolExecutor(max_workers=4) as executor:
78+
futures = [executor.submit(increment) for _ in range(4)]
79+
results = [value for future in futures for value in future.result(30)]
80+
81+
assert sorted(results) == list(range(1, 81))
82+
assert cache.get('count', tag=True) == (80, 'counter')
83+
assert cache.evict('counter') == 1

0 commit comments

Comments
 (0)