Skip to content

Fixes #2073 -- Added DatabaseStore for persistent debug data storage. #2121

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: serializable
Choose a base branch
from
24 changes: 24 additions & 0 deletions debug_toolbar/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from django.db import migrations, models


class Migration(migrations.Migration):
initial = True

operations = [
migrations.CreateModel(
name="DebugToolbarEntry",
fields=[
(
"request_id",
models.UUIDField(primary_key=True, serialize=False),
),
("data", models.JSONField(default=dict)),
("created_at", models.DateTimeField(auto_now_add=True)),
],
options={
"verbose_name": "Debug Toolbar Entry",
"verbose_name_plural": "Debug Toolbar Entries",
"ordering": ["-created_at"],
},
),
]
Empty file.
15 changes: 15 additions & 0 deletions debug_toolbar/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from django.db import models


class DebugToolbarEntry(models.Model):
request_id = models.UUIDField(primary_key=True)
data = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True)

class Meta:
verbose_name = "Debug Toolbar Entry"
verbose_name_plural = "Debug Toolbar Entries"
ordering = ["-created_at"]

def __str__(self):
return str(self.request_id)
97 changes: 97 additions & 0 deletions debug_toolbar/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
from typing import Any

from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from django.utils.encoding import force_str
from django.utils.module_loading import import_string

from debug_toolbar import settings as dt_settings
from debug_toolbar.models import DebugToolbarEntry

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -140,5 +142,100 @@ def panels(cls, request_id: str) -> Any:
yield panel, deserialize(data)


class DatabaseStore(BaseStore):
@classmethod
def _cleanup_old_entries(cls):
"""
Enforce the cache size limit - keeping only the most recently used entries
up to RESULTS_CACHE_SIZE.
"""
# Get the cache size limit from settings
cache_size = dt_settings.get_config()["RESULTS_CACHE_SIZE"]

# Determine which entries to keep (the most recent ones up to cache_size)
keep_ids = list(
DebugToolbarEntry.objects.order_by("-created_at")[:cache_size].values_list(
"request_id", flat=True
)
)

# Delete all entries not in the keep list
if keep_ids:
DebugToolbarEntry.objects.exclude(request_id__in=keep_ids).delete()

@classmethod
def request_ids(cls):
"""Return all stored request ids within the cache size limit"""
cache_size = dt_settings.get_config()["RESULTS_CACHE_SIZE"]
return list(
DebugToolbarEntry.objects.order_by("-created_at")[:cache_size].values_list(
"request_id", flat=True
)
)

@classmethod
def exists(cls, request_id: str) -> bool:
"""Check if the given request_id exists in the store"""
return DebugToolbarEntry.objects.filter(request_id=request_id).exists()

@classmethod
def set(cls, request_id: str):
"""Set a request_id in the store and clean up old entries"""
# Create or update the entry
obj, created = DebugToolbarEntry.objects.get_or_create(request_id=request_id)
if not created:
# Update timestamp to mark as recently used
obj.created_at = timezone.now()
obj.save(update_fields=["created_at"])

# Enforce the cache size limit to clean up old entries
cls._cleanup_old_entries()

@classmethod
def clear(cls):
"""Remove all requests from the store"""
DebugToolbarEntry.objects.all().delete()

@classmethod
def delete(cls, request_id: str):
"""Delete the stored request for the given request_id"""
DebugToolbarEntry.objects.filter(request_id=request_id).delete()

@classmethod
def save_panel(cls, request_id: str, panel_id: str, data: Any = None):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to set(), do we want to use transaction.atomic() here as well?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment is showing under panel()... I wonder if somehow the comment shifted and it was meant for save_panel().

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant this comment for both methods.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per our call, there's a concern about panels updating this concurrently and needing to avoid clearing another panel's data when saving.

"""Save the panel data for the given request_id"""
# First ensure older entries are cleared if we exceed cache size
cls.set(request_id)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder what the set method is for. @tim-schilling , do you remember what the thinking behind it was when we have save_panel to really do saving?

The call can probably be removed here because we're immediately doing get_or_create afterwards, so if the entry doesn't exist already saving the panel data works anyway.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll push the rest of changes for now, while this is being discussed.

For reference, here's our the save_panel method was implemented in MemoryStore, including the set method call.

    @classmethod
    def save_panel(cls, request_id: str, panel_id: str, data: Any = None):
        """Save the panel data for the given request_id"""
        cls.set(request_id)
        cls._request_store[request_id][panel_id] = serialize(data)

We can also call _cleanup_old_entries directly rather than set to call _cleanup_old_entries, if I understand the purpose correctly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cls.set() is meant to create the record for the request in the toolbar's store. For the MemoryStore, there's a separate collection of ids from the actual store. I suspect it's for performance more than anything.


# Ensure the request exists
obj, _ = DebugToolbarEntry.objects.get_or_create(request_id=request_id)
store_data = obj.data
store_data[panel_id] = serialize(data)
obj.data = store_data
obj.save()

@classmethod
def panel(cls, request_id: str, panel_id: str) -> Any:
"""Fetch the panel data for the given request_id"""
try:
data = DebugToolbarEntry.objects.get(request_id=request_id).data
panel_data = data.get(panel_id)
if panel_data is None:
return {}
return deserialize(panel_data)
except DebugToolbarEntry.DoesNotExist:
return {}

@classmethod
def panels(cls, request_id: str) -> Any:
"""Fetch all panel data for the given request_id"""
try:
data = DebugToolbarEntry.objects.get(request_id=request_id).data
for panel_id, panel_data in data.items():
yield panel_id, deserialize(panel_data)
except DebugToolbarEntry.DoesNotExist:
return {}


def get_store() -> BaseStore:
return import_string(dt_settings.get_config()["TOOLBAR_STORE_CLASS"])
2 changes: 2 additions & 0 deletions docs/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Serializable (don't include in main)
* Update all panels to utilize data from ``Panel.get_stats()`` to load content
to render. Specifically for ``Panel.title`` and ``Panel.nav_title``.
* Extend example app to contain an async version.
* Added ``debug_toolbar.store.DatabaseStore`` for persistent debug data
storage.

Pending
-------
Expand Down
29 changes: 28 additions & 1 deletion docs/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ Toolbar options

Default: ``25``

The toolbar keeps up to this many results in memory.
The toolbar keeps up to this many results in memory or persistent storage.


.. _ROOT_TAG_EXTRA_ATTRS:

Expand Down Expand Up @@ -186,6 +187,24 @@ Toolbar options

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

Available store classes:

* ``debug_toolbar.store.MemoryStore`` - Stores data in memory
* ``debug_toolbar.store.DatabaseStore`` - Stores data in the database

The DatabaseStore provides persistence and automatically cleans up old
entries based on the ``RESULTS_CACHE_SIZE`` setting.

Note: For full functionality, DatabaseStore requires migrations for
the debug_toolbar app:

.. code-block:: bash

python manage.py migrate debug_toolbar

For the DatabaseStore to work properly, you need to run migrations for the
debug_toolbar app. The migrations create the necessary database table to store
toolbar data.

.. _TOOLBAR_LANGUAGE:

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

Here's an example of using a persistent store to keep debug data between server
restarts::

DEBUG_TOOLBAR_CONFIG = {
'TOOLBAR_STORE_CLASS': 'debug_toolbar.store.DatabaseStore',
'RESULTS_CACHE_SIZE': 100, # Store up to 100 requests
}

Theming support
---------------
The debug toolbar uses CSS variables to define fonts and colors. This allows
Expand Down
28 changes: 28 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import uuid

from django.test import TestCase

from debug_toolbar.models import DebugToolbarEntry


class DebugToolbarEntryTestCase(TestCase):
def test_str_method(self):
test_uuid = uuid.uuid4()
entry = DebugToolbarEntry(request_id=test_uuid)
self.assertEqual(str(entry), str(test_uuid))

def test_data_field_default(self):
"""Test that the data field defaults to an empty dict"""
entry = DebugToolbarEntry(request_id=uuid.uuid4())
self.assertEqual(entry.data, {})

def test_model_persistence(self):
"""Test saving and retrieving a model instance"""
test_uuid = uuid.uuid4()
entry = DebugToolbarEntry(request_id=test_uuid, data={"test": True})
entry.save()

# Retrieve from database and verify
saved_entry = DebugToolbarEntry.objects.get(request_id=test_uuid)
self.assertEqual(saved_entry.data, {"test": True})
self.assertEqual(str(saved_entry), str(test_uuid))
123 changes: 123 additions & 0 deletions tests/test_store.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import uuid

from django.test import TestCase
from django.test.utils import override_settings

Expand Down Expand Up @@ -109,3 +111,124 @@ def test_get_store(self):
)
def test_get_store_with_setting(self):
self.assertIs(store.get_store(), StubStore)


class DatabaseStoreTestCase(TestCase):
@classmethod
def setUpTestData(cls) -> None:
cls.store = store.DatabaseStore

def tearDown(self) -> None:
self.store.clear()

def test_ids(self):
id1 = str(uuid.uuid4())
id2 = str(uuid.uuid4())
self.store.set(id1)
self.store.set(id2)
# Convert the UUIDs to strings for comparison
request_ids = {str(id) for id in self.store.request_ids()}
self.assertEqual(request_ids, {id1, id2})

def test_exists(self):
missing_id = str(uuid.uuid4())
self.assertFalse(self.store.exists(missing_id))
id1 = str(uuid.uuid4())
self.store.set(id1)
self.assertTrue(self.store.exists(id1))

def test_set(self):
id1 = str(uuid.uuid4())
self.store.set(id1)
self.assertTrue(self.store.exists(id1))

def test_set_max_size(self):
with self.settings(DEBUG_TOOLBAR_CONFIG={"RESULTS_CACHE_SIZE": 1}):
# Clear any existing entries first
self.store.clear()

# Add first entry
id1 = str(uuid.uuid4())
self.store.save_panel(id1, "foo.panel", "foo.value")

# Verify it exists
self.assertTrue(self.store.exists(id1))
self.assertEqual(self.store.panel(id1, "foo.panel"), "foo.value")

# Add second entry, which should push out the first one due to size limit=1
id2 = str(uuid.uuid4())
self.store.save_panel(id2, "bar.panel", {"a": 1})

# Verify only the bar entry exists now
# Convert the UUIDs to strings for comparison
request_ids = {str(id) for id in self.store.request_ids()}
self.assertEqual(request_ids, {id2})
self.assertFalse(self.store.exists(id1))
self.assertEqual(self.store.panel(id1, "foo.panel"), {})
self.assertEqual(self.store.panel(id2, "bar.panel"), {"a": 1})

def test_clear(self):
id1 = str(uuid.uuid4())
self.store.save_panel(id1, "bar.panel", {"a": 1})
self.store.clear()
self.assertEqual(list(self.store.request_ids()), [])
self.assertEqual(self.store.panel(id1, "bar.panel"), {})

def test_delete(self):
id1 = str(uuid.uuid4())
self.store.save_panel(id1, "bar.panel", {"a": 1})
self.store.delete(id1)
self.assertEqual(list(self.store.request_ids()), [])
self.assertEqual(self.store.panel(id1, "bar.panel"), {})
# Make sure it doesn't error
self.store.delete(id1)

def test_save_panel(self):
id1 = str(uuid.uuid4())
self.store.save_panel(id1, "bar.panel", {"a": 1})
self.assertTrue(self.store.exists(id1))
self.assertEqual(self.store.panel(id1, "bar.panel"), {"a": 1})

def test_update_panel(self):
id1 = str(uuid.uuid4())
self.store.save_panel(id1, "test.panel", {"original": True})
self.assertEqual(self.store.panel(id1, "test.panel"), {"original": True})

# Update the panel
self.store.save_panel(id1, "test.panel", {"updated": True})
self.assertEqual(self.store.panel(id1, "test.panel"), {"updated": True})

def test_panels_nonexistent_request(self):
missing_id = str(uuid.uuid4())
panels = dict(self.store.panels(missing_id))
self.assertEqual(panels, {})

def test_panel(self):
id1 = str(uuid.uuid4())
missing_id = str(uuid.uuid4())
self.assertEqual(self.store.panel(missing_id, "missing"), {})
self.store.save_panel(id1, "bar.panel", {"a": 1})
self.assertEqual(self.store.panel(id1, "bar.panel"), {"a": 1})

def test_panels(self):
id1 = str(uuid.uuid4())
self.store.save_panel(id1, "panel1", {"a": 1})
self.store.save_panel(id1, "panel2", {"b": 2})
panels = dict(self.store.panels(id1))
self.assertEqual(len(panels), 2)
self.assertEqual(panels["panel1"], {"a": 1})
self.assertEqual(panels["panel2"], {"b": 2})

def test_cleanup_old_entries(self):
# Create multiple entries
ids = [str(uuid.uuid4()) for _ in range(5)]
for id in ids:
self.store.save_panel(id, "test.panel", {"test": True})

# Set a small cache size
with self.settings(DEBUG_TOOLBAR_CONFIG={"RESULTS_CACHE_SIZE": 2}):
# Trigger cleanup
self.store._cleanup_old_entries()

# Check that only the most recent 2 entries remain
self.assertEqual(len(list(self.store.request_ids())), 2)
Loading