From 4726e0faac417a68d90b471c92fa8f21b2130cb9 Mon Sep 17 00:00:00 2001 From: Aman Pandey Date: Tue, 6 Aug 2024 17:30:50 +0530 Subject: [PATCH 1/6] incoperate signals and contextvars to record staticfiles for concurrent requests --- debug_toolbar/panels/staticfiles.py | 52 +++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/debug_toolbar/panels/staticfiles.py b/debug_toolbar/panels/staticfiles.py index 061068a30..84d3e884a 100644 --- a/debug_toolbar/panels/staticfiles.py +++ b/debug_toolbar/panels/staticfiles.py @@ -1,9 +1,11 @@ import contextlib +import uuid from contextvars import ContextVar from os.path import join, normpath from django.conf import settings from django.contrib.staticfiles import finders, storage +from django.dispatch import Signal from django.utils.functional import LazyObject from django.utils.translation import gettext_lazy as _, ngettext @@ -29,7 +31,9 @@ def url(self): # This will collect the StaticFile instances across threads. -used_static_files = ContextVar("djdt_static_used_static_files") +used_static_files = ContextVar("djdt_static_used_static_files", default=[]) +request_id_context_var = ContextVar("djdt_request_id_store") +record_static_file_signal = Signal() class DebugConfiguredStorage(LazyObject): @@ -59,7 +63,12 @@ def url(self, path): # The ContextVar wasn't set yet. Since the toolbar wasn't properly # configured to handle this request, we don't need to capture # the static file. - used_static_files.get().append(StaticFile(path)) + request_id = request_id_context_var.get() + record_static_file_signal.send( + sender=self, + staticfile=StaticFile(path), + request_id=request_id, + ) return super().url(path) self._wrapped = DebugStaticFilesStorage() @@ -73,7 +82,7 @@ class StaticFilesPanel(panels.Panel): A panel to display the found staticfiles. """ - is_async = False + is_async = True name = "Static files" template = "debug_toolbar/panels/staticfiles.html" @@ -88,12 +97,25 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.num_found = 0 self.used_paths = [] + self.request_id = str(uuid.uuid4()) + + def _store_static_files_signal_handler(self, sender, staticfile, **kwargs): + with contextlib.suppress(LookupError): + # Only record the static file if the request_id matches the one + # that was used to create the panel. + # as sender of the signal and this handler will have multiple + # concurrent connections and we want to avoid storing of same + # staticfile from other connections as well. + if request_id_context_var.get() == self.request_id: + used_static_files.get().append(staticfile) def enable_instrumentation(self): storage.staticfiles_storage = DebugConfiguredStorage() + record_static_file_signal.connect(self._store_static_files_signal_handler) + request_id_context_var.set(self.request_id) def disable_instrumentation(self): - storage.staticfiles_storage = _original_storage + record_static_file_signal.disconnect(self._store_static_files_signal_handler) @property def num_used(self): @@ -109,18 +131,20 @@ def nav_subtitle(self): "%(num_used)s file used", "%(num_used)s files used", num_used ) % {"num_used": num_used} - def process_request(self, request): - reset_token = used_static_files.set([]) - response = super().process_request(request) - # Make a copy of the used paths so that when the - # ContextVar is reset, our panel still has the data. - self.used_paths = used_static_files.get().copy() - # Reset the ContextVar to be empty again, removing the reference - # to the list of used files. - used_static_files.reset(reset_token) - return response + # def process_request(self, request): + # reset_token = used_static_files.set([]) + # response = super().process_request(request) + # # Make a copy of the used paths so that when the + # # ContextVar is reset, our panel still has the data. + # self.used_paths = used_static_files.get().copy() + # # Reset the ContextVar to be empty again, removing the reference + # # to the list of used files. + # used_static_files.reset(reset_token) + # return response def generate_stats(self, request, response): + self.used_paths = used_static_files.get().copy() + used_static_files.get().clear() self.record_stats( { "num_found": self.num_found, From d5bdcc3962fdc304b6c1655a5bbb6a5d05460038 Mon Sep 17 00:00:00 2001 From: Aman Pandey Date: Thu, 8 Aug 2024 20:18:54 +0530 Subject: [PATCH 2/6] remove used_static_files contextvar and its dependencies allow on app ready monkey patching --- debug_toolbar/panels/staticfiles.py | 40 +++++++++++------------------ 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/debug_toolbar/panels/staticfiles.py b/debug_toolbar/panels/staticfiles.py index 84d3e884a..ea50968b6 100644 --- a/debug_toolbar/panels/staticfiles.py +++ b/debug_toolbar/panels/staticfiles.py @@ -30,8 +30,8 @@ def url(self): return storage.staticfiles_storage.url(self.path) -# This will collect the StaticFile instances across threads. -used_static_files = ContextVar("djdt_static_used_static_files", default=[]) +# This will record and map the StaticFile instances with its associated +# request across threads and async concurrent requests state. request_id_context_var = ContextVar("djdt_request_id_store") record_static_file_signal = Signal() @@ -99,23 +99,26 @@ def __init__(self, *args, **kwargs): self.used_paths = [] self.request_id = str(uuid.uuid4()) + @classmethod + def ready(cls): + storage.staticfiles_storage = DebugConfiguredStorage() + def _store_static_files_signal_handler(self, sender, staticfile, **kwargs): - with contextlib.suppress(LookupError): - # Only record the static file if the request_id matches the one - # that was used to create the panel. - # as sender of the signal and this handler will have multiple - # concurrent connections and we want to avoid storing of same - # staticfile from other connections as well. - if request_id_context_var.get() == self.request_id: - used_static_files.get().append(staticfile) + # Only record the static file if the request_id matches the one + # that was used to create the panel. + # as sender of the signal and this handler will have multiple + # concurrent connections and we want to avoid storing of same + # staticfile from other connections as well. + if request_id_context_var.get() == self.request_id: + self.used_paths.append(staticfile) def enable_instrumentation(self): - storage.staticfiles_storage = DebugConfiguredStorage() record_static_file_signal.connect(self._store_static_files_signal_handler) - request_id_context_var.set(self.request_id) + self.ctx_token = request_id_context_var.set(self.request_id) def disable_instrumentation(self): record_static_file_signal.disconnect(self._store_static_files_signal_handler) + request_id_context_var.reset(self.ctx_token) @property def num_used(self): @@ -131,20 +134,7 @@ def nav_subtitle(self): "%(num_used)s file used", "%(num_used)s files used", num_used ) % {"num_used": num_used} - # def process_request(self, request): - # reset_token = used_static_files.set([]) - # response = super().process_request(request) - # # Make a copy of the used paths so that when the - # # ContextVar is reset, our panel still has the data. - # self.used_paths = used_static_files.get().copy() - # # Reset the ContextVar to be empty again, removing the reference - # # to the list of used files. - # used_static_files.reset(reset_token) - # return response - def generate_stats(self, request, response): - self.used_paths = used_static_files.get().copy() - used_static_files.get().clear() self.record_stats( { "num_found": self.num_found, From 182e90bf60c3bad7aefe0b613058ad241313f319 Mon Sep 17 00:00:00 2001 From: Aman Pandey Date: Mon, 19 Aug 2024 20:55:02 +0530 Subject: [PATCH 3/6] async static files panel test --- tests/panels/test_staticfiles.py | 13 +++++++++++++ tests/templates/base.html | 1 + tests/templates/staticfiles/async_static.html | 6 ++++++ 3 files changed, 20 insertions(+) create mode 100644 tests/templates/staticfiles/async_static.html diff --git a/tests/panels/test_staticfiles.py b/tests/panels/test_staticfiles.py index 0736d86ed..11e89e1a2 100644 --- a/tests/panels/test_staticfiles.py +++ b/tests/panels/test_staticfiles.py @@ -1,5 +1,7 @@ from django.conf import settings from django.contrib.staticfiles import finders +from django.shortcuts import render +from django.test import AsyncRequestFactory from ..base import BaseTestCase @@ -27,6 +29,17 @@ def test_default_case(self): self.panel.get_staticfiles_dirs(), finders.FileSystemFinder().locations ) + async def test_store_staticfiles_with_async_context(self): + async def get_response(request): + # template contains one static file + return render(request, "staticfiles/async_static.html") + + self._get_response = get_response + async_request = AsyncRequestFactory().get("/") + response = await self.panel.process_request(async_request) + self.panel.generate_stats(self.request, response) + self.assertNotEqual(self.panel.num_used, 0) + def test_insert_content(self): """ Test that the panel only inserts content after generate_stats and diff --git a/tests/templates/base.html b/tests/templates/base.html index ea0d773ac..272c316f0 100644 --- a/tests/templates/base.html +++ b/tests/templates/base.html @@ -2,6 +2,7 @@ {{ title }} + {% block head %}{% endblock %} {% block content %}{% endblock %} diff --git a/tests/templates/staticfiles/async_static.html b/tests/templates/staticfiles/async_static.html new file mode 100644 index 000000000..fc0c9b885 --- /dev/null +++ b/tests/templates/staticfiles/async_static.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} +{% load static %} + +{% block head %} + +{% endblock %} From 0c3dbe58d92222e69c1e0273918e645e9cb45c78 Mon Sep 17 00:00:00 2001 From: Aman Pandey Date: Mon, 19 Aug 2024 20:56:39 +0530 Subject: [PATCH 4/6] update doc --- docs/architecture.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.rst b/docs/architecture.rst index 145676459..e30aabbe8 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -81,7 +81,7 @@ Problematic Parts the main benefit of the toolbar - Support for async and multi-threading: ``debug_toolbar.middleware.DebugToolbarMiddleware`` is now async compatible and can process async requests. However certain - panels such as ``SQLPanel``, ``TimerPanel``, ``StaticFilesPanel``, + panels such as ``SQLPanel``, ``TimerPanel``, ``RequestPanel``, ``RedirectsPanel`` and ``ProfilingPanel`` aren't fully compatible and currently being worked on. For now, these panels are disabled by default when running in async environment. From cf0d2a0c95dba39fa4cce9346619472ed67dc7ee Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Tue, 20 Aug 2024 07:10:56 -0500 Subject: [PATCH 5/6] Code review changes --- debug_toolbar/panels/staticfiles.py | 2 +- tests/panels/test_staticfiles.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/debug_toolbar/panels/staticfiles.py b/debug_toolbar/panels/staticfiles.py index ea50968b6..b0997404c 100644 --- a/debug_toolbar/panels/staticfiles.py +++ b/debug_toolbar/panels/staticfiles.py @@ -113,8 +113,8 @@ def _store_static_files_signal_handler(self, sender, staticfile, **kwargs): self.used_paths.append(staticfile) def enable_instrumentation(self): - record_static_file_signal.connect(self._store_static_files_signal_handler) self.ctx_token = request_id_context_var.set(self.request_id) + record_static_file_signal.connect(self._store_static_files_signal_handler) def disable_instrumentation(self): record_static_file_signal.disconnect(self._store_static_files_signal_handler) diff --git a/tests/panels/test_staticfiles.py b/tests/panels/test_staticfiles.py index 11e89e1a2..3caedc4eb 100644 --- a/tests/panels/test_staticfiles.py +++ b/tests/panels/test_staticfiles.py @@ -38,7 +38,7 @@ async def get_response(request): async_request = AsyncRequestFactory().get("/") response = await self.panel.process_request(async_request) self.panel.generate_stats(self.request, response) - self.assertNotEqual(self.panel.num_used, 0) + self.assertEqual(self.panel.num_used, 1) def test_insert_content(self): """ From ed40ead5dc1d5083d16a1960a6ec6e0f5819e4c2 Mon Sep 17 00:00:00 2001 From: Aman Pandey Date: Tue, 20 Aug 2024 17:55:18 +0530 Subject: [PATCH 6/6] suggested changes --- debug_toolbar/panels/staticfiles.py | 2 +- tests/panels/test_staticfiles.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/debug_toolbar/panels/staticfiles.py b/debug_toolbar/panels/staticfiles.py index ea50968b6..b0997404c 100644 --- a/debug_toolbar/panels/staticfiles.py +++ b/debug_toolbar/panels/staticfiles.py @@ -113,8 +113,8 @@ def _store_static_files_signal_handler(self, sender, staticfile, **kwargs): self.used_paths.append(staticfile) def enable_instrumentation(self): - record_static_file_signal.connect(self._store_static_files_signal_handler) self.ctx_token = request_id_context_var.set(self.request_id) + record_static_file_signal.connect(self._store_static_files_signal_handler) def disable_instrumentation(self): record_static_file_signal.disconnect(self._store_static_files_signal_handler) diff --git a/tests/panels/test_staticfiles.py b/tests/panels/test_staticfiles.py index 11e89e1a2..3caedc4eb 100644 --- a/tests/panels/test_staticfiles.py +++ b/tests/panels/test_staticfiles.py @@ -38,7 +38,7 @@ async def get_response(request): async_request = AsyncRequestFactory().get("/") response = await self.panel.process_request(async_request) self.panel.generate_stats(self.request, response) - self.assertNotEqual(self.panel.num_used, 0) + self.assertEqual(self.panel.num_used, 1) def test_insert_content(self): """