You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/tests/metadata/test_cache_capacity_evictio...

182 lines
5.7 KiB

"""Tests for the metadata-cache hard capacity cap (LRU eviction).
TTL-only eviction had no upper bound, so heavy in-window caching let
metadata_cache_entities reach ~1.8M rows / 7.6 GB. evict_over_capacity adds an
LRU row ceiling. We test the pure decision function directly and the SQL
behavior against a real temp DB (proves it drops the LEAST-recently-accessed
rows, not arbitrary ones).
"""
from __future__ import annotations
import sqlite3
import sys
import types
import pytest
# Minimal stubs so importing core.metadata.cache doesn't drag in spotipy/config.
if "spotipy" not in sys.modules:
spotipy = types.ModuleType("spotipy")
spotipy.Spotify = object
oauth2 = types.ModuleType("spotipy.oauth2")
oauth2.SpotifyOAuth = object
oauth2.SpotifyClientCredentials = object
spotipy.oauth2 = oauth2
sys.modules["spotipy"] = spotipy
sys.modules["spotipy.oauth2"] = oauth2
if "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.settings")
class _DummyCM:
def get(self, key, default=None):
return default
def get_active_media_server(self):
return "plex"
settings_mod.config_manager = _DummyCM()
config_pkg.settings = settings_mod
sys.modules["config"] = config_pkg
sys.modules["config.settings"] = settings_mod
from core.metadata.cache import ( # noqa: E402
MetadataCache,
entities_to_evict_for_capacity,
)
# ── pure decision function ─────────────────────────────────────────────────
def test_evict_count_over_cap():
assert entities_to_evict_for_capacity(1000, 250) == 750
def test_evict_count_at_or_under_cap_is_zero():
assert entities_to_evict_for_capacity(250, 250) == 0
assert entities_to_evict_for_capacity(10, 250) == 0
def test_cap_zero_or_negative_means_no_eviction():
assert entities_to_evict_for_capacity(10_000, 0) == 0
assert entities_to_evict_for_capacity(10_000, -1) == 0
def test_never_negative():
assert entities_to_evict_for_capacity(0, 100) == 0
# ── evict_over_capacity SQL behavior (real temp DB) ────────────────────────
class _NonClosingConn:
def __init__(self, real):
self._real = real
def cursor(self):
return self._real.cursor()
def commit(self):
return self._real.commit()
def close(self):
pass
def __enter__(self):
return self
def __exit__(self, *a):
pass
class _TempCache(MetadataCache):
"""MetadataCache whose _get_db returns a shim over a shared in-memory DB
holding just the entities table — enough to exercise evict_over_capacity."""
def __init__(self):
self._conn = sqlite3.connect(":memory:")
self._conn.row_factory = sqlite3.Row
self._conn.execute("""
CREATE TABLE metadata_cache_entities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT, entity_type TEXT, entity_id TEXT,
name TEXT, raw_json TEXT,
last_accessed_at TIMESTAMP,
ttl_days INTEGER DEFAULT 30
)
""")
self._conn.commit()
def _get_db(self):
outer = self
class _DB:
def _get_connection(self_inner):
return _NonClosingConn(outer._conn)
return _DB()
# NOTE: we deliberately do NOT override _run_maintenance_write — the test
# exercises the REAL method (retry + connection handling) so we're testing
# production code, not a stub. _get_db is the only injected seam.
def _add_rows(cache, specs):
"""specs: list of (entity_id, last_accessed_at). Inserts rows."""
cur = cache._conn.cursor()
for eid, ts in specs:
cur.execute(
"INSERT INTO metadata_cache_entities (source, entity_type, entity_id, name, raw_json, last_accessed_at) "
"VALUES ('spotify','artist',?,?, '{}', ?)",
(eid, eid, ts),
)
cache._conn.commit()
def _ids(cache):
return [r["entity_id"] for r in cache._conn.execute(
"SELECT entity_id FROM metadata_cache_entities ORDER BY entity_id"
).fetchall()]
def test_evict_over_capacity_drops_least_recently_accessed():
cache = _TempCache()
# 5 rows, distinct access times. Cap at 3 -> evict the 2 oldest-accessed.
_add_rows(cache, [
("a", "2026-05-01T00:00:00"), # oldest -> evicted
("b", "2026-05-02T00:00:00"), # oldest -> evicted
("c", "2026-05-03T00:00:00"),
("d", "2026-05-04T00:00:00"),
("e", "2026-05-05T00:00:00"), # newest -> kept
])
evicted = cache.evict_over_capacity(max_rows=3)
assert evicted == 2
assert _ids(cache) == ["c", "d", "e"] # a, b gone (LRU)
def test_evict_over_capacity_noop_under_cap():
cache = _TempCache()
_add_rows(cache, [("a", "2026-05-01T00:00:00"), ("b", "2026-05-02T00:00:00")])
assert cache.evict_over_capacity(max_rows=10) == 0
assert _ids(cache) == ["a", "b"]
def test_evict_over_capacity_disabled_with_zero_cap():
cache = _TempCache()
_add_rows(cache, [(str(i), f"2026-05-0{i}T00:00:00") for i in range(1, 6)])
assert cache.evict_over_capacity(max_rows=0) == 0
assert len(_ids(cache)) == 5
def test_null_access_times_evicted_first():
"""Rows never accessed since insert (NULL last_accessed_at) are the
coldest — they should go before any touched row."""
cache = _TempCache()
_add_rows(cache, [
("never1", None),
("never2", None),
("touched", "2026-05-01T00:00:00"),
])
evicted = cache.evict_over_capacity(max_rows=1)
assert evicted == 2
assert _ids(cache) == ["touched"]