Skip to content

Commit

Permalink
Cache statistics (#34)
Browse files Browse the repository at this point in the history
* feat: cache statistics

* refactor: redesign APIs

* perf: remove unnecessary locks

* refactor stats
  • Loading branch information
uncle-lv authored Oct 31, 2023
1 parent d9c6d8b commit 53d55c6
Show file tree
Hide file tree
Showing 3 changed files with 329 additions and 0 deletions.
7 changes: 7 additions & 0 deletions src/cacheout/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import time
import typing as t

from .stats import StatsTracker


F = t.TypeVar("F", bound=t.Callable[..., t.Any])
T_DECORATOR = t.Callable[[F], F]
Expand Down Expand Up @@ -91,6 +93,7 @@ def __init__(
self.timer = timer
self.default = default
self.on_delete = on_delete
self.stats = StatsTracker(self)

self.setup()
self.configure(maxsize=maxsize, ttl=ttl, timer=timer, default=default)
Expand Down Expand Up @@ -241,7 +244,9 @@ def _get(self, key: t.Hashable, default: t.Any = None) -> t.Any:
if self.expired(key):
self._delete(key, RemovalCause.EXPIRED)
raise KeyError
self.stats.inc_hits(1)
except KeyError:
self.stats.inc_misses(1)
if default is None:
default = self.default

Expand Down Expand Up @@ -378,6 +383,8 @@ def _delete(self, key: t.Hashable, cause: RemovalCause) -> int:
if self.on_delete:
self.on_delete(key, value, cause)
count = 1
if cause == RemovalCause.FULL:
self.stats.inc_evictions(1)
except KeyError:
pass

Expand Down
177 changes: 177 additions & 0 deletions src/cacheout/stats.py
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)
145 changes: 145 additions & 0 deletions tests/test_stats.py
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

0 comments on commit 53d55c6

Please sign in to comment.