-
Notifications
You must be signed in to change notification settings - Fork 44
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat: cache statistics * refactor: redesign APIs * perf: remove unnecessary locks * refactor stats
- Loading branch information
Showing
3 changed files
with
329 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,177 @@ | ||
import copy | ||
from threading import RLock | ||
from typing import TYPE_CHECKING | ||
|
||
|
||
if TYPE_CHECKING: | ||
from .cache import Cache # pragma: no cover | ||
|
||
|
||
class Stats: | ||
""" | ||
An object to represent a snapshot of statistics. | ||
Attributes: | ||
_hits: The number of cache hits. | ||
_misses: The number of cache misses. | ||
_evictions: The number of cache entries have been evicted. | ||
_total_entries: The total number of cache entries. | ||
""" | ||
|
||
def __init__( | ||
self, hits: int = 0, misses: int = 0, evictions: int = 0, total_entries: int = 0 | ||
) -> None: | ||
self._hits = hits | ||
self._misses = misses | ||
self._evictions = evictions | ||
self._total_entries = total_entries | ||
|
||
@property | ||
def hits(self) -> int: | ||
"""The number of cache hits.""" | ||
return self._hits | ||
|
||
@property | ||
def misses(self) -> int: | ||
"""The number of cache misses.""" | ||
return self._misses | ||
|
||
@property | ||
def total_entries(self) -> int: | ||
"""The total number of cache entries.""" | ||
return self._total_entries | ||
|
||
@property | ||
def accesses(self) -> int: | ||
"""The number of times cache has been accessed.""" | ||
return self._hits + self._misses | ||
|
||
@property | ||
def hit_rate(self) -> float: | ||
""" | ||
The cache hit rate. | ||
Return 1.0 when ``accesses`` == 0. | ||
""" | ||
if self.accesses == 0: | ||
return 1.0 | ||
return self.hits / self.accesses | ||
|
||
@property | ||
def miss_rate(self) -> float: | ||
""" | ||
The cache miss rate. | ||
Return 0.0 when ``accesses`` == 0. | ||
""" | ||
if self.accesses == 0: | ||
return 0.0 | ||
return self.misses / self.accesses | ||
|
||
@property | ||
def eviction_rate(self) -> float: | ||
""" | ||
The cache eviction rate. | ||
Return 1.0 when ``accesses`` == 0. | ||
""" | ||
if self.accesses == 0: | ||
return 1.0 | ||
return self._evictions / self.accesses | ||
|
||
def __repr__(self) -> str: | ||
return ( | ||
f"{self.__class__.__name__}" | ||
"(" | ||
f"hits={self.hits}, misses={self.misses}, " | ||
f"total_entries={self.total_entries}, accesses={self.accesses}, " | ||
f"hit_rate={self.hit_rate}, miss_rate={self.miss_rate}, " | ||
f"eviction_rate={self.eviction_rate}" | ||
")" | ||
) | ||
|
||
|
||
class StatsTracker: | ||
""" | ||
An object to track statistics. | ||
Attributes: | ||
_hit_count: The number of cache hits. | ||
_miss_count: The number of cache misses. | ||
_evicted_count: The number of cache entries have been evicted. | ||
_total_count: The total number of cache entries. | ||
_enabled: A flag that indicates if statistics is enabled. | ||
_paused: A flag that indicates if statistics is paused. | ||
""" | ||
|
||
_lock: RLock | ||
|
||
def __init__(self, cache: "Cache") -> None: | ||
self._cache = cache | ||
|
||
self._lock = RLock() | ||
self._stats = Stats() | ||
|
||
self._enabled = False | ||
self._paused = False | ||
|
||
def inc_hits(self, count: int) -> None: | ||
if not self._enabled or self._paused: | ||
return | ||
|
||
with self._lock: | ||
self._stats._hits += count | ||
|
||
def inc_misses(self, count: int) -> None: | ||
if not self._enabled or self._paused: | ||
return | ||
|
||
with self._lock: | ||
self._stats._misses += count | ||
|
||
def inc_evictions(self, count: int) -> None: | ||
if not self._enabled or self._paused: | ||
return | ||
|
||
with self._lock: | ||
self._stats._evictions += count | ||
|
||
def enable(self) -> None: | ||
"""Enable statistics.""" | ||
with self._lock: | ||
self._enabled = True | ||
|
||
def disable(self) -> None: | ||
"""Disable statistics.""" | ||
with self._lock: | ||
self.reset() | ||
self._enabled = False | ||
|
||
def is_enabled(self) -> bool: | ||
"""Whether statistics is enabled.""" | ||
return self._enabled | ||
|
||
def pause(self) -> None: | ||
"""Pause statistics.""" | ||
with self._lock: | ||
self._paused = True | ||
|
||
def resume(self) -> None: | ||
"""Resume statistics.""" | ||
with self._lock: | ||
self._paused = False | ||
|
||
def is_paused(self) -> bool: | ||
"""Whether statistics is paused.""" | ||
return self._paused | ||
|
||
def reset(self) -> None: | ||
"""Clear statistics.""" | ||
with self._lock: | ||
self._stats = Stats() | ||
|
||
def info(self) -> Stats: | ||
"""Get a snapshot of statistics.""" | ||
with self._lock: | ||
self._stats._total_entries = len(self._cache) | ||
return copy.copy(self._stats) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,145 @@ | ||
import pytest | ||
|
||
from cacheout import Cache | ||
|
||
|
||
@pytest.fixture | ||
def cache() -> Cache: | ||
cache = Cache(maxsize=2) | ||
cache.stats.enable() | ||
cache.add("1", "one") | ||
cache.add("2", "two") | ||
cache.add("3", "three") | ||
return cache | ||
|
||
|
||
def test_info(cache: Cache): | ||
"""Test that cache.stats.info() gets statistics.""" | ||
assert cache.get("1") is None | ||
assert cache.get("2") == "two" | ||
|
||
stats = cache.stats.info() | ||
assert stats is not None | ||
assert stats.hits == 1 | ||
assert stats.misses == 4 | ||
assert stats.hit_rate == 0.2 | ||
assert stats.accesses == 5 | ||
assert stats.miss_rate == 0.8 | ||
assert stats.eviction_rate == 0.2 | ||
assert stats.total_entries == 2 | ||
assert repr(stats) == ( | ||
"Stats(" | ||
"hits=1, misses=4, total_entries=2, accesses=5, " | ||
"hit_rate=0.2, miss_rate=0.8, eviction_rate=0.2" | ||
")" | ||
) | ||
|
||
|
||
def test_reset(cache: Cache): | ||
"""Test that cache.stats.reset() clears statistics.""" | ||
cache.stats.reset() | ||
|
||
stats = cache.stats.info() | ||
assert stats is not None | ||
assert stats.hits == 0 | ||
assert stats.misses == 0 | ||
assert stats.accesses == 0 | ||
assert stats.hit_rate == 1.0 | ||
assert stats.miss_rate == 0.0 | ||
assert stats.eviction_rate == 1.0 | ||
assert stats.total_entries == 2 | ||
|
||
|
||
def test_pause(cache: Cache): | ||
"""Test that cache.stats.pause() pauses statistics.""" | ||
assert cache.get("1") is None | ||
assert cache.get("2") == "two" | ||
|
||
stats = cache.stats.info() | ||
assert stats is not None | ||
assert stats.hits == 1 | ||
assert stats.misses == 4 | ||
assert stats.hit_rate == 0.2 | ||
assert stats.accesses == 5 | ||
assert stats.miss_rate == 0.8 | ||
assert stats.eviction_rate == 0.2 | ||
assert stats.total_entries == 2 | ||
|
||
cache.stats.pause() | ||
assert cache.stats.is_paused() is True | ||
|
||
assert cache.get("1") is None | ||
assert cache.get("2") == "two" | ||
cache.add("4", "four") | ||
|
||
stats = cache.stats.info() | ||
assert stats is not None | ||
assert stats.hits == 1 | ||
assert stats.misses == 4 | ||
assert stats.hit_rate == 0.2 | ||
assert stats.accesses == 5 | ||
assert stats.miss_rate == 0.8 | ||
assert stats.eviction_rate == 0.2 | ||
assert stats.total_entries == 2 | ||
|
||
|
||
def test_resume(cache: Cache): | ||
"""Test that cache.stats.resume() resumes statistics.""" | ||
cache.stats.pause() | ||
assert cache.stats.is_paused() is True | ||
|
||
cache.stats.resume() | ||
assert cache.stats.is_paused() is False | ||
|
||
assert cache.get("1") is None | ||
assert cache.get("2") == "two" | ||
|
||
stats = cache.stats.info() | ||
assert stats is not None | ||
assert stats.hits == 1 | ||
assert stats.misses == 4 | ||
assert stats.hit_rate == 0.2 | ||
assert stats.accesses == 5 | ||
assert stats.miss_rate == 0.8 | ||
assert stats.eviction_rate == 0.2 | ||
assert stats.total_entries == 2 | ||
|
||
|
||
def test_disable(cache: Cache): | ||
"""Test that cache.stats.pause() disables statistics.""" | ||
cache.stats.disable() | ||
assert cache.stats.is_enabled() is False | ||
|
||
stats = cache.stats.info() | ||
assert stats is not None | ||
assert stats.hits == 0 | ||
assert stats.misses == 0 | ||
assert stats.accesses == 0 | ||
assert stats.hit_rate == 1.0 | ||
assert stats.miss_rate == 0.0 | ||
assert stats.eviction_rate == 1.0 | ||
assert stats.total_entries == 2 | ||
|
||
|
||
def test_enable(cache: Cache): | ||
"""Test that cache.stats.pause() enables statistics.""" | ||
cache.stats.disable() | ||
assert cache.stats.is_enabled() is False | ||
|
||
cache.stats.enable() | ||
assert cache.stats.is_enabled() is True | ||
|
||
assert cache.get("1") is None | ||
assert cache.get("2") == "two" | ||
cache.add("4", "four") | ||
cache.add("5", "five") | ||
|
||
stats = cache.stats.info() | ||
assert stats is not None | ||
assert stats.hits == 1 | ||
assert stats.misses == 3 | ||
assert stats.accesses == 4 | ||
assert stats.hit_rate == 0.25 | ||
assert stats.miss_rate == 0.75 | ||
assert stats.eviction_rate == 0.5 | ||
assert stats.total_entries == 2 |