diff options
Diffstat (limited to 'packages/meshbay-node/tests/test_media_cache_eviction.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_media_cache_eviction.py | 152 |
1 files changed, 152 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_media_cache_eviction.py b/packages/meshbay-node/tests/test_media_cache_eviction.py new file mode 100644 index 0000000..587063a --- /dev/null +++ b/packages/meshbay-node/tests/test_media_cache_eviction.py @@ -0,0 +1,152 @@ +""" +The media cache has a ceiling, and reaching it drops the least useful rows. + +`thumbs` holds every generated thumbnail, every TMDB poster and backdrop, every +Cover Art Archive image and every cached audio transcode. Rows were removed only +when their source file left every group's index (`prune_file`), so a library +that merely *changes* over years grew this database with nothing to bound it. +Nothing in it is precious — every row is keyed off a value the node can +re-derive — which is what makes eviction the right answer rather than a bigger +disk. + +The migration is the part worth pinning hardest: `CREATE TABLE IF NOT EXISTS` +adds missing tables and never missing columns, so `used_at` would have reached a +fresh test database and never a deployed node — `CLAUDE.md`'s standing lesson +about `create_all()`. Every existing node has a `thumbs` table without it. +""" + +import sqlite3 + +import pytest + +from meshbay_node.media_cache import MediaCache + + +def _blob(n: int) -> bytes: + return b"x" * n + + +@pytest.mark.asyncio +async def test_the_cache_stays_under_its_cap(tmp_path): + cache = MediaCache(db_path=tmp_path / "media_cache.db") + await cache.open() + try: + cap = 40_000 + for i in range(20): + await cache.put_thumb(f"hash{i:03d}", f"file{i:03d}", _blob(5_000)) + await cache._evict_thumbs(cap=cap) + assert await cache.thumb_bytes() <= cap + finally: + await cache.close() + + +@pytest.mark.asyncio +async def test_what_is_evicted_is_what_nobody_asked_for(tmp_path): + """ + Least *recently used*, not least recently written: a poster fetched a year + ago and shown on every visit to a grid must outlive one cached last week and + never looked at again. + """ + cache = MediaCache(db_path=tmp_path / "media_cache.db") + await cache.open() + try: + for i in range(8): + await cache.put_thumb(f"hash{i}", f"file{i}", _blob(5_000)) + # The oldest row by write time, read now — so it is the newest by use. + assert await cache.get_thumb("hash0") is not None + await cache._evict_thumbs(cap=20_000) + assert await cache.get_thumb("hash0") is not None, ( + "evicted a row that had just been served") + assert await cache.get_thumb("hash1") is None, ( + "kept a row nothing had asked for since it was written") + finally: + await cache.close() + + +@pytest.mark.asyncio +async def test_a_lookup_by_synthetic_id_counts_as_use(tmp_path): + """ + `_fetch_and_cache_poster` finds an already-cached poster through + `get_thumb_hash_by_file_id`, which is the lookup a poster grid makes on + every visit. If that did not count as use, the images shown most often + would look like the coldest rows in the table. + """ + cache = MediaCache(db_path=tmp_path / "media_cache.db") + await cache.open() + try: + for i in range(8): + await cache.put_thumb(f"hash{i}", f"tmdb:/poster{i}.jpg", _blob(5_000)) + assert await cache.get_thumb_hash_by_file_id("tmdb:/poster0.jpg") == "hash0" + await cache._evict_thumbs(cap=20_000) + assert await cache.get_thumb("hash0") is not None + finally: + await cache.close() + + +@pytest.mark.asyncio +async def test_one_oversized_blob_does_not_empty_the_table(tmp_path): + """ + A single audio transcode larger than the whole cap would otherwise evict + everything and then itself, leaving an empty cache and the same problem. + """ + cache = MediaCache(db_path=tmp_path / "media_cache.db") + await cache.open() + try: + await cache.put_thumb("big", "file-big", _blob(50_000)) + removed = await cache._evict_thumbs(cap=10_000) + assert await cache.get_thumb("big") is not None + assert removed == 0 + finally: + await cache.close() + + +@pytest.mark.asyncio +async def test_an_existing_database_gains_the_column(tmp_path): + """ + The migration, against a database shaped exactly like a deployed node's: + `thumbs` with no `used_at`, holding a row that must survive. + """ + db_path = tmp_path / "media_cache.db" + con = sqlite3.connect(db_path) + con.executescript(""" + CREATE TABLE thumbs ( + thumb_hash TEXT PRIMARY KEY, + file_id TEXT NOT NULL, + jpeg BLOB NOT NULL + ); + CREATE INDEX idx_thumbs_file ON thumbs(file_id); + """) + con.execute("INSERT INTO thumbs VALUES (?, ?, ?)", ("old", "file-old", b"abc")) + con.commit() + con.close() + + cache = MediaCache(db_path=db_path) + await cache.open() + try: + assert await cache.get_thumb("old") == b"abc", "the migration lost a row" + # Seeded with "now", not 0: an upgrade must not make every existing row + # look infinitely old and evict the whole cache on the next write. + con = sqlite3.connect(db_path) + used_at = con.execute( + "SELECT used_at FROM thumbs WHERE thumb_hash = 'old'").fetchone()[0] + con.close() + assert used_at > 0, "existing rows were left at 0 and are first to go" + finally: + await cache.close() + + +@pytest.mark.asyncio +async def test_opening_twice_is_harmless(tmp_path): + """The migration must be idempotent — a node opens this on every start.""" + db_path = tmp_path / "media_cache.db" + for _ in range(3): + cache = MediaCache(db_path=db_path) + await cache.open() + await cache.put_thumb("h", "f", b"xyz") + await cache.close() + cache = MediaCache(db_path=db_path) + await cache.open() + try: + assert await cache.get_thumb("h") == b"xyz" + finally: + await cache.close() |