summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_media_cache_eviction.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-node/tests/test_media_cache_eviction.py')
-rw-r--r--packages/meshbay-node/tests/test_media_cache_eviction.py152
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()