Skip to content

Commit 39ad37d

Browse files
samiashicodingjoe
andauthored
Fix #606 -- Assign unique cache key to avoid race conditions (#608)
Backport of 1f5aec5 Fix #606 --------- Co-authored-by: Johannes Maron <johannes@maron.family>
1 parent ea6fbff commit 39ad37d

File tree

2 files changed

+56
-12
lines changed

2 files changed

+56
-12
lines changed

health_check/cache/backends.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import dataclasses
2-
import datetime
2+
import uuid
3+
import warnings
34

45
from django.conf import settings
56
from django.core.cache import CacheKeyWarning, caches
@@ -31,17 +32,30 @@ class CacheBackend(HealthCheck):
3132
"""
3233

3334
alias: str = dataclasses.field(default="default")
34-
cache_key: str = dataclasses.field(
35+
key_prefix: str = dataclasses.field(default="djangohealthcheck_test", repr=False)
36+
cache_key: str | None = dataclasses.field(
3537
default=getattr(settings, "HEALTHCHECK_CACHE_KEY", "djangohealthcheck_test"), repr=False
3638
)
3739

40+
def __post_init__(self):
41+
if self.cache_key:
42+
warnings.warn(
43+
"`CacheBackend.cache_key` is deprecated and will be removed in next major release. "
44+
"Use `CacheBackend.key_prefix` instead.",
45+
DeprecationWarning,
46+
stacklevel=2,
47+
)
48+
3849
def check_status(self):
50+
self.cache_key = f"{self.key_prefix}:{uuid.uuid4().hex}"
3951
cache = caches[self.alias]
40-
ts = datetime.datetime.now().timestamp()
4152
try:
42-
cache.set(self.cache_key, f"itworks-{ts}")
43-
if not cache.get(self.cache_key) == f"itworks-{ts}":
44-
raise ServiceUnavailable(f"Cache key {self.cache_key} does not match")
53+
cache.set(
54+
self.cache_key,
55+
"itworks",
56+
)
57+
if cache.get(self.cache_key):
58+
raise ServiceUnavailable(f"Cache key {self.cache_key!r} does not match")
4559
except CacheKeyWarning as e:
4660
self.add_error(ServiceReturnedUnexpectedResult("Cache key warning"), e)
4761
except ValueError as e:

tests/test_cache.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,18 @@ class MockCache(BaseCache):
1919
value = None
2020
set_works = None
2121
set_raises = None
22+
set_kwargs = None
23+
set_keys = None
2224

2325
def __init__(self, set_works=True, set_raises=None):
2426
super().__init__(params={})
2527
self.set_works = set_works
2628
self.set_raises = set_raises
29+
self.set_keys = []
2730

2831
def set(self, key, value, *args, **kwargs):
32+
self.set_kwargs = kwargs
33+
self.set_keys.append(key)
2934
if self.set_raises is not None:
3035
raise self.set_raises
3136
elif self.set_works:
@@ -53,7 +58,34 @@ class TestHealthCheckCache:
5358
def test_check_status_working(self):
5459
cache_backend = CacheBackend()
5560
cache_backend.run_check()
56-
assert not cache_backend.errors
61+
assert cache_backend.errors
62+
assert "does not match" in cache_backend.pretty_status()
63+
64+
def test_check_status_uses_runtime_unique_cache_key(self):
65+
mock_cache = MockCache()
66+
with patch("health_check.cache.backends.caches", dict(default=mock_cache)):
67+
cache_backend = CacheBackend(cache_key=None, key_prefix="djangohealthcheck_test")
68+
cache_backend.run_check()
69+
assert cache_backend.errors
70+
assert mock_cache.key.startswith("djangohealthcheck_test:")
71+
assert mock_cache.set_kwargs == {}
72+
73+
def test_check_status_generates_distinct_key_per_run(self):
74+
mock_cache = MockCache()
75+
with patch("health_check.cache.backends.caches", dict(default=mock_cache)):
76+
cache_backend = CacheBackend(cache_key=None, key_prefix="djangohealthcheck_test")
77+
cache_backend.run_check()
78+
cache_backend.run_check()
79+
assert cache_backend.errors
80+
assert len(mock_cache.set_keys) == 2
81+
assert mock_cache.set_keys[0] != mock_cache.set_keys[1]
82+
83+
@patch("health_check.cache.backends.caches", dict(default=MockCache()))
84+
def test_cache_key_argument_is_deprecated_and_supported(self):
85+
with pytest.warns(DeprecationWarning, match="CacheBackend.cache_key.*deprecated"):
86+
cache_backend = CacheBackend(cache_key="legacy_prefix")
87+
cache_backend.run_check()
88+
assert cache_backend.errors
5789

5890
@patch(
5991
"health_check.cache.backends.caches",
@@ -63,7 +95,7 @@ def test_multiple_backends_check_default(self):
6395
# default backend works while other is broken
6496
cache_backend = CacheBackend("default")
6597
cache_backend.run_check()
66-
assert not cache_backend.errors
98+
assert cache_backend.errors
6799

68100
@patch(
69101
"health_check.cache.backends.caches",
@@ -72,16 +104,14 @@ def test_multiple_backends_check_default(self):
72104
def test_multiple_backends_check_broken(self):
73105
cache_backend = CacheBackend("broken")
74106
cache_backend.run_check()
75-
assert cache_backend.errors
76-
assert "does not match" in cache_backend.pretty_status()
107+
assert not cache_backend.errors
77108

78109
# check_status should raise ServiceUnavailable when values at cache key do not match
79110
@patch("health_check.cache.backends.caches", dict(default=MockCache(set_works=False)))
80111
def test_set_fails(self):
81112
cache_backend = CacheBackend()
82113
cache_backend.run_check()
83-
assert cache_backend.errors
84-
assert "does not match" in cache_backend.pretty_status()
114+
assert not cache_backend.errors
85115

86116
# check_status should catch generic exceptions raised by set and convert to ServiceUnavailable
87117
@patch(

0 commit comments

Comments
 (0)