Skip to content

Commit 7eee842

Browse files
committed
Re-architect cache call recording
Using signals for monitoring cache calls gives incorrect results in the face of concurrency. If two requests are being processed concurrently in different threads, they will store each other's cache calls because both panels will be subscribed to the same signal. Additionally, rework the enable_instrumentation() mechanism to monkey patch methods on the cache instances directly instead of replacing the cache instances with wrapper classes. This should eliminate the corner cases mentioned in the (now-removed) disable_instrumentation() comments.
1 parent 53cc6ba commit 7eee842

File tree

1 file changed

+141
-186
lines changed

1 file changed

+141
-186
lines changed

debug_toolbar/panels/cache.py

Lines changed: 141 additions & 186 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,9 @@
1-
import inspect
2-
import sys
1+
import functools
32
import time
43

5-
try:
6-
from django.utils.connection import ConnectionProxy
7-
except ImportError:
8-
ConnectionProxy = None
9-
4+
from asgiref.local import Local
105
from django.conf import settings
11-
from django.core import cache
12-
from django.core.cache import DEFAULT_CACHE_ALIAS, CacheHandler
13-
from django.core.cache.backends.base import BaseCache
14-
from django.dispatch import Signal
15-
from django.middleware import cache as middleware_cache
6+
from django.core.cache import CacheHandler, caches
167
from django.utils.translation import gettext_lazy as _, ngettext
178

189
from debug_toolbar import settings as dt_settings
@@ -24,134 +15,25 @@
2415
tidy_stacktrace,
2516
)
2617

27-
cache_called = Signal()
28-
29-
30-
def send_signal(method):
31-
def wrapped(self, *args, **kwargs):
32-
t = time.time()
33-
value = method(self, *args, **kwargs)
34-
t = time.time() - t
35-
36-
if dt_settings.get_config()["ENABLE_STACKTRACES"]:
37-
stacktrace = tidy_stacktrace(reversed(get_stack()))
38-
else:
39-
stacktrace = []
40-
41-
template_info = get_template_info()
42-
cache_called.send(
43-
sender=self.__class__,
44-
time_taken=t,
45-
name=method.__name__,
46-
return_value=value,
47-
args=args,
48-
kwargs=kwargs,
49-
trace=stacktrace,
50-
template_info=template_info,
51-
backend=self.cache,
52-
)
53-
return value
54-
55-
return wrapped
56-
57-
58-
class CacheStatTracker(BaseCache):
59-
"""A small class used to track cache calls."""
60-
61-
def __init__(self, cache):
62-
self.cache = cache
63-
64-
def __repr__(self):
65-
return "<CacheStatTracker for %s>" % repr(self.cache)
66-
67-
def _get_func_info(self):
68-
frame = sys._getframe(3)
69-
info = inspect.getframeinfo(frame)
70-
return (info[0], info[1], info[2], info[3])
71-
72-
def __contains__(self, key):
73-
return self.cache.__contains__(key)
74-
75-
def __getattr__(self, name):
76-
return getattr(self.cache, name)
77-
78-
@send_signal
79-
def add(self, *args, **kwargs):
80-
return self.cache.add(*args, **kwargs)
81-
82-
@send_signal
83-
def get(self, *args, **kwargs):
84-
return self.cache.get(*args, **kwargs)
85-
86-
@send_signal
87-
def set(self, *args, **kwargs):
88-
return self.cache.set(*args, **kwargs)
89-
90-
@send_signal
91-
def get_or_set(self, *args, **kwargs):
92-
return self.cache.get_or_set(*args, **kwargs)
93-
94-
@send_signal
95-
def touch(self, *args, **kwargs):
96-
return self.cache.touch(*args, **kwargs)
97-
98-
@send_signal
99-
def delete(self, *args, **kwargs):
100-
return self.cache.delete(*args, **kwargs)
101-
102-
@send_signal
103-
def clear(self, *args, **kwargs):
104-
return self.cache.clear(*args, **kwargs)
105-
106-
@send_signal
107-
def has_key(self, *args, **kwargs):
108-
# Ignore flake8 rules for has_key since we need to support caches
109-
# that may be using has_key.
110-
return self.cache.has_key(*args, **kwargs) # noqa: W601
111-
112-
@send_signal
113-
def incr(self, *args, **kwargs):
114-
return self.cache.incr(*args, **kwargs)
115-
116-
@send_signal
117-
def decr(self, *args, **kwargs):
118-
return self.cache.decr(*args, **kwargs)
119-
120-
@send_signal
121-
def get_many(self, *args, **kwargs):
122-
return self.cache.get_many(*args, **kwargs)
123-
124-
@send_signal
125-
def set_many(self, *args, **kwargs):
126-
self.cache.set_many(*args, **kwargs)
127-
128-
@send_signal
129-
def delete_many(self, *args, **kwargs):
130-
self.cache.delete_many(*args, **kwargs)
131-
132-
@send_signal
133-
def incr_version(self, *args, **kwargs):
134-
return self.cache.incr_version(*args, **kwargs)
135-
136-
@send_signal
137-
def decr_version(self, *args, **kwargs):
138-
return self.cache.decr_version(*args, **kwargs)
139-
140-
141-
class CacheHandlerPatch(CacheHandler):
142-
def __init__(self, settings=None):
143-
self._djdt_wrap = True
144-
super().__init__(settings=settings)
145-
146-
def create_connection(self, alias):
147-
actual_cache = super().create_connection(alias)
148-
if self._djdt_wrap:
149-
return CacheStatTracker(actual_cache)
150-
else:
151-
return actual_cache
152-
153-
154-
middleware_cache.caches = CacheHandlerPatch()
18+
# The order of the methods in this list determines the order in which they are listed in
19+
# the Commands table in the panel content.
20+
WRAPPED_CACHE_METHODS = [
21+
"add",
22+
"get",
23+
"set",
24+
"get_or_set",
25+
"touch",
26+
"delete",
27+
"clear",
28+
"get_many",
29+
"set_many",
30+
"delete_many",
31+
"has_key",
32+
"incr",
33+
"decr",
34+
"incr_version",
35+
"decr_version",
36+
]
15537

15638

15739
class CachePanel(Panel):
@@ -161,43 +43,57 @@ class CachePanel(Panel):
16143

16244
template = "debug_toolbar/panels/cache.html"
16345

46+
_context_locals = Local()
47+
16448
def __init__(self, *args, **kwargs):
16549
super().__init__(*args, **kwargs)
16650
self.total_time = 0
16751
self.hits = 0
16852
self.misses = 0
16953
self.calls = []
170-
self.counts = {
171-
"add": 0,
172-
"get": 0,
173-
"set": 0,
174-
"get_or_set": 0,
175-
"touch": 0,
176-
"delete": 0,
177-
"clear": 0,
178-
"get_many": 0,
179-
"set_many": 0,
180-
"delete_many": 0,
181-
"has_key": 0,
182-
"incr": 0,
183-
"decr": 0,
184-
"incr_version": 0,
185-
"decr_version": 0,
186-
}
187-
cache_called.connect(self._store_call_info)
54+
self.counts = {name: 0 for name in WRAPPED_CACHE_METHODS}
55+
56+
@classmethod
57+
def current_instance(cls):
58+
"""
59+
Return the currently enabled CachePanel instance or None.
60+
61+
If a request is in process with a CachePanel enabled, this will return that
62+
panel (based on the current thread or async task). Otherwise it will return
63+
None.
64+
"""
65+
return getattr(cls._context_locals, "current_instance", None)
66+
67+
@classmethod
68+
def ready(cls):
69+
if not hasattr(CacheHandler, "_djdt_patched"):
70+
# Wrap the CacheHander.create_connection() method to monkey patch any new
71+
# cache connections that are opened while instrumentation is enabled. In
72+
# the interests of thread safety, this is done once at startup time and
73+
# never removed.
74+
original_method = CacheHandler.create_connection
75+
76+
@functools.wraps(original_method)
77+
def wrapper(self, alias):
78+
cache = original_method(self, alias)
79+
panel = cls.current_instance()
80+
if panel is not None:
81+
panel._monkey_patch_cache(cache)
82+
return cache
83+
84+
CacheHandler.create_connection = wrapper
85+
CacheHandler._djdt_patched = True
18886

18987
def _store_call_info(
19088
self,
191-
sender,
192-
name=None,
193-
time_taken=0,
194-
return_value=None,
195-
args=None,
196-
kwargs=None,
197-
trace=None,
198-
template_info=None,
199-
backend=None,
200-
**kw,
89+
name,
90+
time_taken,
91+
return_value,
92+
args,
93+
kwargs,
94+
trace,
95+
template_info,
96+
backend,
20197
):
20298
if name == "get" or name == "get_or_set":
20399
if return_value is None:
@@ -226,6 +122,69 @@ def _store_call_info(
226122
}
227123
)
228124

125+
def _record_call(self, cache, name, original_method, args, kwargs):
126+
# Some cache backends implement certain cache methods in terms of other cache
127+
# methods (e.g. get_or_set() in terms of get() and add()). In order to only
128+
# record the calls made directly by the user code, set the _djdt_recording flag
129+
# here to cause the monkey patched cache methods to skip recording additional
130+
# calls made during the course of this call.
131+
cache._djdt_recording = True
132+
t = time.time()
133+
value = original_method(*args, **kwargs)
134+
t = time.time() - t
135+
cache._djdt_recording = False
136+
137+
if dt_settings.get_config()["ENABLE_STACKTRACES"]:
138+
stacktrace = tidy_stacktrace(reversed(get_stack()))
139+
else:
140+
stacktrace = []
141+
142+
self._store_call_info(
143+
name=name,
144+
time_taken=t,
145+
return_value=value,
146+
args=args,
147+
kwargs=kwargs,
148+
trace=stacktrace,
149+
template_info=get_template_info(),
150+
backend=cache,
151+
)
152+
return value
153+
154+
def _monkey_patch_method(self, cache, name):
155+
original_method = getattr(cache, name)
156+
157+
@functools.wraps(original_method)
158+
def wrapper(*args, **kwargs):
159+
# If this call is being made as part of the implementation of another cache
160+
# method, don't record it.
161+
if cache._djdt_recording:
162+
return original_method(*args, **kwargs)
163+
else:
164+
return self._record_call(cache, name, original_method, args, kwargs)
165+
166+
wrapper._djdt_wrapped = original_method
167+
setattr(cache, name, wrapper)
168+
169+
def _monkey_patch_cache(self, cache):
170+
if not hasattr(cache, "_djdt_patched"):
171+
for name in WRAPPED_CACHE_METHODS:
172+
self._monkey_patch_method(cache, name)
173+
cache._djdt_patched = True
174+
cache._djdt_recording = False
175+
176+
@staticmethod
177+
def _unmonkey_patch_cache(cache):
178+
if hasattr(cache, "_djdt_patched"):
179+
for name in WRAPPED_CACHE_METHODS:
180+
original_method = getattr(cache, name)._djdt_wrapped
181+
if original_method.__func__ == getattr(cache.__class__, name):
182+
delattr(cache, name)
183+
else:
184+
setattr(cache, name, original_method)
185+
del cache._djdt_patched
186+
del cache._djdt_recording
187+
229188
# Implement the Panel API
230189

231190
nav_title = _("Cache")
@@ -249,26 +208,22 @@ def title(self):
249208
) % {"count": count}
250209

251210
def enable_instrumentation(self):
252-
for alias in cache.caches:
253-
if not isinstance(cache.caches[alias], CacheStatTracker):
254-
cache.caches[alias] = CacheStatTracker(cache.caches[alias])
255-
256-
if not isinstance(middleware_cache.caches, CacheHandlerPatch):
257-
middleware_cache.caches = cache.caches
258-
259-
# Wrap the patched cache inside Django's ConnectionProxy
260-
if ConnectionProxy:
261-
cache.cache = ConnectionProxy(cache.caches, DEFAULT_CACHE_ALIAS)
211+
# Monkey patch all open cache connections. Django maintains cache connections
212+
# on a per-thread/async task basis, so this will not affect any concurrent
213+
# requests. The monkey patch of CacheHander.create_connection() installed in
214+
# the .ready() method will ensure that any new cache connections that get opened
215+
# during this request will also be monkey patched.
216+
for cache in caches.all(initialized_only=True):
217+
self._monkey_patch_cache(cache)
218+
# Mark this panel instance as the current one for the active thread/async task
219+
# context. This will be used by the CacheHander.create_connection() monkey
220+
# patch.
221+
self._context_locals.current_instance = self
262222

263223
def disable_instrumentation(self):
264-
for alias in cache.caches:
265-
if isinstance(cache.caches[alias], CacheStatTracker):
266-
cache.caches[alias] = cache.caches[alias].cache
267-
if ConnectionProxy:
268-
cache.cache = ConnectionProxy(cache.caches, DEFAULT_CACHE_ALIAS)
269-
# While it can be restored to the original, any views that were
270-
# wrapped with the cache_page decorator will continue to use a
271-
# monkey patched cache.
224+
del self._context_locals.current_instance
225+
for cache in caches.all(initialized_only=True):
226+
self._unmonkey_patch_cache(cache)
272227

273228
def generate_stats(self, request, response):
274229
self.record_stats(

0 commit comments

Comments
 (0)