Skip to content

Commit f97b59f

Browse files
authored
Switched imagekit caching from in-memory to redis (#1475)
1 parent 092adc0 commit f97b59f

File tree

3 files changed

+117
-32
lines changed

3 files changed

+117
-32
lines changed

main/cache/backends.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from django.core.cache import caches
2-
from django.core.cache.backends.base import BaseCache
2+
from django.core.cache.backends.base import DEFAULT_TIMEOUT, BaseCache
33

44

55
class FallbackCache(BaseCache):
@@ -24,20 +24,50 @@ class FallbackCache(BaseCache):
2424
def __init__(self, cache_names, params):
2525
super().__init__(params)
2626
self._cache_names = cache_names
27+
self.default_timeout = params.get("TIMEOUT", None)
28+
29+
def get_backend_timeout(self, timeout=DEFAULT_TIMEOUT):
30+
"""
31+
Return the timeout to pass to fallback caches
32+
"""
33+
if timeout == DEFAULT_TIMEOUT:
34+
timeout = self.default_timeout
35+
36+
return timeout
2737

2838
def get(self, key, default=None, version=None):
2939
"""Get the value from the caches in order"""
40+
cache_misses = []
41+
result = None
42+
3043
for cache_name in self._cache_names:
3144
cache = caches[cache_name]
32-
result = cache.get(key, default=default, version=version)
33-
if result:
34-
return result
35-
return None
45+
# explicitly pass None here because it'd cause a false positive
46+
result = cache.get(key, default=None, version=version)
47+
if result is not None:
48+
break
49+
else:
50+
cache_misses.append(cache)
51+
52+
# We need to manually set the value in caches that missed
53+
# because consumers of get() will typically not call set() if get()
54+
# returns a value. If it doesn't return a value, consumers will
55+
# compute the value and then call set().
56+
if result is not None:
57+
for cache in cache_misses:
58+
cache.set(
59+
key, result, timeout=self.get_backend_timeout(), version=version
60+
)
61+
62+
return result
63+
64+
return default
3665

37-
def set(self, key, value, timeout=None, version=None):
66+
def set(self, key, value, timeout=DEFAULT_TIMEOUT, version=None):
3867
"""Set a value in the caches"""
3968
for cache_name in self._cache_names:
4069
cache = caches[cache_name]
70+
timeout = self.get_backend_timeout(timeout)
4171
cache.set(
4272
key,
4373
value,

main/cache/backends_test.py

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,87 @@
1-
from django.core.cache.backends.base import BaseCache
1+
from dataclasses import dataclass
2+
3+
import pytest
4+
from django.core.cache.backends.base import DEFAULT_TIMEOUT, BaseCache
25

36
from main.cache.backends import FallbackCache
47

58

6-
def test_fallback_cache_get(mocker, settings):
7-
"""Test that get() on the fallback cache works correctly"""
8-
mock_cache_1 = mocker.Mock(spec=BaseCache)
9-
mock_cache_1.get.return_value = 12345
10-
mock_cache_2 = mocker.Mock(spec=BaseCache)
11-
mock_cache_2.get.return_value = 67890
9+
@dataclass
10+
class MockCaches:
11+
caches: list[BaseCache]
12+
cache_names: list[str]
13+
caches_by_name: dict[str, BaseCache]
14+
cache_results: list[str]
1215

13-
mocker.patch.dict(
14-
"main.cache.backends.caches", {"dummy1": mock_cache_1, "dummy2": mock_cache_2}
15-
)
1616

17-
cache = FallbackCache(["dummy1", "dummy2"], {})
17+
@pytest.fixture(autouse=True)
18+
def mock_caches(mocker):
19+
"""Define mock caches"""
20+
cache_names = []
21+
caches = []
22+
caches_by_name = {}
23+
cache_results = []
24+
25+
for idx in range(3):
26+
name = f"dummy-{idx}"
27+
result = f"result-{idx}"
28+
mock_cache = mocker.Mock(spec=BaseCache)
29+
mock_cache.get.return_value = result
1830

19-
assert cache.get("key", default="default", version=1) == 12345
31+
cache_names.append(name)
32+
caches.append(mock_cache)
33+
caches_by_name[name] = mock_cache
34+
cache_results.append(result)
2035

21-
mock_cache_1.get.return_value = None
36+
mocker.patch.dict("main.cache.backends.caches", caches_by_name)
2237

23-
assert cache.get("key", default="default", version=1) == 67890
38+
return MockCaches(caches, cache_names, caches_by_name, cache_results)
2439

25-
mock_cache_2.get.return_value = None
2640

27-
assert cache.get("key", default="default", version=1) is None
41+
@pytest.mark.parametrize("cache_hit_idx", range(4))
42+
def test_fallback_cache_get(mock_caches: MockCaches, settings, cache_hit_idx):
43+
"""Test that get() on the fallback cache works correctly"""
2844

45+
cache = FallbackCache(mock_caches.cache_names, {})
2946

30-
def test_fallback_cache_set(mocker, settings):
31-
"""Test that set() on the fallback cache works correctly"""
32-
mock_cache_1 = mocker.Mock(spec=BaseCache)
33-
mock_cache_2 = mocker.Mock(spec=BaseCache)
47+
cold_caches = mock_caches.caches[:cache_hit_idx]
3448

35-
mocker.patch.dict(
36-
"main.cache.backends.caches", {"dummy1": mock_cache_1, "dummy2": mock_cache_2}
49+
for mock_cache in cold_caches:
50+
mock_cache.get.return_value = None
51+
52+
caches_exhausted = cache_hit_idx >= len(mock_caches.caches)
53+
expected_value = (
54+
"default" if caches_exhausted else mock_caches.cache_results[cache_hit_idx]
3755
)
3856

39-
cache = FallbackCache(["dummy1", "dummy2"], {})
57+
assert cache.get("key", default="default", version=1) == expected_value
58+
59+
if not caches_exhausted:
60+
for mock_cache in cold_caches:
61+
mock_cache.set.assert_called_once_with(
62+
"key", expected_value, timeout=cache.get_backend_timeout(), version=1
63+
)
64+
65+
66+
@pytest.mark.parametrize("cache_timeout", [DEFAULT_TIMEOUT, None, 0, 1000])
67+
@pytest.mark.parametrize(
68+
"kwargs",
69+
[
70+
{},
71+
{"timeout": 600},
72+
{"version": 1},
73+
{"timeout": 600, "version": 1},
74+
],
75+
)
76+
def test_fallback_cache_set(mock_caches, settings, cache_timeout, kwargs):
77+
"""Test that set() on the fallback cache works correctly"""
78+
cache = FallbackCache(mock_caches.cache_names, {"TIMEOUT": cache_timeout})
79+
cache.set("key", "value", **kwargs)
4080

41-
cache.set("key", "value", timeout=600, version=1)
81+
expected_timeout = kwargs.get("timeout", cache_timeout)
82+
expected_version = kwargs.get("version", None)
4283

43-
mock_cache_1.set.assert_called_once_with("key", "value", timeout=600, version=1)
44-
mock_cache_2.set.assert_called_once_with("key", "value", timeout=600, version=1)
84+
for mock_cache in mock_caches.caches:
85+
mock_cache.set.assert_called_once_with(
86+
"key", "value", timeout=expected_timeout, version=expected_version
87+
)

main/settings.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,9 +537,21 @@
537537
"imagekit": {
538538
"BACKEND": "main.cache.backends.FallbackCache",
539539
"LOCATION": [
540+
"imagekit_redis",
540541
"imagekit_db",
541542
],
542543
},
544+
# This uses FallbackCache but it's basically a proxy to the redis cache
545+
# so that we can reuse the client and not create another pile of connections.
546+
# The main purpose of this is to set TIMEOUT without specifying it on
547+
# the global redis cache.
548+
"imagekit_redis": {
549+
"BACKEND": "main.cache.backends.FallbackCache",
550+
"LOCATION": [
551+
"redis",
552+
],
553+
"TIMEOUT": 60 * 60,
554+
},
543555
"imagekit_db": {
544556
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
545557
"LOCATION": "imagekit_cache",

0 commit comments

Comments
 (0)