From 41911ad51df1856853494c4aade89d6b030d5b2b Mon Sep 17 00:00:00 2001 From: Harsh Singhaniya Date: Thu, 16 Jul 2026 01:24:20 +0530 Subject: [PATCH 1/8] Enhance cache panel to accurately track cache hits and misses for get() method --- debug_toolbar/panels/cache.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/debug_toolbar/panels/cache.py b/debug_toolbar/panels/cache.py index 24c942de7..2b94544c7 100644 --- a/debug_toolbar/panels/cache.py +++ b/debug_toolbar/panels/cache.py @@ -65,6 +65,7 @@ class CachePanel(Panel): is_async = True _context_locals = Local() + _missing_key = object() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -116,7 +117,12 @@ def _store_call_info( template_info, backend, ): - if name == "get" or name == "get_or_set": + if name == "get": + if return_value is self._missing_key: + self.misses += 1 + else: + self.hits += 1 + elif name == "get_or_set": if return_value is None: self.misses += 1 else: @@ -149,9 +155,22 @@ def _record_call(self, cache, alias, name, original_method, args, kwargs): # monkey-patched cache methods to skip recording additional calls made during # the course of this call, and then reset it back afterward. cache._djdt_panel = None + user_default = None try: start_time = perf_counter() - value = original_method(*args, **kwargs) + if name == "get": + user_default = kwargs.get("default", args[1] if len(args) > 1 else None) + # Replace the caller's default with an internal sentinel so a cache miss + # can be distinguished from a cached value equal to the supplied default. + if "default" in kwargs: + call_args = args + call_kwargs = {**kwargs, "default": self._missing_key} + else: + call_args = (args[0], self._missing_key, *args[2:]) + call_kwargs = kwargs + value = original_method(*call_args, **call_kwargs) + else: + value = original_method(*args, **kwargs) t = perf_counter() - start_time finally: cache._djdt_panel = self @@ -166,6 +185,10 @@ def _record_call(self, cache, alias, name, original_method, args, kwargs): template_info=get_template_info(), backend=f"{alias} ({type(cache).__name__})", ) + # Preserve the original cache.get() behavior by returning the caller's + # default instead of the internal sentinel on a cache miss. + if name == "get" and value is self._missing_key: + return user_default return value # Implement the Panel API From 6aa94f9f2ef770c7117285600a42e88a4f5a698f Mon Sep 17 00:00:00 2001 From: Harsh Singhaniya Date: Thu, 16 Jul 2026 01:24:33 +0530 Subject: [PATCH 2/8] Add tests for cache hits and misses with None and default values --- tests/panels/test_cache.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/panels/test_cache.py b/tests/panels/test_cache.py index fff28280b..e29cf0baa 100644 --- a/tests/panels/test_cache.py +++ b/tests/panels/test_cache.py @@ -45,6 +45,37 @@ def test_hits_and_misses(self): self.assertEqual(self.panel.hits, 4) self.assertEqual(self.panel.misses, 2) + def test_cached_none_counts_as_hit(self): + cache.cache.clear() + + cache.cache.set("foo", None) + self.assertIsNone(cache.cache.get("foo")) + self.assertEqual(self.panel.hits, 1) + self.assertEqual(self.panel.misses, 0) + + def test_missing_key_with_default_counts_as_miss(self): + cache.cache.clear() + + self.assertIsNone(cache.cache.get("foo", None)) + self.assertEqual(self.panel.hits, 0) + self.assertEqual(self.panel.misses, 1) + + def test_missing_key_returns_supplied_default(self): + cache.cache.clear() + + self.assertEqual(cache.cache.get("foo", "bar"), "bar") + self.assertEqual(self.panel.hits, 0) + self.assertEqual(self.panel.misses, 1) + + def test_cached_value_equal_to_default_counts_as_hit(self): + cache.cache.clear() + + cache.cache.set("foo", "bar") + self.assertEqual(cache.cache.get("foo", "bar"), "bar") + + self.assertEqual(self.panel.hits, 1) + self.assertEqual(self.panel.misses, 0) + def test_get_or_set_value(self): cache.cache.get_or_set("baz", "val") self.assertEqual(cache.cache.get("baz"), "val") From 391d39ceb0a38158fdadfd9e303f45cd605cb272 Mon Sep 17 00:00:00 2001 From: Harsh Singhaniya Date: Thu, 16 Jul 2026 01:24:47 +0530 Subject: [PATCH 3/8] Update pending section in changes.rst --- docs/changes.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/changes.rst b/docs/changes.rst index 5b3156bbc..7ae2df9a8 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -22,6 +22,10 @@ Pending before the panel script has loaded, which navigated away from the page. * Added support for Django 6.1. +* Fixed incorrect cache hit/miss reporting in the Cache panel for + ``cache.get()`` when a default value is supplied or the cached value is + ``None``. + 7.0.0 (2026-06-17) ------------------ From ce6831ad64656289b938d8496d3284238142e09f Mon Sep 17 00:00:00 2001 From: Harsh Singhaniya Date: Fri, 24 Jul 2026 21:33:44 +0530 Subject: [PATCH 4/8] Fix cache hit/miss tracking for keyword arguments in get() method --- debug_toolbar/panels/cache.py | 11 +++++++---- tests/panels/test_cache.py | 22 ++++++++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/debug_toolbar/panels/cache.py b/debug_toolbar/panels/cache.py index 2b94544c7..bea346ef9 100644 --- a/debug_toolbar/panels/cache.py +++ b/debug_toolbar/panels/cache.py @@ -149,7 +149,7 @@ def _store_call_info( def _record_call(self, cache, alias, name, original_method, args, kwargs): # Some cache backends implement certain cache methods in terms of other cache - # methods (e.g. get_or_set() in terms of get() and add()). In order to only + # methods (e.g. get_or_set() in terms of get() and add()). In order to only # record the calls made directly by the user code, set the cache's _djdt_panel # attribute to None before invoking the original method, which will cause the # monkey-patched cache methods to skip recording additional calls made during @@ -162,12 +162,15 @@ def _record_call(self, cache, alias, name, original_method, args, kwargs): user_default = kwargs.get("default", args[1] if len(args) > 1 else None) # Replace the caller's default with an internal sentinel so a cache miss # can be distinguished from a cached value equal to the supplied default. + call_args = args + call_kwargs = kwargs if "default" in kwargs: - call_args = args call_kwargs = {**kwargs, "default": self._missing_key} - else: + elif args: call_args = (args[0], self._missing_key, *args[2:]) - call_kwargs = kwargs + else: + # Key was supplied as a keyword argument. + call_kwargs = {**kwargs, "default": self._missing_key} value = original_method(*call_args, **call_kwargs) else: value = original_method(*args, **kwargs) diff --git a/tests/panels/test_cache.py b/tests/panels/test_cache.py index e29cf0baa..5992e6cea 100644 --- a/tests/panels/test_cache.py +++ b/tests/panels/test_cache.py @@ -76,6 +76,28 @@ def test_cached_value_equal_to_default_counts_as_hit(self): self.assertEqual(self.panel.hits, 1) self.assertEqual(self.panel.misses, 0) + def test_get_with_keyword_default_is_cache_miss(self): + cache.cache.clear() + + self.assertEqual(cache.cache.get("foo", default="default"), "default") + self.assertEqual(self.panel.hits, 0) + self.assertEqual(self.panel.misses, 1) + + def test_get_with_keyword_key_and_default_is_cache_miss(self): + cache.cache.clear() + + self.assertEqual(cache.cache.get(key="foo", default="default"), "default") + self.assertEqual(self.panel.hits, 0) + self.assertEqual(self.panel.misses, 1) + + def test_get_with_keyword_key_and_version(self): + cache.cache.clear() + + cache.cache.set("foo", "bar", version=1) + self.assertEqual(cache.cache.get(key="foo", version=1), "bar") + self.assertEqual(self.panel.hits, 1) + self.assertEqual(self.panel.misses, 0) + def test_get_or_set_value(self): cache.cache.get_or_set("baz", "val") self.assertEqual(cache.cache.get("baz"), "val") From 15f47bf8be96abc71b38f0143615ea5630f44ff9 Mon Sep 17 00:00:00 2001 From: Harsh Singhaniya Date: Tue, 4 Aug 2026 23:55:01 +0530 Subject: [PATCH 5/8] Add test for get_or_set with None to count as cache miss --- tests/panels/test_cache.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/panels/test_cache.py b/tests/panels/test_cache.py index 5992e6cea..c2f33dde4 100644 --- a/tests/panels/test_cache.py +++ b/tests/panels/test_cache.py @@ -167,6 +167,13 @@ def test_get_or_set_does_not_override_existing_value(self): }, ) + def test_get_or_set_none_counts_as_miss(self): + cache.cache.clear() + + self.assertIsNone(cache.cache.get_or_set("foo", None)) + self.assertEqual(self.panel.hits, 0) + self.assertEqual(self.panel.misses, 1) + def test_insert_content(self): """ Test that the panel only inserts content after generate_stats and From 76af1059c7bdb2f278c49b9c2dbfe66db9132930 Mon Sep 17 00:00:00 2001 From: Harsh Singhaniya Date: Tue, 11 Aug 2026 01:21:32 +0530 Subject: [PATCH 6/8] Refactor cache hit/miss logic in get() method and update related tests --- debug_toolbar/panels/cache.py | 26 +++----------------------- tests/panels/test_cache.py | 23 +++++++++++------------ 2 files changed, 14 insertions(+), 35 deletions(-) diff --git a/debug_toolbar/panels/cache.py b/debug_toolbar/panels/cache.py index bea346ef9..20b2faa9b 100644 --- a/debug_toolbar/panels/cache.py +++ b/debug_toolbar/panels/cache.py @@ -65,7 +65,6 @@ class CachePanel(Panel): is_async = True _context_locals = Local() - _missing_key = object() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -118,7 +117,8 @@ def _store_call_info( backend, ): if name == "get": - if return_value is self._missing_key: + default = kwargs.get("default", args[1] if len(args) > 1 else None) + if return_value == default: self.misses += 1 else: self.hits += 1 @@ -155,25 +155,9 @@ def _record_call(self, cache, alias, name, original_method, args, kwargs): # monkey-patched cache methods to skip recording additional calls made during # the course of this call, and then reset it back afterward. cache._djdt_panel = None - user_default = None try: start_time = perf_counter() - if name == "get": - user_default = kwargs.get("default", args[1] if len(args) > 1 else None) - # Replace the caller's default with an internal sentinel so a cache miss - # can be distinguished from a cached value equal to the supplied default. - call_args = args - call_kwargs = kwargs - if "default" in kwargs: - call_kwargs = {**kwargs, "default": self._missing_key} - elif args: - call_args = (args[0], self._missing_key, *args[2:]) - else: - # Key was supplied as a keyword argument. - call_kwargs = {**kwargs, "default": self._missing_key} - value = original_method(*call_args, **call_kwargs) - else: - value = original_method(*args, **kwargs) + value = original_method(*args, **kwargs) t = perf_counter() - start_time finally: cache._djdt_panel = self @@ -188,10 +172,6 @@ def _record_call(self, cache, alias, name, original_method, args, kwargs): template_info=get_template_info(), backend=f"{alias} ({type(cache).__name__})", ) - # Preserve the original cache.get() behavior by returning the caller's - # default instead of the internal sentinel on a cache miss. - if name == "get" and value is self._missing_key: - return user_default return value # Implement the Panel API diff --git a/tests/panels/test_cache.py b/tests/panels/test_cache.py index c2f33dde4..66f86d384 100644 --- a/tests/panels/test_cache.py +++ b/tests/panels/test_cache.py @@ -45,36 +45,35 @@ def test_hits_and_misses(self): self.assertEqual(self.panel.hits, 4) self.assertEqual(self.panel.misses, 2) - def test_cached_none_counts_as_hit(self): + def test_cached_none_with_default_none_is_ambiguous(self): cache.cache.clear() cache.cache.set("foo", None) self.assertIsNone(cache.cache.get("foo")) - self.assertEqual(self.panel.hits, 1) - self.assertEqual(self.panel.misses, 0) + self.assertEqual(self.panel.hits, 0) + self.assertEqual(self.panel.misses, 1) - def test_missing_key_with_default_counts_as_miss(self): + def test_cached_value_equal_to_default_is_ambiguous(self): cache.cache.clear() - self.assertIsNone(cache.cache.get("foo", None)) + cache.cache.set("foo", "bar") + self.assertEqual(cache.cache.get("foo", "bar"), "bar") self.assertEqual(self.panel.hits, 0) self.assertEqual(self.panel.misses, 1) - def test_missing_key_returns_supplied_default(self): + def test_missing_key_with_default_counts_as_miss(self): cache.cache.clear() - self.assertEqual(cache.cache.get("foo", "bar"), "bar") + self.assertIsNone(cache.cache.get("foo", None)) self.assertEqual(self.panel.hits, 0) self.assertEqual(self.panel.misses, 1) - def test_cached_value_equal_to_default_counts_as_hit(self): + def test_missing_key_returns_supplied_default(self): cache.cache.clear() - cache.cache.set("foo", "bar") self.assertEqual(cache.cache.get("foo", "bar"), "bar") - - self.assertEqual(self.panel.hits, 1) - self.assertEqual(self.panel.misses, 0) + self.assertEqual(self.panel.hits, 0) + self.assertEqual(self.panel.misses, 1) def test_get_with_keyword_default_is_cache_miss(self): cache.cache.clear() From 91443f5826366a196359a08b82c631d18bb19963 Mon Sep 17 00:00:00 2001 From: Harsh Singhaniya Date: Tue, 11 Aug 2026 01:22:48 +0530 Subject: [PATCH 7/8] Add cache hit/miss note to cache panel and documentation --- debug_toolbar/static/debug_toolbar/css/toolbar.css | 9 ++++++++- debug_toolbar/templates/debug_toolbar/panels/cache.html | 5 +++++ docs/panels.rst | 6 ++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/debug_toolbar/static/debug_toolbar/css/toolbar.css b/debug_toolbar/static/debug_toolbar/css/toolbar.css index ad0d58df9..a7000a95a 100644 --- a/debug_toolbar/static/debug_toolbar/css/toolbar.css +++ b/debug_toolbar/static/debug_toolbar/css/toolbar.css @@ -348,7 +348,14 @@ height: 50px; } -#djDebug .djDebugPanelTitle code { +#djDebug .djdt-cache-note { + margin: 1em 0; + padding: 8px 10px; + border: 1px solid var(--djdt-table-border-color); + background-color: var(--djdt-panel-content-table-strip-background-color); +} +#djDebug .djDebugPanelTitle code, +#djDebug .djdt-cache-note code { display: inline; font-size: inherit; } diff --git a/debug_toolbar/templates/debug_toolbar/panels/cache.html b/debug_toolbar/templates/debug_toolbar/panels/cache.html index 04c7d37a1..f2ebe8c3d 100644 --- a/debug_toolbar/templates/debug_toolbar/panels/cache.html +++ b/debug_toolbar/templates/debug_toolbar/panels/cache.html @@ -18,6 +18,11 @@

{% translate "Summary" %}

+

+ Cache hit/miss statistics for cache.get() calls may not always be accurate. + See the discussion + for details and to share feedback about improving cache hit/miss tracking. +

{% translate "Commands" %}

diff --git a/docs/panels.rst b/docs/panels.rst index c9ce5ab63..e6d0e5f26 100644 --- a/docs/panels.rst +++ b/docs/panels.rst @@ -101,6 +101,12 @@ Cache Cache queries. Is incompatible with Django's per-site caching. +Cache hit/miss statistics for ``cache.get()`` calls may not always be +accurate. See the `discussion`_ for details and to share feedback about +improving cache hit/miss tracking. + +.. _discussion: https://github.com/django-commons/django-debug-toolbar/discussions/2441 + Signals ~~~~~~~ From e134f2e9508a56a3c2b0e7131c1bcfb69b935e53 Mon Sep 17 00:00:00 2001 From: Harsh Singhaniya Date: Tue, 11 Aug 2026 01:45:49 +0530 Subject: [PATCH 8/8] docs: clarify cache hit/miss reporting changes --- docs/changes.rst | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 7ae2df9a8..f2c9c89b8 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -21,10 +21,9 @@ Pending * Stopped the history panel buttons from submitting their form when clicked before the panel script has loaded, which navigated away from the page. * Added support for Django 6.1. - -* Fixed incorrect cache hit/miss reporting in the Cache panel for - ``cache.get()`` when a default value is supplied or the cached value is - ``None``. +* Improved cache hit/miss reporting in the Cache panel for ``cache.get()`` + calls with a supplied default value, while documenting the remaining + ambiguity when a cached value equals the supplied default. 7.0.0 (2026-06-17) ------------------