diff --git a/backend/server/api/aa.py b/backend/server/api/aa.py index 9154be06..9b3d7003 100644 --- a/backend/server/api/aa.py +++ b/backend/server/api/aa.py @@ -398,6 +398,7 @@ def _read() -> StoreRecord | None: rec = await run_sync(_read) if rec is not None: + aa_cache.record_store_hit() stale = (now - rec["last_resolved_at"]) > CHARACTER_STALE_S if stale: asyncio.create_task(_bg_refresh_aas(name, cache_key)) diff --git a/backend/server/api/character/gear_sets.py b/backend/server/api/character/gear_sets.py index 8f2398d9..436b4c30 100644 --- a/backend/server/api/character/gear_sets.py +++ b/backend/server/api/character/gear_sets.py @@ -167,6 +167,7 @@ def _read() -> StoreRecord | None: rec = await run_sync(_read) if rec is not None: + gear_sets_cache.record_store_hit() stale = (now - rec["last_resolved_at"]) > CHARACTER_STALE_S if stale: asyncio.create_task(_bg_refresh_gear_sets(name, cache_key)) diff --git a/backend/server/api/character/views.py b/backend/server/api/character/views.py index 80b2c2ac..6979cb65 100644 --- a/backend/server/api/character/views.py +++ b/backend/server/api/character/views.py @@ -458,6 +458,7 @@ async def get_character(request: Request, name: str) -> CharacterResponse: if rec is not None: from backend.server.census_refresh import request_character_refresh + character_cache.record_store_hit() data = rec["data"] # A guild-roster sync persists a partial character record (name / level / # guild only) so the roster can render before the character is ever diff --git a/backend/server/api/characters.py b/backend/server/api/characters.py index 80c5e73b..92087acc 100644 --- a/backend/server/api/characters.py +++ b/backend/server/api/characters.py @@ -145,7 +145,7 @@ async def lookup_characters(request: Request, names: str = "") -> BulkLookupResp out: dict[str, BulkLookupEntry] = {} for name in unique: cache_key = char_cache_key(name, current_world()) - cached = character_cache.get_stale(cache_key)[0] + cached = character_cache.peek(cache_key) # opportunistic probe — no metrics/LRU churn if cached is None: out[name] = BulkLookupEntry(found=False) continue diff --git a/backend/server/api/guild.py b/backend/server/api/guild.py index 4bd93d05..4d1b9d3c 100644 --- a/backend/server/api/guild.py +++ b/backend/server/api/guild.py @@ -212,6 +212,7 @@ async def get_guild_info(request: Request, guild_name: str) -> GuildInfoResponse finally: conn.close() if rec is not None: + guild_cache.record_store_hit() age = int(time.time()) - rec["last_resolved_at"] stale = age > 900 if stale: diff --git a/backend/server/cache.py b/backend/server/cache.py index 98f16b5c..9e3fb923 100644 --- a/backend/server/cache.py +++ b/backend/server/cache.py @@ -61,6 +61,21 @@ def _inc_stale(self) -> None: CACHE_STALE.labels(cache=self._name).inc() + def record_store_hit(self) -> None: + """Call when a memory miss was served from the durable census_store — + lets the dashboard split misses into store-absorbed vs Census-fetch.""" + with swallow("metrics"): + from backend.server.metrics import CACHE_STORE_HITS + + CACHE_STORE_HITS.labels(cache=self._name).inc() + + def _touch(self, key: str) -> None: + """Move an accessed key to the end of the dict so maxsize eviction + (which pops the front) behaves as LRU-by-access, not FIFO-by-insert.""" + entry = self._store.pop(key, None) + if entry is not None: + self._store[key] = entry + def _update_size(self) -> None: with swallow("metrics"): from backend.server.metrics import CACHE_SIZE @@ -83,6 +98,7 @@ def get(self, key: str) -> Any | None: return None _log.debug("[cache] HIT %s", scrub(key)) self._inc_hit() + self._touch(key) return value def get_stale(self, key: str) -> tuple[Any | None, bool]: @@ -113,8 +129,23 @@ def get_stale(self, key: str) -> tuple[Any | None, bool]: self._inc_stale() else: self._inc_hit() + self._touch(key) return value, is_stale + def peek(self, key: str) -> Any | None: + """Metric-free, side-effect-free read for OPPORTUNISTIC probes (bulk + lookups that treat a miss as "no enrichment, fine"). Returns whatever + get_stale would (fresh or stale value; None past max_age) without + counting a hit/miss or refreshing the entry's LRU position — so probe + sweeps can't distort the hit-ratio dashboards or the eviction order.""" + entry = self._store.get(key) + if entry is None: + return None + ts, value = entry + if self._max_age is not None and time.monotonic() - ts > self._max_age: + return None + return value + def set(self, key: str, value: Any) -> None: _log.debug("[cache] SET %s", scrub(key)) # Evict oldest entry if we're at capacity and this is a new key. @@ -122,6 +153,9 @@ def set(self, key: str, value: Any) -> None: oldest_key = next(iter(self._store)) del self._store[oldest_key] _log.debug("[cache] EVICT (maxsize) %s", oldest_key) + # Pop-then-set so an overwrite also moves to the back of the eviction + # queue (plain dict assignment keeps the original insertion slot). + self._store.pop(key, None) self._store[key] = (time.monotonic(), value) with swallow("metrics"): from backend.server.metrics import CACHE_SETS @@ -167,7 +201,11 @@ def delete(self, key: str) -> None: # cached to skip the per-request aiosqlite connection open on hot # character pages. Writes invalidate exactly (single-process), so # the TTL is only a backstop. -character_cache: TTLCache = TTLCache(name="character", maxsize=500) +# 2000: guild-roster views and the startup pre-warm insert every member/claimed +# character; at 500 the combined rosters exceeded the cap and cycled the cache +# (observed pinned-at-cap with a ~4% hit ratio, 2026-07). Entries are a few KB +# each → ~10-20 MB worst case. +character_cache: TTLCache = TTLCache(name="character", maxsize=2000) guild_cache: TTLCache = TTLCache(name="guild", maxsize=50) claim_cache: TTLCache = TTLCache(name="claim", maxsize=200) aa_cache: TTLCache = TTLCache(name="aa", maxsize=200) diff --git a/backend/server/metrics.py b/backend/server/metrics.py index 1b30a9df..bf0bddb0 100644 --- a/backend/server/metrics.py +++ b/backend/server/metrics.py @@ -64,6 +64,9 @@ CACHE_HITS = Counter("cache_hits_total", "Fresh cache hits", ["cache"]) CACHE_MISSES = Counter("cache_misses_total", "Cache misses (not found or expired)", ["cache"]) CACHE_STALE = Counter("cache_stale_total", "Stale hits that fired bg refresh", ["cache"]) +# A memory miss that census_store then served instantly — the dashboard's +# "miss" panel splits into store-absorbed vs real Census fetches with this. +CACHE_STORE_HITS = Counter("cache_store_hits_total", "Misses served from the durable census_store", ["cache"]) CACHE_SETS = Counter("cache_sets_total", "Values written into cache", ["cache"]) CACHE_SIZE = Gauge("cache_size", "Live entry count in cache", ["cache"]) diff --git a/tests/server/test_cache.py b/tests/server/test_cache.py index f172becf..2499abac 100644 --- a/tests/server/test_cache.py +++ b/tests/server/test_cache.py @@ -116,3 +116,85 @@ def test_multiple_keys_independent(self): cache.delete("a") assert cache.get("a") is None assert cache.get("b") == 2 + + +class TestLRUEviction: + """maxsize eviction is LRU-by-access (2026-07): reads and overwrites move + an entry to the back of the eviction queue, so hot entries survive + roster-flood inserts.""" + + def test_read_saves_an_entry_from_eviction(self): + cache = TTLCache(ttl=300, max_age=3600, maxsize=3) + for k in ("a", "b", "c"): + cache.set(k, k) + cache.get_stale("a") # touch the oldest + cache.set("d", "d") # over capacity → evicts the LRU entry + assert cache.get_stale("a")[0] == "a" # touched — survived + assert cache.get_stale("b")[0] is None # untouched oldest — evicted + + def test_get_also_touches(self): + cache = TTLCache(ttl=300, max_age=3600, maxsize=3) + for k in ("a", "b", "c"): + cache.set(k, k) + cache.get("a") + cache.set("d", "d") + assert cache.get("a") == "a" + assert cache.get("b") is None + + def test_overwrite_moves_to_back_of_queue(self): + cache = TTLCache(ttl=300, max_age=3600, maxsize=3) + for k in ("a", "b", "c"): + cache.set(k, k) + cache.set("a", "a2") # overwrite — must refresh queue position + cache.set("d", "d") + assert cache.get_stale("a")[0] == "a2" + assert cache.get_stale("b")[0] is None + + +class TestPeek: + """peek() is the opportunistic-probe read: no metrics, no LRU touch.""" + + def test_peek_returns_fresh_and_stale_values(self): + cache = TTLCache(ttl=1, max_age=3600) + cache.set("fresh", 1) + cache._store["stale"] = (time.monotonic() - 2, 2) + assert cache.peek("fresh") == 1 + assert cache.peek("stale") == 2 # stale-but-within-max-age still served + assert cache.peek("absent") is None + + def test_peek_respects_hard_expiry(self): + cache = TTLCache(ttl=1, max_age=10) + cache._store["old"] = (time.monotonic() - 11, "gone") + assert cache.peek("old") is None + assert "old" in cache._store # read-only: no eviction side effect + + def test_peek_records_no_metrics(self): + from backend.server.metrics import CACHE_HITS, CACHE_MISSES + + cache = TTLCache(ttl=300, max_age=3600, name="claim") + cache.set("k", 1) + hits_before = CACHE_HITS.labels(cache="claim")._value.get() + misses_before = CACHE_MISSES.labels(cache="claim")._value.get() + cache.peek("k") + cache.peek("nope") + assert CACHE_HITS.labels(cache="claim")._value.get() == hits_before + assert CACHE_MISSES.labels(cache="claim")._value.get() == misses_before + + def test_peek_does_not_touch_lru_order(self): + cache = TTLCache(ttl=300, max_age=3600, maxsize=3) + for k in ("a", "b", "c"): + cache.set(k, k) + cache.peek("a") # must NOT save it from eviction + cache.set("d", "d") + assert cache.peek("a") is None + assert cache.peek("b") == "b" + + +class TestStoreHitMetric: + def test_record_store_hit_increments_counter(self): + from backend.server.metrics import CACHE_STORE_HITS + + cache = TTLCache(ttl=300, max_age=3600, name="character") + before = CACHE_STORE_HITS.labels(cache="character")._value.get() + cache.record_store_hit() + assert CACHE_STORE_HITS.labels(cache="character")._value.get() == before + 1 diff --git a/tests/server/test_characters.py b/tests/server/test_characters.py index 57be2555..11472d73 100644 --- a/tests/server/test_characters.py +++ b/tests/server/test_characters.py @@ -15,12 +15,13 @@ def _fake_cached(*, guild_name=None, cls=None, level=None): def _cache_with(entries: dict): - """Build a fake character_cache whose get_stale returns the given map.""" + """Build a fake character_cache whose peek/get_stale serve the given map.""" from unittest.mock import MagicMock fake = MagicMock() # entries keyed by cache_key (name.lower():world.lower()) fake.get_stale.side_effect = lambda key: (entries.get(key), False) + fake.peek.side_effect = lambda key: entries.get(key) return fake