Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ Contributors
- AllinolCP, `AllinolCP@github <https://github.com/AllinolCP>`_
- uncle-lv, `uncle-lv@github <https://github.com/uncle-lv>`_
- Vitaliy, `vitalivo@github <https://github.com/vitalivo>`_
- fhgffy, `fhgffy@github <https://github.com/fhgffy>`_
4 changes: 3 additions & 1 deletion src/cacheout/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ def reset(self) -> None:

def info(self) -> CacheStats:
"""Return a snapshot of the cache statistics."""
# 2026-10-11: Read the cache before taking the statistics lock to avoid inverted lock order.
entry_count = len(self._cache)
with self._lock:
self._stats.entry_count = len(self._cache)
self._stats.entry_count = entry_count
return self._stats.copy()
61 changes: 61 additions & 0 deletions tests/test_stats.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from threading import Event, RLock, Thread

import pytest

from cacheout import Cache
Expand Down Expand Up @@ -34,6 +36,65 @@ def test_stats_tracker_info(cache: Cache):
assert info.entry_count == 2


def test_stats_tracker_info_does_not_invert_cache_lock(monkeypatch):
# 2026-10-11: Coordinate both lock acquisitions without leaving blocked threads behind.
cache_locked = Event()
reading_length = Event()
errors = []
snapshots = []

class CoordinatedCache(Cache):
def __len__(self):
reading_length.set()
return super().__len__()

class BoundedLock:
def __init__(self):
self.lock = RLock()

def __enter__(self):
if not self.lock.acquire(timeout=5):
raise TimeoutError("statistics lock blocked by the snapshot")
return self

def __exit__(self, *args):
self.lock.release()

cache = CoordinatedCache(enable_stats=True)
cache.set("key", "value")
reading_length.clear()
monkeypatch.setattr(cache.stats, "_lock", BoundedLock())

def get_value():
try:
with cache._lock:
cache_locked.set()
assert reading_length.wait(5)
assert cache.get("key") == "value"
except Exception as exc:
errors.append(exc)

def get_info():
try:
snapshots.append(cache.stats.info())
except Exception as exc:
errors.append(exc)

getter = Thread(target=get_value, daemon=True)
getter.start()
assert cache_locked.wait(5)
reader = Thread(target=get_info, daemon=True)
reader.start()
getter.join(6)
reader.join(6)
assert not getter.is_alive()
assert not reader.is_alive()
assert errors == []
assert len(snapshots) == 1
assert snapshots[0].hit_count == 1
assert snapshots[0].entry_count == 1


def test_stats_tracker_info_to_dict(cache: Cache):
info = cache.stats.info()
expected = {
Expand Down
Loading