Skip to content

Commit 74f7dd8

Browse files
committed
feat(v7-i07): LRU cache stats — evictions + hit_rate dashboard
Closes V7-I07 (cppmega-mlx-zat0): operators had no way to see if the JsonRPC LRU cache was helping or thrashing. Stats now expose: - hits (incremented on get-key-present) - misses (incremented on get-key-absent) - evictions (incremented when set() bumps the oldest entry out under the capacity bound) — NEW - hit_rate = hits / (hits + misses), 0.0 when no traffic — NEW - size, capacity (existing) Server /cache/stats GET annotation widened to dict[str, int | float] so FastAPI accepts the float hit_rate field. Tests (tests/v4/test_cache_stats.py): 5/5 — * Initial stats shape includes all six fields. * Repeat get on the same key bumps hits; missing key bumps misses; hit_rate = 2/3 after 2 hits + 1 miss. * Capacity-2 cache with 4 inserts → 2 evictions. * clear() resets all counters to 0. * FastAPI /cache/stats endpoint returns all six keys. - Full cache + jsonrpc regression: 118/118 passing.
1 parent 0211553 commit 74f7dd8

3 files changed

Lines changed: 89 additions & 2 deletions

File tree

cppmega_v4/jsonrpc/cache.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ def __init__(self, capacity: int = DEFAULT_CAPACITY) -> None:
8484
self._lock = Lock()
8585
self._hits = 0
8686
self._misses = 0
87+
# V7-I07: count entries evicted by LRU bound (oldest popped
88+
# to make room for newer set()).
89+
self._evictions = 0
8790

8891
@property
8992
def capacity(self) -> int:
@@ -121,18 +124,23 @@ def set(self, key: str, value: Any) -> None:
121124
self._data[key] = snapshot
122125
if len(self._data) > self._capacity:
123126
self._data.popitem(last=False)
127+
self._evictions += 1
124128

125129
def clear(self) -> None:
126130
with self._lock:
127131
self._data.clear()
128132
self._hits = 0
129133
self._misses = 0
134+
self._evictions = 0
130135

131-
def stats(self) -> dict[str, int]:
136+
def stats(self) -> dict[str, int | float]:
132137
with self._lock:
138+
total = self._hits + self._misses
133139
return {
134140
"size": len(self._data),
135141
"capacity": self._capacity,
136142
"hits": self._hits,
137143
"misses": self._misses,
144+
"evictions": self._evictions,
145+
"hit_rate": (self._hits / total) if total > 0 else 0.0,
138146
}

cppmega_v4/jsonrpc/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def methods() -> dict[str, list[str]]:
6363
return {"methods": sorted(METHOD_REGISTRY)}
6464

6565
@app.get("/cache/stats")
66-
def cache_stats() -> dict[str, int]:
66+
def cache_stats() -> dict[str, int | float]:
6767
return cache.stats()
6868

6969
@app.post("/cache/clear")

tests/v4/test_cache_stats.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""V7-I07: LRUCache exposes hits/misses/evictions/hit_rate."""
2+
3+
from __future__ import annotations
4+
5+
from cppmega_v4.jsonrpc.cache import LRUCache
6+
7+
8+
def test_v7_i07_stats_initial_shape():
9+
c = LRUCache(capacity=4)
10+
s = c.stats()
11+
for k in ("size", "capacity", "hits", "misses",
12+
"evictions", "hit_rate"):
13+
assert k in s
14+
assert s["capacity"] == 4
15+
assert s["size"] == 0
16+
assert s["hits"] == 0
17+
assert s["misses"] == 0
18+
assert s["evictions"] == 0
19+
assert s["hit_rate"] == 0.0
20+
21+
22+
def test_v7_i07_hits_increment_on_repeat_get():
23+
c = LRUCache(capacity=4)
24+
c.set("k1", {"v": 1})
25+
# First get → hit.
26+
assert c.get("k1") is not None
27+
assert c.stats()["hits"] == 1
28+
# Second get → hit again.
29+
assert c.get("k1") is not None
30+
assert c.stats()["hits"] == 2
31+
# Missing key → miss.
32+
assert c.get("k_other") is None
33+
assert c.stats()["misses"] == 1
34+
s = c.stats()
35+
# 2 hits / (2 + 1) = 0.6667
36+
assert abs(s["hit_rate"] - 2 / 3) < 1e-6
37+
38+
39+
def test_v7_i07_evictions_increment_when_capacity_exceeded():
40+
c = LRUCache(capacity=2)
41+
c.set("a", 1)
42+
c.set("b", 2)
43+
assert c.stats()["evictions"] == 0
44+
c.set("c", 3) # bumps 'a' out
45+
assert c.stats()["evictions"] == 1
46+
c.set("d", 4) # bumps 'b' out
47+
assert c.stats()["evictions"] == 2
48+
assert c.stats()["size"] == 2
49+
50+
51+
def test_v7_i07_clear_resets_counters():
52+
c = LRUCache(capacity=2)
53+
c.set("a", 1)
54+
c.get("a")
55+
c.get("nothing")
56+
c.set("b", 2)
57+
c.set("c", 3)
58+
assert c.stats()["evictions"] >= 1
59+
c.clear()
60+
s = c.stats()
61+
assert s["hits"] == 0
62+
assert s["misses"] == 0
63+
assert s["evictions"] == 0
64+
assert s["size"] == 0
65+
66+
67+
def test_v7_i07_http_cache_stats_endpoint_contains_v7_fields():
68+
"""Sanity that the FastAPI /cache/stats hook returns the V7 keys."""
69+
from fastapi.testclient import TestClient
70+
from cppmega_v4.jsonrpc.server import create_app
71+
72+
app = create_app()
73+
client = TestClient(app)
74+
r = client.get("/cache/stats")
75+
assert r.status_code == 200
76+
body = r.json()
77+
for k in ("hits", "misses", "evictions", "hit_rate",
78+
"size", "capacity"):
79+
assert k in body, f"missing {k} in /cache/stats response"

0 commit comments

Comments
 (0)