Skip to content

Commit 3a87785

Browse files
robhudsontim-schilling
authored andcommitted
Add store that uses cache framework
Use functools.wraps() in _UntrackedCache Use delete_many for efficiency Use deque for O(1) pops Add test to verify no self cache tracking Refactor tests with a `CommonStoreTestsMixin` Add tests for different cache backends
1 parent 0573846 commit 3a87785

6 files changed

Lines changed: 587 additions & 115 deletions

File tree

debug_toolbar/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ def _is_running_tests():
1818

1919
CONFIG_DEFAULTS = {
2020
# Toolbar options
21+
"CACHE_BACKEND": "default",
22+
"CACHE_KEY_PREFIX": "djdt:",
2123
"DISABLE_PANELS": {
2224
"debug_toolbar.panels.profiling.ProfilingPanel",
2325
"debug_toolbar.panels.redirects.RedirectsPanel",

debug_toolbar/store.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import contextlib
2+
import functools
23
import json
34
from collections import defaultdict, deque
45
from collections.abc import Iterable
56
from typing import Any
67

8+
from django.core.cache import caches
79
from django.core.serializers.json import DjangoJSONEncoder
810
from django.db import transaction
911
from django.utils.module_loading import import_string
@@ -219,5 +221,175 @@ def panels(cls, request_id: str) -> Any:
219221
return {}
220222

221223

224+
class _UntrackedCache:
225+
"""
226+
Wrapper around a Django cache backend that suppresses debug toolbar tracking.
227+
228+
The cache panel's monkey-patched methods check ``cache._djdt_panel`` and skip
229+
recording when it is ``None``. This proxy temporarily sets that attribute to
230+
``None`` around every call so the toolbar's own cache operations are invisible.
231+
"""
232+
233+
def __init__(self, cache):
234+
self._cache = cache
235+
236+
def __getattr__(self, name):
237+
attr = getattr(self._cache, name)
238+
if not callable(attr):
239+
return attr
240+
241+
@functools.wraps(attr)
242+
def untracked(*args, **kwargs):
243+
panel = getattr(self._cache, "_djdt_panel", None)
244+
self._cache._djdt_panel = None
245+
try:
246+
return attr(*args, **kwargs)
247+
finally:
248+
self._cache._djdt_panel = panel
249+
250+
return untracked
251+
252+
253+
class CacheStore(BaseStore):
254+
"""
255+
Store that uses Django's cache framework to persist debug toolbar data.
256+
"""
257+
258+
_cache_table_registered = False
259+
260+
@classmethod
261+
def _get_cache(cls):
262+
"""Get the Django cache backend, wrapped to bypass toolbar tracking."""
263+
cache = _UntrackedCache(caches[dt_settings.get_config()["CACHE_BACKEND"]])
264+
265+
# Register the cache table with DDT_MODELS to filter SQL queries
266+
if not cls._cache_table_registered:
267+
cls._register_cache_table_for_sql_filtering(cache._cache)
268+
cls._cache_table_registered = True
269+
270+
return cache
271+
272+
@classmethod
273+
def _register_cache_table_for_sql_filtering(cls, cache):
274+
"""
275+
Add the cache table to DDT_MODELS.
276+
277+
This ensures that when using DatabaseCache, the cache table's SQL queries
278+
don't appear in the SQLPanel.
279+
"""
280+
# Only proceed if this is a DatabaseCache backend
281+
if cache.__class__.__name__ != "DatabaseCache":
282+
return
283+
284+
# Get the cache table name
285+
cache_table = getattr(cache, "_table", None)
286+
if cache_table:
287+
# Import here to avoid circular dependency:
288+
# store.py -> panels/sql/tracking.py -> panels/sql/forms.py -> toolbar.py -> store.py
289+
from debug_toolbar.panels.sql import tracking
290+
291+
tracking.DDT_MODELS.add(cache_table)
292+
293+
@classmethod
294+
def _key_prefix(cls) -> str:
295+
"""Get the cache key prefix from settings."""
296+
return dt_settings.get_config()["CACHE_KEY_PREFIX"]
297+
298+
@classmethod
299+
def _request_ids_key(cls) -> str:
300+
"""Return the cache key for the request IDs list."""
301+
return f"{cls._key_prefix()}request_ids"
302+
303+
@classmethod
304+
def _request_key(cls, request_id: str) -> str:
305+
"""Return the cache key for a specific request's data."""
306+
return f"{cls._key_prefix()}req:{request_id}"
307+
308+
@classmethod
309+
def request_ids(cls) -> Iterable:
310+
"""The stored request ids."""
311+
return cls._get_cache().get(cls._request_ids_key(), [])
312+
313+
@classmethod
314+
def exists(cls, request_id: str) -> bool:
315+
"""Does the given request_id exist in the store."""
316+
return request_id in cls.request_ids()
317+
318+
@classmethod
319+
def set(cls, request_id: str):
320+
"""Set a request_id in the store."""
321+
cache = cls._get_cache()
322+
ids_key = cls._request_ids_key()
323+
request_ids = deque(cache.get(ids_key, []))
324+
325+
if request_id not in request_ids:
326+
request_ids.append(request_id)
327+
328+
# Enforce RESULTS_CACHE_SIZE limit
329+
max_size = dt_settings.get_config()["RESULTS_CACHE_SIZE"]
330+
while len(request_ids) > max_size:
331+
removed_id = request_ids.popleft()
332+
cache.delete(cls._request_key(removed_id))
333+
334+
cache.set(ids_key, list(request_ids), None)
335+
336+
@classmethod
337+
def clear(cls):
338+
"""Remove all requests from the request store."""
339+
cache = cls._get_cache()
340+
ids_key = cls._request_ids_key()
341+
request_ids = cache.get(ids_key, [])
342+
343+
# Delete all request data
344+
if request_ids:
345+
cache.delete_many([cls._request_key(_id) for _id in request_ids])
346+
347+
# Clear the request IDs list
348+
cache.delete(ids_key)
349+
350+
@classmethod
351+
def delete(cls, request_id: str):
352+
"""Delete the stored request for the given request_id."""
353+
cache = cls._get_cache()
354+
ids_key = cls._request_ids_key()
355+
request_ids = list(cache.get(ids_key, []))
356+
357+
# Remove from the list if present
358+
if request_id in request_ids:
359+
request_ids.remove(request_id)
360+
cache.set(ids_key, request_ids, None)
361+
362+
# Delete the request data
363+
cache.delete(cls._request_key(request_id))
364+
365+
@classmethod
366+
def save_panel(cls, request_id: str, panel_id: str, data: Any = None):
367+
"""Save the panel data for the given request_id."""
368+
cls.set(request_id)
369+
cache = cls._get_cache()
370+
request_key = cls._request_key(request_id)
371+
request_data = cache.get(request_key, {})
372+
request_data[panel_id] = serialize(data)
373+
cache.set(request_key, request_data, None)
374+
375+
@classmethod
376+
def panel(cls, request_id: str, panel_id: str) -> Any:
377+
"""Fetch the panel data for the given request_id."""
378+
cache = cls._get_cache()
379+
request_data = cache.get(cls._request_key(request_id), {})
380+
panel_data = request_data.get(panel_id)
381+
if panel_data is None:
382+
return {}
383+
return deserialize(panel_data)
384+
385+
@classmethod
386+
def panels(cls, request_id: str) -> Any:
387+
"""Fetch all the panel data for the given request_id."""
388+
cache = cls._get_cache()
389+
request_data = cache.get(cls._request_key(request_id), {})
390+
for panel_id, panel_data in request_data.items():
391+
yield panel_id, deserialize(panel_data)
392+
393+
222394
def get_store() -> BaseStore:
223395
return import_string(dt_settings.get_config()["TOOLBAR_STORE_CLASS"])

docs/architecture.rst

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,13 @@ the store ID. This is so that the toolbar can load the collected metrics
6868
for that particular request.
6969

7070
The history panel allows a user to view the metrics for any request since
71-
the application was started. The toolbar maintains its state entirely in
72-
memory for the process running ``runserver``. If the application is
73-
restarted the toolbar will lose its state.
71+
the application was started. By default, the toolbar maintains its state
72+
entirely in memory (``MemoryStore``) for the process running ``runserver``.
73+
If the application is restarted the toolbar will lose its state. To persist
74+
data across restarts, configure ``TOOLBAR_STORE_CLASS`` to use
75+
``DatabaseStore`` or ``CacheStore``. See the
76+
:ref:`TOOLBAR_STORE_CLASS <TOOLBAR_STORE_CLASS>` configuration option for
77+
details.
7478

7579
Problematic Parts
7680
-----------------

docs/changes.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ Pending
1818
ensure the latest static assets are used.
1919
* Fixed bug with ``CachePanel`` so the cache patching is only applied
2020
once.
21+
* Added ``debug_toolbar.store.CacheStore`` for storing toolbar data using
22+
Django's cache framework. This provides persistence without requiring
23+
database migrations, and works with any cache backend (Memcached, Redis,
24+
database, file-based, etc.).
25+
* Added ``CACHE_BACKEND`` and ``CACHE_KEY_PREFIX`` settings to configure the
26+
``CacheStore``.
2127

2228
6.2.0 (2026-01-20)
2329
------------------

docs/configuration.rst

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,43 @@ toolbar itself, others are specific to some panels.
5353
Toolbar options
5454
~~~~~~~~~~~~~~~
5555

56+
* ``CACHE_BACKEND``
57+
58+
Default: ``"default"``
59+
60+
The alias of the Django cache backend to use when
61+
:ref:`TOOLBAR_STORE_CLASS <TOOLBAR_STORE_CLASS>` is set to
62+
``debug_toolbar.store.CacheStore``. This should match one of the keys in
63+
your :setting:`CACHES` setting.
64+
65+
Using a dedicated cache backend for the toolbar is recommended in
66+
production-like environments to avoid evicting application cache entries:
67+
68+
.. code-block:: python
69+
70+
CACHES = {
71+
"default": {
72+
"BACKEND": "django.core.cache.backends.redis.RedisCache",
73+
"LOCATION": "redis://127.0.0.1:6379",
74+
},
75+
"debug-toolbar": {
76+
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
77+
},
78+
}
79+
80+
DEBUG_TOOLBAR_CONFIG = {
81+
"TOOLBAR_STORE_CLASS": "debug_toolbar.store.CacheStore",
82+
"CACHE_BACKEND": "debug-toolbar",
83+
}
84+
85+
* ``CACHE_KEY_PREFIX``
86+
87+
Default: ``"djdt:"``
88+
89+
A prefix applied to all cache keys used by the ``CacheStore``. This
90+
prevents collisions with other cache entries when sharing a cache
91+
backend with the rest of your application.
92+
5693
* ``DISABLE_PANELS``
5794

5895
Default:
@@ -190,22 +227,30 @@ Toolbar options
190227

191228
Available store classes:
192229

193-
* ``debug_toolbar.store.MemoryStore`` - Stores data in memory
194-
* ``debug_toolbar.store.DatabaseStore`` - Stores data in the database
195-
196-
The DatabaseStore provides persistence and automatically cleans up old
197-
entries based on the ``RESULTS_CACHE_SIZE`` setting.
198-
199-
Note: When using ``DatabaseStore`` migrations are required for
230+
* ``debug_toolbar.store.MemoryStore`` - Stores data in memory. This is the
231+
default and requires no additional configuration. Data is lost when the
232+
server restarts.
233+
* ``debug_toolbar.store.DatabaseStore`` - Stores data in the database.
234+
Requires running migrations (see below).
235+
* ``debug_toolbar.store.CacheStore`` - Stores data using Django's cache
236+
framework. Works with any cache backend (Memcached, Redis, database,
237+
file-based, etc.). See ``CACHE_BACKEND`` and ``CACHE_KEY_PREFIX`` below
238+
for configuration options.
239+
240+
The ``DatabaseStore`` and ``CacheStore`` both provide persistence across
241+
server restarts and automatically clean up old entries based on the
242+
``RESULTS_CACHE_SIZE`` setting.
243+
244+
Note: When using ``DatabaseStore``, migrations are required for
200245
the ``debug_toolbar`` app:
201246

202247
.. code-block:: bash
203248
204249
python manage.py migrate debug_toolbar
205250
206-
For the ``DatabaseStore`` to work properly, you need to run migrations for the
207-
``debug_toolbar`` app. The migrations create the necessary database table to store
208-
toolbar data.
251+
The toolbar's own cache and SQL operations are automatically hidden from
252+
the cache and SQL panels when using ``CacheStore``, so you won't see the
253+
toolbar's internal bookkeeping in the collected metrics.
209254

210255
.. _TOOLBAR_LANGUAGE:
211256

@@ -418,12 +463,20 @@ Here's what a slightly customized toolbar configuration might look like::
418463
'SQL_WARNING_THRESHOLD': 100, # milliseconds
419464
}
420465

421-
Here's an example of using a persistent store to keep debug data between server
466+
Here's an example of using the database store to keep debug data between server
422467
restarts::
423468

424469
DEBUG_TOOLBAR_CONFIG = {
425-
'TOOLBAR_STORE_CLASS': 'debug_toolbar.store.DatabaseStore',
426-
'RESULTS_CACHE_SIZE': 100, # Store up to 100 requests
470+
"TOOLBAR_STORE_CLASS": "debug_toolbar.store.DatabaseStore",
471+
"RESULTS_CACHE_SIZE": 100, # Store up to 100 requests
472+
}
473+
474+
Here's an example of using the cache store, which provides persistence without
475+
requiring migrations::
476+
477+
DEBUG_TOOLBAR_CONFIG = {
478+
"TOOLBAR_STORE_CLASS": "debug_toolbar.store.CacheStore",
479+
"CACHE_BACKEND": "default", # Or a dedicated cache alias
427480
}
428481

429482
Theming support

0 commit comments

Comments
 (0)