summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_media_cache_eviction.py
blob: f0292b5dcc97f4c36488dc07812bce4936061d16 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
"""
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()