Skip to content

Commit 47d4eed

Browse files
Switch StaticFilesPanel to use ContextVar. (#1801)
The StaticFilesPanel thread collector was not closing connections to the database. This approach allows those connections to be closed while still collecting context across threads. The test case is to hit an admin login screen with ab -n 200 http://localhost:8000/admin/login/ As far as I can tell, all the static files are collected properly and connections don't stay open.
1 parent 58f5ae3 commit 47d4eed

File tree

3 files changed

+25
-65
lines changed

3 files changed

+25
-65
lines changed

debug_toolbar/panels/staticfiles.py

Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import contextlib
2+
from contextvars import ContextVar
13
from os.path import join, normpath
24

35
from django.conf import settings
@@ -7,12 +9,6 @@
79
from django.utils.translation import gettext_lazy as _, ngettext
810

911
from debug_toolbar import panels
10-
from debug_toolbar.utils import ThreadCollector
11-
12-
try:
13-
import threading
14-
except ImportError:
15-
threading = None
1612

1713

1814
class StaticFile:
@@ -33,15 +29,8 @@ def url(self):
3329
return storage.staticfiles_storage.url(self.path)
3430

3531

36-
class FileCollector(ThreadCollector):
37-
def collect(self, path, thread=None):
38-
# handle the case of {% static "admin/" %}
39-
if path.endswith("/"):
40-
return
41-
super().collect(StaticFile(path), thread)
42-
43-
44-
collector = FileCollector()
32+
# This will collect the StaticFile instances across threads.
33+
used_static_files = ContextVar("djdt_static_used_static_files")
4534

4635

4736
class DebugConfiguredStorage(LazyObject):
@@ -65,15 +54,16 @@ def _setup(self):
6554
configured_storage_cls = get_storage_class(settings.STATICFILES_STORAGE)
6655

6756
class DebugStaticFilesStorage(configured_storage_cls):
68-
def __init__(self, collector, *args, **kwargs):
69-
super().__init__(*args, **kwargs)
70-
self.collector = collector
71-
7257
def url(self, path):
73-
self.collector.collect(path)
58+
with contextlib.suppress(LookupError):
59+
# For LookupError:
60+
# The ContextVar wasn't set yet. Since the toolbar wasn't properly
61+
# configured to handle this request, we don't need to capture
62+
# the static file.
63+
used_static_files.get().append(StaticFile(path))
7464
return super().url(path)
7565

76-
self._wrapped = DebugStaticFilesStorage(collector)
66+
self._wrapped = DebugStaticFilesStorage()
7767

7868

7969
_original_storage = storage.staticfiles_storage
@@ -97,7 +87,7 @@ def title(self):
9787
def __init__(self, *args, **kwargs):
9888
super().__init__(*args, **kwargs)
9989
self.num_found = 0
100-
self._paths = {}
90+
self.used_paths = []
10191

10292
def enable_instrumentation(self):
10393
storage.staticfiles_storage = DebugConfiguredStorage()
@@ -120,18 +110,22 @@ def nav_subtitle(self):
120110
) % {"num_used": num_used}
121111

122112
def process_request(self, request):
123-
collector.clear_collection()
124-
return super().process_request(request)
113+
reset_token = used_static_files.set([])
114+
response = super().process_request(request)
115+
# Make a copy of the used paths so that when the
116+
# ContextVar is reset, our panel still has the data.
117+
self.used_paths = used_static_files.get().copy()
118+
# Reset the ContextVar to be empty again, removing the reference
119+
# to the list of used files.
120+
used_static_files.reset(reset_token)
121+
return response
125122

126123
def generate_stats(self, request, response):
127-
used_paths = collector.get_collection()
128-
self._paths[threading.current_thread()] = used_paths
129-
130124
self.record_stats(
131125
{
132126
"num_found": self.num_found,
133-
"num_used": len(used_paths),
134-
"staticfiles": used_paths,
127+
"num_used": len(self.used_paths),
128+
"staticfiles": self.used_paths,
135129
"staticfiles_apps": self.get_staticfiles_apps(),
136130
"staticfiles_dirs": self.get_staticfiles_dirs(),
137131
"staticfiles_finders": self.get_staticfiles_finders(),

debug_toolbar/utils.py

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,6 @@
1414

1515
from debug_toolbar import _stubs as stubs, settings as dt_settings
1616

17-
try:
18-
import threading
19-
except ImportError:
20-
threading = None
21-
22-
2317
_local_data = Local()
2418

2519

@@ -357,33 +351,3 @@ def get_stack_trace(*, skip=0):
357351
def clear_stack_trace_caches():
358352
if hasattr(_local_data, "stack_trace_recorder"):
359353
del _local_data.stack_trace_recorder
360-
361-
362-
class ThreadCollector:
363-
def __init__(self):
364-
if threading is None:
365-
raise NotImplementedError(
366-
"threading module is not available, "
367-
"this panel cannot be used without it"
368-
)
369-
self.collections = {} # a dictionary that maps threads to collections
370-
371-
def get_collection(self, thread=None):
372-
"""
373-
Returns a list of collected items for the provided thread, of if none
374-
is provided, returns a list for the current thread.
375-
"""
376-
if thread is None:
377-
thread = threading.current_thread()
378-
if thread not in self.collections:
379-
self.collections[thread] = []
380-
return self.collections[thread]
381-
382-
def clear_collection(self, thread=None):
383-
if thread is None:
384-
thread = threading.current_thread()
385-
if thread in self.collections:
386-
del self.collections[thread]
387-
388-
def collect(self, item, thread=None):
389-
self.get_collection(thread).append(item)

docs/changes.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Pending
99
<https://beta.ruff.rs/>`__.
1010
* Converted cookie keys to lowercase. Fixed the ``samesite`` argument to
1111
``djdt.cookie.set``.
12+
* Converted ``StaticFilesPanel`` to no longer use a thread collector. Instead,
13+
it collects the used static files in a ``ContextVar``.
1214

1315
4.1.0 (2023-05-15)
1416
------------------

0 commit comments

Comments
 (0)