Skip to content

Commit 73eea66

Browse files
Fixes #2073 -- Added DatabaseStore for persistent debug data storage. (#2121)
* feat: add DatabaseStore for persistent debug data storage - Introduced `DatabaseStore` to store debug toolbar data in the database. - Added `DebugToolbarEntry` model and migrations for persistent storage. - Updated documentation to include configuration for `DatabaseStore`. - Added tests for `DatabaseStore` functionality, including CRUD operations and cache size enforcement. Fixes #2073 * refactor: rename DebugToolbarEntry to HistoryEntry and more - Updated model name from `DebugToolbarEntry` to `HistoryEntry` to make string representations of the app_model less redundant. - Adjusted verbose names to use translations with `gettext_lazy`. - Updated all references in `store.py` to use the new model name. - Modified tests to reflect the model name change. - Added a test to check the default ordering of the model and make it the default ordering in methods reliable. * Optimize entry creation logic to clean up old entries only when new entries are added * Wrap creation and update methods in atomic transactions * Avoid using .set() for database store This doesn't provide the same utility as it does for the memory store. We need to use get_or_create to generate the entry in the database regardless. The middleware will be using .set() to trim extra requests to avoid overflowing the store removing the need for save_panel to also do the same. --------- Co-authored-by: Tim Schilling <[email protected]>
1 parent 5a21920 commit 73eea66

File tree

9 files changed

+306
-2
lines changed

9 files changed

+306
-2
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
initial = True
6+
7+
operations = [
8+
migrations.CreateModel(
9+
name="HistoryEntry",
10+
fields=[
11+
(
12+
"request_id",
13+
models.UUIDField(primary_key=True, serialize=False),
14+
),
15+
("data", models.JSONField(default=dict)),
16+
("created_at", models.DateTimeField(auto_now_add=True)),
17+
],
18+
options={
19+
"verbose_name": "history entry",
20+
"verbose_name_plural": "history entries",
21+
"ordering": ["-created_at"],
22+
},
23+
),
24+
]

debug_toolbar/migrations/__init__.py

Whitespace-only changes.

debug_toolbar/models.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from django.db import models
2+
from django.utils.translation import gettext_lazy as _
3+
4+
5+
class HistoryEntry(models.Model):
6+
request_id = models.UUIDField(primary_key=True)
7+
data = models.JSONField(default=dict)
8+
created_at = models.DateTimeField(auto_now_add=True)
9+
10+
class Meta:
11+
verbose_name = _("history entry")
12+
verbose_name_plural = _("history entries")
13+
ordering = ["-created_at"]
14+
15+
def __str__(self):
16+
return str(self.request_id)

debug_toolbar/store.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
from typing import Any
77

88
from django.core.serializers.json import DjangoJSONEncoder
9+
from django.db import transaction
910
from django.utils.encoding import force_str
1011
from django.utils.module_loading import import_string
1112

1213
from debug_toolbar import settings as dt_settings
14+
from debug_toolbar.models import HistoryEntry
1315

1416
logger = logging.getLogger(__name__)
1517

@@ -140,5 +142,86 @@ def panels(cls, request_id: str) -> Any:
140142
yield panel, deserialize(data)
141143

142144

145+
class DatabaseStore(BaseStore):
146+
@classmethod
147+
def _cleanup_old_entries(cls):
148+
"""
149+
Enforce the cache size limit - keeping only the most recently used entries
150+
up to RESULTS_CACHE_SIZE.
151+
"""
152+
# Determine which entries to keep
153+
keep_ids = cls.request_ids()
154+
155+
# Delete all entries not in the keep list
156+
if keep_ids:
157+
HistoryEntry.objects.exclude(request_id__in=keep_ids).delete()
158+
159+
@classmethod
160+
def request_ids(cls):
161+
"""Return all stored request ids within the cache size limit"""
162+
cache_size = dt_settings.get_config()["RESULTS_CACHE_SIZE"]
163+
return list(
164+
HistoryEntry.objects.all()[:cache_size].values_list("request_id", flat=True)
165+
)
166+
167+
@classmethod
168+
def exists(cls, request_id: str) -> bool:
169+
"""Check if the given request_id exists in the store"""
170+
return HistoryEntry.objects.filter(request_id=request_id).exists()
171+
172+
@classmethod
173+
def set(cls, request_id: str):
174+
"""Set a request_id in the store and clean up old entries"""
175+
with transaction.atomic():
176+
# Create the entry if it doesn't exist (ignore otherwise)
177+
_, created = HistoryEntry.objects.get_or_create(request_id=request_id)
178+
179+
# Only enforce cache size limit when new entries are created
180+
if created:
181+
cls._cleanup_old_entries()
182+
183+
@classmethod
184+
def clear(cls):
185+
"""Remove all requests from the store"""
186+
HistoryEntry.objects.all().delete()
187+
188+
@classmethod
189+
def delete(cls, request_id: str):
190+
"""Delete the stored request for the given request_id"""
191+
HistoryEntry.objects.filter(request_id=request_id).delete()
192+
193+
@classmethod
194+
def save_panel(cls, request_id: str, panel_id: str, data: Any = None):
195+
"""Save the panel data for the given request_id"""
196+
with transaction.atomic():
197+
obj, _ = HistoryEntry.objects.get_or_create(request_id=request_id)
198+
store_data = obj.data
199+
store_data[panel_id] = serialize(data)
200+
obj.data = store_data
201+
obj.save()
202+
203+
@classmethod
204+
def panel(cls, request_id: str, panel_id: str) -> Any:
205+
"""Fetch the panel data for the given request_id"""
206+
try:
207+
data = HistoryEntry.objects.get(request_id=request_id).data
208+
panel_data = data.get(panel_id)
209+
if panel_data is None:
210+
return {}
211+
return deserialize(panel_data)
212+
except HistoryEntry.DoesNotExist:
213+
return {}
214+
215+
@classmethod
216+
def panels(cls, request_id: str) -> Any:
217+
"""Fetch all panel data for the given request_id"""
218+
try:
219+
data = HistoryEntry.objects.get(request_id=request_id).data
220+
for panel_id, panel_data in data.items():
221+
yield panel_id, deserialize(panel_data)
222+
except HistoryEntry.DoesNotExist:
223+
return {}
224+
225+
143226
def get_store() -> BaseStore:
144227
return import_string(dt_settings.get_config()["TOOLBAR_STORE_CLASS"])

docs/changes.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ Serializable (don't include in main)
2424
* Update all panels to utilize data from ``Panel.get_stats()`` to load content
2525
to render. Specifically for ``Panel.title`` and ``Panel.nav_title``.
2626
* Extend example app to contain an async version.
27+
* Added ``debug_toolbar.store.DatabaseStore`` for persistent debug data
28+
storage.
2729

2830
Pending
2931
-------

docs/configuration.rst

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ Toolbar options
109109

110110
Default: ``25``
111111

112-
The toolbar keeps up to this many results in memory.
112+
The toolbar keeps up to this many results in memory or persistent storage.
113+
113114

114115
.. _ROOT_TAG_EXTRA_ATTRS:
115116

@@ -186,6 +187,24 @@ Toolbar options
186187

187188
The path to the class to be used for storing the toolbar's data per request.
188189

190+
Available store classes:
191+
192+
* ``debug_toolbar.store.MemoryStore`` - Stores data in memory
193+
* ``debug_toolbar.store.DatabaseStore`` - Stores data in the database
194+
195+
The DatabaseStore provides persistence and automatically cleans up old
196+
entries based on the ``RESULTS_CACHE_SIZE`` setting.
197+
198+
Note: For full functionality, DatabaseStore requires migrations for
199+
the debug_toolbar app:
200+
201+
.. code-block:: bash
202+
203+
python manage.py migrate debug_toolbar
204+
205+
For the DatabaseStore to work properly, you need to run migrations for the
206+
debug_toolbar app. The migrations create the necessary database table to store
207+
toolbar data.
189208

190209
.. _TOOLBAR_LANGUAGE:
191210

@@ -394,6 +413,14 @@ Here's what a slightly customized toolbar configuration might look like::
394413
'SQL_WARNING_THRESHOLD': 100, # milliseconds
395414
}
396415

416+
Here's an example of using a persistent store to keep debug data between server
417+
restarts::
418+
419+
DEBUG_TOOLBAR_CONFIG = {
420+
'TOOLBAR_STORE_CLASS': 'debug_toolbar.store.DatabaseStore',
421+
'RESULTS_CACHE_SIZE': 100, # Store up to 100 requests
422+
}
423+
397424
Theming support
398425
---------------
399426
The debug toolbar uses CSS variables to define fonts and colors. This allows

tests/test_models.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import uuid
2+
3+
from django.test import TestCase
4+
5+
from debug_toolbar.models import HistoryEntry
6+
7+
8+
class HistoryEntryTestCase(TestCase):
9+
def test_str_method(self):
10+
test_uuid = uuid.uuid4()
11+
entry = HistoryEntry(request_id=test_uuid)
12+
self.assertEqual(str(entry), str(test_uuid))
13+
14+
def test_data_field_default(self):
15+
"""Test that the data field defaults to an empty dict"""
16+
entry = HistoryEntry(request_id=uuid.uuid4())
17+
self.assertEqual(entry.data, {})
18+
19+
def test_model_persistence(self):
20+
"""Test saving and retrieving a model instance"""
21+
test_uuid = uuid.uuid4()
22+
entry = HistoryEntry(request_id=test_uuid, data={"test": True})
23+
entry.save()
24+
25+
# Retrieve from database and verify
26+
saved_entry = HistoryEntry.objects.get(request_id=test_uuid)
27+
self.assertEqual(saved_entry.data, {"test": True})
28+
self.assertEqual(str(saved_entry), str(test_uuid))
29+
30+
def test_default_ordering(self):
31+
"""Test that the default ordering is by created_at in descending order"""
32+
self.assertEqual(HistoryEntry._meta.ordering, ["-created_at"])

tests/test_store.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import uuid
2+
13
from django.test import TestCase
24
from django.test.utils import override_settings
35

@@ -109,3 +111,121 @@ def test_get_store(self):
109111
)
110112
def test_get_store_with_setting(self):
111113
self.assertIs(store.get_store(), StubStore)
114+
115+
116+
class DatabaseStoreTestCase(TestCase):
117+
@classmethod
118+
def setUpTestData(cls) -> None:
119+
cls.store = store.DatabaseStore
120+
121+
def tearDown(self) -> None:
122+
self.store.clear()
123+
124+
def test_ids(self):
125+
id1 = str(uuid.uuid4())
126+
id2 = str(uuid.uuid4())
127+
self.store.set(id1)
128+
self.store.set(id2)
129+
# Convert the UUIDs to strings for comparison
130+
request_ids = {str(id) for id in self.store.request_ids()}
131+
self.assertEqual(request_ids, {id1, id2})
132+
133+
def test_exists(self):
134+
missing_id = str(uuid.uuid4())
135+
self.assertFalse(self.store.exists(missing_id))
136+
id1 = str(uuid.uuid4())
137+
self.store.set(id1)
138+
self.assertTrue(self.store.exists(id1))
139+
140+
def test_set(self):
141+
id1 = str(uuid.uuid4())
142+
self.store.set(id1)
143+
self.assertTrue(self.store.exists(id1))
144+
145+
def test_set_max_size(self):
146+
with self.settings(DEBUG_TOOLBAR_CONFIG={"RESULTS_CACHE_SIZE": 1}):
147+
# Clear any existing entries first
148+
self.store.clear()
149+
150+
# Add first entry
151+
id1 = str(uuid.uuid4())
152+
self.store.set(id1)
153+
154+
# Verify it exists
155+
self.assertTrue(self.store.exists(id1))
156+
157+
# Add second entry, which should push out the first one due to size limit=1
158+
id2 = str(uuid.uuid4())
159+
self.store.set(id2)
160+
161+
# Verify only the bar entry exists now
162+
# Convert the UUIDs to strings for comparison
163+
request_ids = {str(id) for id in self.store.request_ids()}
164+
self.assertEqual(request_ids, {id2})
165+
self.assertFalse(self.store.exists(id1))
166+
167+
def test_clear(self):
168+
id1 = str(uuid.uuid4())
169+
self.store.save_panel(id1, "bar.panel", {"a": 1})
170+
self.store.clear()
171+
self.assertEqual(list(self.store.request_ids()), [])
172+
self.assertEqual(self.store.panel(id1, "bar.panel"), {})
173+
174+
def test_delete(self):
175+
id1 = str(uuid.uuid4())
176+
self.store.save_panel(id1, "bar.panel", {"a": 1})
177+
self.store.delete(id1)
178+
self.assertEqual(list(self.store.request_ids()), [])
179+
self.assertEqual(self.store.panel(id1, "bar.panel"), {})
180+
# Make sure it doesn't error
181+
self.store.delete(id1)
182+
183+
def test_save_panel(self):
184+
id1 = str(uuid.uuid4())
185+
self.store.save_panel(id1, "bar.panel", {"a": 1})
186+
self.assertTrue(self.store.exists(id1))
187+
self.assertEqual(self.store.panel(id1, "bar.panel"), {"a": 1})
188+
189+
def test_update_panel(self):
190+
id1 = str(uuid.uuid4())
191+
self.store.save_panel(id1, "test.panel", {"original": True})
192+
self.assertEqual(self.store.panel(id1, "test.panel"), {"original": True})
193+
194+
# Update the panel
195+
self.store.save_panel(id1, "test.panel", {"updated": True})
196+
self.assertEqual(self.store.panel(id1, "test.panel"), {"updated": True})
197+
198+
def test_panels_nonexistent_request(self):
199+
missing_id = str(uuid.uuid4())
200+
panels = dict(self.store.panels(missing_id))
201+
self.assertEqual(panels, {})
202+
203+
def test_panel(self):
204+
id1 = str(uuid.uuid4())
205+
missing_id = str(uuid.uuid4())
206+
self.assertEqual(self.store.panel(missing_id, "missing"), {})
207+
self.store.save_panel(id1, "bar.panel", {"a": 1})
208+
self.assertEqual(self.store.panel(id1, "bar.panel"), {"a": 1})
209+
210+
def test_panels(self):
211+
id1 = str(uuid.uuid4())
212+
self.store.save_panel(id1, "panel1", {"a": 1})
213+
self.store.save_panel(id1, "panel2", {"b": 2})
214+
panels = dict(self.store.panels(id1))
215+
self.assertEqual(len(panels), 2)
216+
self.assertEqual(panels["panel1"], {"a": 1})
217+
self.assertEqual(panels["panel2"], {"b": 2})
218+
219+
def test_cleanup_old_entries(self):
220+
# Create multiple entries
221+
ids = [str(uuid.uuid4()) for _ in range(5)]
222+
for id in ids:
223+
self.store.save_panel(id, "test.panel", {"test": True})
224+
225+
# Set a small cache size
226+
with self.settings(DEBUG_TOOLBAR_CONFIG={"RESULTS_CACHE_SIZE": 2}):
227+
# Trigger cleanup
228+
self.store._cleanup_old_entries()
229+
230+
# Check that only the most recent 2 entries remain
231+
self.assertEqual(len(list(self.store.request_ids())), 2)

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ deps =
2525
pygments
2626
selenium>=4.8.0
2727
sqlparse
28-
django-csp
28+
django-csp<4.0
2929
passenv=
3030
CI
3131
COVERAGE_ARGS

0 commit comments

Comments
 (0)