Skip to content
Merged
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 backend/server/api/aa.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions backend/server/api/character/gear_sets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions backend/server/api/character/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/server/api/characters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions backend/server/api/guild.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
40 changes: 39 additions & 1 deletion backend/server/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]:
Expand Down Expand Up @@ -113,15 +129,33 @@ 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.
if key not in self._store and len(self._store) >= self._maxsize:
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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions backend/server/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])

Expand Down
82 changes: 82 additions & 0 deletions tests/server/test_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion tests/server/test_characters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
Loading