diff options
Diffstat (limited to 'packages/meshbay-node')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/daemon.py | 52 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/indexer/cache.py | 42 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ops.py | 60 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/ui/app.py | 59 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_daemon.py | 65 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_index_cache.py | 46 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_indexer.py | 38 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_ops_index_cache.py | 139 |
8 files changed, 475 insertions, 26 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index cb3626b..74ab7b1 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -147,7 +147,9 @@ class NodeDaemon: self._denylist = ( Denylist(path=config.data_dir / "denylist.json") if Denylist else None) self._chat_stores: dict[str, ChatStore] = {} - self._index_caches: dict[str, IndexCache] = {} + # One instance, shared by every group's DirectoryIndexer — see + # indexer/cache.py's docstring for why this stopped being per-group. + self._index_cache: IndexCache | None = None # Coalesces a burst of index changes (one per debounced watchdog # event) into a single broadcast — see _on_index_change. 0.5s is # short enough nobody notices the wait, long enough that dropping a @@ -245,6 +247,13 @@ class NodeDaemon: await self._bundle_store.open() log.info("Bundle store opened: %s", data_dir / "bundles.db") + # 4a. Path->hash cache, node-wide — opened once, shared by every + # group's DirectoryIndexer below (indexer/cache.py). + self._index_cache = IndexCache(db_path=data_dir / "index_cache.db") + await self._index_cache.open() + self._state["index_cache"] = self._index_cache + log.info("Index cache opened: %s", data_dir / "index_cache.db") + # 4b. Roster — who this node recognises and which keys are theirs. # Node authority is established here, locally, and never learned from # the hub: a hub that could name the operator's key could install @@ -296,11 +305,6 @@ class NodeDaemon: log.info("No GEK yet for group %s — will accept first setup", group_cfg.name) - index_cache = IndexCache( - db_path=data_dir / group_cfg.id[:16] / "index_cache.db") - await index_cache.open() - self._index_caches[group_cfg.id] = index_cache - # Read once at load, like member_upload/enabled_apps below — # kept current in place afterwards by set_scan_settings # (ops.py), which updates both this indexer object directly @@ -318,7 +322,7 @@ class NodeDaemon: sk_node=keys.sk_ed25519, gek=gek, on_change=self._on_index_change, - cache=index_cache, + cache=self._index_cache, reconcile_secs=scan_settings["reconcile_interval_secs"], debounce_secs=scan_settings["debounce_secs"], ) @@ -733,11 +737,6 @@ class NodeDaemon: if gek: log.info("GEK loaded for new group %s", group_cfg.id[:8]) - index_cache = IndexCache( - db_path=data_dir / group_cfg.id[:16] / "index_cache.db") - await index_cache.open() - self._index_caches[group_cfg.id] = index_cache - scan_settings = ( await self._roster.scan_settings(group_cfg.id) if self._roster else { @@ -751,7 +750,7 @@ class NodeDaemon: sk_node=sk_ed, gek=gek, on_change=self._on_index_change, - cache=index_cache, + cache=self._index_cache, reconcile_secs=scan_settings["reconcile_interval_secs"], debounce_secs=scan_settings["debounce_secs"], ) @@ -836,12 +835,10 @@ class NodeDaemon: await store.close() except Exception: pass - cache = self._index_caches.pop(gid, None) - if cache: - try: - await cache.close() - except Exception: - pass + # No per-group index cache to close here (2026-08-25): the + # (path, size, mtime) -> hash cache is now one shared instance, + # open for the life of the daemon, since another group may still + # reference the same physical folder — see indexer/cache.py. pending = self._pending_broadcasts.pop(gid, None) if pending: pending.cancel() @@ -1080,8 +1077,19 @@ class NodeDaemon: # that is content-addressed and simply moved effectively is. if delta is not None and delta.deletions and self._media_cache: for file_id in delta.deletions: - asyncio.ensure_future(self._media_cache.prune_file(file_id)) self._enriched_attempted.discard((indexer.group_id, file_id)) + # media_cache.db is node-wide, keyed by content hash — a file + # shared into two groups is one row there, same reasoning as + # `_enriched_attempted`'s own docstring above. This group's + # copy is genuinely gone (that's what a deletion delta is), + # but another group may still hold the same content: only + # prune once *no* group's index has this file_id any more, + # or the surviving group pays for a redundant re-fetch/ + # re-probe/re-thumbnail for content it never actually lost. + still_referenced = any( + i.index.get_entry(file_id) is not None for i in self._indexers) + if not still_referenced: + asyncio.ensure_future(self._media_cache.prune_file(file_id)) # 11.5 — Push to connected WebRTC peers in this group if self._webrtc: @@ -1426,8 +1434,8 @@ class NodeDaemon: for store in self._chat_stores.values(): await store.close() - for cache in self._index_caches.values(): - await cache.close() + if self._index_cache: + await self._index_cache.close() for indexer in self._indexers: await indexer.stop() diff --git a/packages/meshbay-node/src/meshbay_node/indexer/cache.py b/packages/meshbay-node/src/meshbay_node/indexer/cache.py index c26ddf1..31b9b15 100644 --- a/packages/meshbay-node/src/meshbay_node/indexer/cache.py +++ b/packages/meshbay-node/src/meshbay_node/indexer/cache.py @@ -1,5 +1,5 @@ """ -MeshBay Node — persistent (path, size, mtime) -> hash cache, one per group. +MeshBay Node — persistent (path, size, mtime) -> hash cache, one per node. Without this, every node restart re-reads and re-hashes every file in every root, even when nothing changed — measured at 23 minutes for a 114 GB library @@ -10,6 +10,16 @@ It is a path-keyed accelerator only. The GroupIndex itself stays keyed by content hash (see indexer.py's note on why two identical files are one entry) — this cache never changes that, it only avoids recomputing a hash that has not changed. + +**Shared by every group's DirectoryIndexer, one instance, one open +connection** (2026-08-25) — an operator very often shares the same physical +folder (a music library, a Séries drive) into more than one group, and a +cache keyed purely by absolute path has no reason to care which group asked. +It used to be opened once per group (`data_dir/{group_id}/index_cache.db`), +which meant the second group to reference an already-fully-hashed multi- +terabyte folder paid the same full read the first one did — exactly the cost +this cache exists to avoid. The schema carries no group_id and never has; +only daemon.py's wiring changed. """ import logging @@ -40,7 +50,7 @@ class CachedEntry: class IndexCache: - """Async SQLite (path, size, mtime) -> hash cache for one group.""" + """Async SQLite (path, size, mtime) -> hash cache, shared node-wide.""" def __init__(self, db_path: Path): self._db_path = db_path @@ -93,3 +103,31 @@ class IndexCache: "type = excluded.type, added_at = excluded.added_at", (path, mtime, size, hash, type, added_at)) await self._db.commit() + + # ── Maintenance (node admin UI "prune index cache") ────────────────────── + + async def count(self) -> int: + """Cheap — used for the dashboard stat, never for the prune decision + itself (that needs the actual paths, see all_paths).""" + async with self._db.execute("SELECT COUNT(*) FROM files") as cur: + row = await cur.fetchone() + return row[0] if row else 0 + + async def all_paths(self) -> list[str]: + """Every cached path, for a caller that decides staleness itself — + this cache has no notion of which paths are still claimed by a + group's roots, on purpose (see ops.prune_index_cache).""" + async with self._db.execute("SELECT path FROM files") as cur: + rows = await cur.fetchall() + return [row[0] for row in rows] + + async def remove_many(self, paths: list[str]) -> None: + """Drop rows outright — used only for paths a caller has already + decided are gone for good. Losing one costs a rehash next time that + path is scanned, never a wrong answer (lookup() always re-validates + against a live stat()).""" + if not paths: + return + await self._db.executemany( + "DELETE FROM files WHERE path = ?", [(p,) for p in paths]) + await self._db.commit() diff --git a/packages/meshbay-node/src/meshbay_node/ops.py b/packages/meshbay-node/src/meshbay_node/ops.py index c9375b3..c880c75 100644 --- a/packages/meshbay-node/src/meshbay_node/ops.py +++ b/packages/meshbay-node/src/meshbay_node/ops.py @@ -922,6 +922,66 @@ async def set_scan_settings(state: dict, group_id: str, reconcile_interval_secs: "debounce_secs": debounce_secs, "group_id": group_id} +# ── Index cache maintenance ─────────────────────────────────────────────────── +# +# The (path, size, mtime) -> hash accelerator (indexer/cache.py) is node-wide +# and grows for as long as a path was ever seen — a folder an operator later +# stops sharing (root removed, or every group hosting it is deleted) leaves +# its rows behind forever otherwise. Nothing about correctness needs this: +# a stale row just sits unused (lookup() keys on the live path string, so a +# path nothing scans any more is never looked up). This is disk space +# hygiene the operator can run when they want it, not a background job. + +async def index_cache_stats(state: dict) -> dict: + """Row count only — cheap, safe to call on every dashboard render. + The actual staleness check (prune_index_cache) is not this cheap and + must never run implicitly.""" + cache = state.get("index_cache") + return {"count": await cache.count() if cache else 0} + + +async def prune_index_cache(state: dict) -> dict: + """ + Drop cache rows that cannot be right for anything any more: the path is + not under any group's root at all, or it is under a root that is + available right now and the file is genuinely gone from disk. + + Deliberately leaves alone anything under a root that is currently + *unavailable* (a disconnected drive) — indexer.py's own rule is that + such a root freezes rather than empties, precisely so it does not pay a + full rehash the moment it comes back. Pruning through an unavailable + root here would reintroduce exactly that cost via a different door, so + an owning-but-unavailable root wins over "the file isn't there right + now" every time, unconditionally. + + A row lost here costs one rehash the next time that path is scanned, + never a wrong answer: lookup() (cache.py) always re-validates size and + mtime against a live stat() before trusting a cached hash. + """ + cache = state.get("index_cache") + if cache is None: + raise OpError("No index cache in this process", status=503) + + indexers = list((state.get("indexers") or {}).values()) + roots = [root for indexer in indexers for root in indexer.roots] + + def _is_stale(path_str: str) -> bool: + path = Path(path_str) + owning = [r for r in roots if r.path in path.parents] + if not owning: + return True + if any(not r.available for r in owning): + return False + return not path.exists() + + paths = await cache.all_paths() + stale = await asyncio.to_thread(lambda: [p for p in paths if _is_stale(p)]) + await cache.remove_many(stale) + log.info("Index cache pruned: %d stale row(s) removed, %d kept", + len(stale), len(paths) - len(stale)) + return {"status": "pruned", "removed": len(stale), "kept": len(paths) - len(stale)} + + # ── Reload ────────────────────────────────────────────────────────────────── async def reload_config(state: dict) -> dict: diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py index 18764e8..3dc320b 100644 --- a/packages/meshbay-node/src/meshbay_node/ui/app.py +++ b/packages/meshbay-node/src/meshbay_node/ui/app.py @@ -169,6 +169,14 @@ def create_ui_app(state: dict) -> FastAPI: async def api_denylist_clear(subject: str = ""): return await _op(lambda: ops.clear_denylist(state, subject=subject)) + @app.get("/api/index-cache") + async def api_index_cache_stats(): + return await _op(lambda: ops.index_cache_stats(state)) + + @app.post("/api/index-cache/prune") + async def api_index_cache_prune(): + return await _op(lambda: ops.prune_index_cache(state)) + @app.get("/api/groups/{group_id}/files") async def api_group_files(group_id: str): groups_ctx = state.get("groups_ctx", {}) @@ -458,7 +466,9 @@ def create_ui_app(state: dict) -> FastAPI: "members": await roster.list_members(), "invites": await roster.list_invites(), } - return _render_page(state, roster_view) + index_cache = state.get("index_cache") + cache_count = await index_cache.count() if index_cache else None + return _render_page(state, roster_view, cache_count) @app.get("/audit", response_class=HTMLResponse) async def audit_page(): @@ -540,7 +550,8 @@ def _render_roster(roster_view: dict | None) -> str: </p>""" -def _render_page(state: dict, roster_view: dict | None = None) -> str: +def _render_page(state: dict, roster_view: dict | None = None, + index_cache_count: int | None = None) -> str: token_js = json.dumps(state.get("ui_token", "")) status = state.get("status", "starting") indexes = state.get("indexes", {}) @@ -723,6 +734,26 @@ def _render_page(state: dict, roster_view: dict | None = None) -> str: <p><b>Node ID:</b> <code>{state.get("endpoint_hint") or "—"}</code></p> </div> + <h2>Maintenance</h2> + <div class="card"> + <p><b>Index cache:</b> <span id="cacheCount">{ + index_cache_count if index_cache_count is not None else "—" + }</span> path(s) remembered (size/mtime → hash, shared by every group)</p> + <p class="muted">Removes rows whose path no longer belongs to any group's + root, or whose file is genuinely gone from a root that is currently + reachable. Never touches a root that is temporarily unavailable + (unplugged drive) — that one still needs its full cache back the + moment it returns.</p> + <div style="margin:10px 0"> + <button onclick="pruneIndexCache()" id="prune-cache-btn" + style="padding:8px 16px;background:#3b82f6;color:#fff;border:none; + border-radius:6px;cursor:pointer;font-size:0.85em"> + Prune stale entries + </button> + <span id="prune-cache-status" class="muted" style="margin-left:8px"></span> + </div> + </div> + <h2>Link Node to Hub Account</h2> <div class="card"> <p>To connect to your group from a browser, link this node to your hub account. @@ -774,6 +805,30 @@ async function initGEK(groupId) {{ if (btn) btn.disabled = false; }} }} +async function pruneIndexCache() {{ + const btn = document.getElementById('prune-cache-btn'); + const status = document.getElementById('prune-cache-status'); + if (btn) btn.disabled = true; + if (status) {{ status.textContent = 'Pruning...'; status.style.color = ''; }} + try {{ + const resp = await fetch('/api/index-cache/prune?t=' + TOKEN, {{ method: 'POST' }}); + const data = await resp.json(); + if (resp.ok) {{ + if (status) status.textContent = 'Removed ' + data.removed + ', kept ' + data.kept; + if (status) status.style.color = '#22c55e'; + const count = document.getElementById('cacheCount'); + if (count) count.textContent = data.kept; + }} else {{ + if (status) status.textContent = data.error || 'Failed'; + if (status) status.style.color = '#ef4444'; + }} + }} catch (e) {{ + if (status) status.textContent = 'Error: ' + e.message; + if (status) status.style.color = '#ef4444'; + }} finally {{ + if (btn) btn.disabled = false; + }} +}} setTimeout(()=>location.reload(), 10000); </script> </body> diff --git a/packages/meshbay-node/tests/test_daemon.py b/packages/meshbay-node/tests/test_daemon.py index b367a20..71aae78 100644 --- a/packages/meshbay-node/tests/test_daemon.py +++ b/packages/meshbay-node/tests/test_daemon.py @@ -440,6 +440,71 @@ async def test_delta_reflects_additions_and_deletions(tmp_path, shared_dir, gek) @pytest.mark.asyncio +async def test_media_cache_not_pruned_when_another_group_still_has_the_content( + tmp_path, shared_dir, gek): + """ + media_cache.db is node-wide, keyed by content hash — a file shared into + two groups is one row there. Removing it from ONE group's index (root + unshared, group left) must not wipe the thumbnail/tmdb/mbid mapping the + OTHER group's copy still needs, or that surviving group pays for a + redundant re-fetch/re-probe/re-thumbnail for content it never lost. + """ + daemon = _new_daemon_for_group(tmp_path, shared_dir, gek, group_id="a" * 32) + daemon._media_cache = AsyncMock() + daemon._webrtc = MagicMock() + daemon._webrtc._sessions = {} + + indexer_a = DirectoryIndexer(roots=one_root(shared_dir), group_id="a" * 32, + sk_node=Ed25519PrivateKey.generate(), gek=gek) + await indexer_a.initial_scan() + shared_id = indexer_a.index.entries[0].id + + indexer_b = DirectoryIndexer(roots=one_root(shared_dir), group_id="b" * 32, + sk_node=Ed25519PrivateKey.generate(), gek=gek) + await indexer_b.initial_scan() + assert indexer_b.index.get_entry(shared_id) is not None + + daemon._indexers = [indexer_a, indexer_b] + + await daemon._on_index_change(indexer_a) # establishes the snapshot + await asyncio.sleep(0.05) + + indexer_a.index.remove_entry(shared_id) + await daemon._on_index_change(indexer_a) + await asyncio.sleep(0.05) + + daemon._media_cache.prune_file.assert_not_called() + + +@pytest.mark.asyncio +async def test_media_cache_pruned_once_no_group_has_the_content_left( + tmp_path, shared_dir, gek): + """Counterpart of the test above: with only one group ever having held + the content, its removal must still prune media_cache as before — the + fix only withholds pruning when the content genuinely survives + elsewhere, it must not make pruning stop happening altogether.""" + daemon = _new_daemon_for_group(tmp_path, shared_dir, gek, group_id="a" * 32) + daemon._media_cache = AsyncMock() + daemon._webrtc = MagicMock() + daemon._webrtc._sessions = {} + + indexer = DirectoryIndexer(roots=one_root(shared_dir), group_id="a" * 32, + sk_node=Ed25519PrivateKey.generate(), gek=gek) + await indexer.initial_scan() + removed_id = indexer.index.entries[0].id + daemon._indexers = [indexer] + + await daemon._on_index_change(indexer) + await asyncio.sleep(0.05) + + indexer.index.remove_entry(removed_id) + await daemon._on_index_change(indexer) + await asyncio.sleep(0.05) + + daemon._media_cache.prune_file.assert_called_once_with(removed_id) + + +@pytest.mark.asyncio async def test_a_burst_of_changes_produces_one_broadcast(tmp_path, shared_dir, gek): """Coalescing: several _on_index_change calls in quick succession (one per debounced watchdog event) must collapse into a single push.""" diff --git a/packages/meshbay-node/tests/test_index_cache.py b/packages/meshbay-node/tests/test_index_cache.py index db0e37e..24e2f76 100644 --- a/packages/meshbay-node/tests/test_index_cache.py +++ b/packages/meshbay-node/tests/test_index_cache.py @@ -60,6 +60,52 @@ async def test_put_overwrites_previous_row_for_same_path(cache): @pytest.mark.asyncio +async def test_count_reflects_number_of_rows(cache): + assert await cache.count() == 0 + + await cache.put("/lib/a.mkv", size=1000, mtime=111.0, hash="a", + type="video", added_at=1) + await cache.put("/lib/b.mkv", size=2000, mtime=222.0, hash="b", + type="video", added_at=2) + + assert await cache.count() == 2 + + +@pytest.mark.asyncio +async def test_all_paths_returns_every_row(cache): + await cache.put("/lib/a.mkv", size=1000, mtime=111.0, hash="a", + type="video", added_at=1) + await cache.put("/lib/b.mkv", size=2000, mtime=222.0, hash="b", + type="video", added_at=2) + + assert set(await cache.all_paths()) == {"/lib/a.mkv", "/lib/b.mkv"} + + +@pytest.mark.asyncio +async def test_remove_many_drops_only_the_given_paths(cache): + await cache.put("/lib/a.mkv", size=1000, mtime=111.0, hash="a", + type="video", added_at=1) + await cache.put("/lib/b.mkv", size=2000, mtime=222.0, hash="b", + type="video", added_at=2) + + await cache.remove_many(["/lib/a.mkv"]) + + assert await cache.lookup("/lib/a.mkv", size=1000, mtime=111.0) is None + assert await cache.lookup("/lib/b.mkv", size=2000, mtime=222.0) is not None + assert await cache.count() == 1 + + +@pytest.mark.asyncio +async def test_remove_many_with_empty_list_is_a_no_op(cache): + await cache.put("/lib/a.mkv", size=1000, mtime=111.0, hash="a", + type="video", added_at=1) + + await cache.remove_many([]) + + assert await cache.count() == 1 + + +@pytest.mark.asyncio async def test_cache_survives_reopen(tmp_path): db_path = tmp_path / "index_cache.db" diff --git a/packages/meshbay-node/tests/test_indexer.py b/packages/meshbay-node/tests/test_indexer.py index 9aa9fb9..729dade 100644 --- a/packages/meshbay-node/tests/test_indexer.py +++ b/packages/meshbay-node/tests/test_indexer.py @@ -284,6 +284,44 @@ async def test_second_scan_with_same_cache_hashes_nothing( @pytest.mark.asyncio +async def test_second_group_sharing_the_same_folder_hashes_nothing( + shared_dir, sk_node, gek, index_cache): + """ + The scenario the cache was made node-wide for (2026-08-25): an operator + shares the same physical folder into a second group. `IndexCache` is + keyed purely by absolute path, with no notion of group_id at all — a + *different* group_id scanning the same folder through the same shared + cache instance must hit exactly as hard as a same-group restart does + (the test right above this one). Before this cache was shared node-wide, + each group got its own on-disk cache file and this scan would have + rehashed every byte again. + """ + first = DirectoryIndexer(roots=one_root(shared_dir), group_id="group-a", + sk_node=sk_node, gek=gek, cache=index_cache) + await first.initial_scan() + assert first.index.count == 4 + + calls = [] + real_scan_file = indexer_mod._scan_file + + def spy(root, path): + calls.append(path) + return real_scan_file(root, path) + + indexer_mod._scan_file = spy + try: + second = DirectoryIndexer(roots=one_root(shared_dir), group_id="group-b", + sk_node=sk_node, gek=gek, cache=index_cache) + await second.initial_scan() + finally: + indexer_mod._scan_file = real_scan_file + + assert calls == [], ( + f"a second group scanning the same folder must not rehash it, got {calls}") + assert {e.id for e in second.index.entries} == {e.id for e in first.index.entries} + + +@pytest.mark.asyncio async def test_modified_file_is_rehashed(tmp_path, sk_node, gek, index_cache): d = tmp_path / "shared" d.mkdir() diff --git a/packages/meshbay-node/tests/test_ops_index_cache.py b/packages/meshbay-node/tests/test_ops_index_cache.py new file mode 100644 index 0000000..0b28d69 --- /dev/null +++ b/packages/meshbay-node/tests/test_ops_index_cache.py @@ -0,0 +1,139 @@ +"""Tests for ops.index_cache_stats / ops.prune_index_cache.""" + +import types + +import pytest +from meshbay_node import ops +from meshbay_node.indexer.cache import IndexCache +from meshbay_node.roots import Root + + +@pytest.fixture +async def cache(tmp_path): + c = IndexCache(db_path=tmp_path / "index_cache.db") + await c.open() + yield c + await c.close() + + +def _indexer(roots): + """A stand-in for DirectoryIndexer — prune_index_cache only ever reads + `.roots`, so a real one (with its watchdog Observer, executor, etc.) + would be pure overhead here.""" + return types.SimpleNamespace(roots=roots) + + +@pytest.mark.asyncio +async def test_stats_with_no_cache_reports_zero(): + assert await ops.index_cache_stats({}) == {"count": 0} + + +@pytest.mark.asyncio +async def test_stats_reports_row_count(cache): + await cache.put("/lib/a.mkv", size=1, mtime=1.0, hash="a", type="video", added_at=1) + + assert await ops.index_cache_stats({"index_cache": cache}) == {"count": 1} + + +@pytest.mark.asyncio +async def test_prune_without_a_cache_raises(): + with pytest.raises(ops.OpError): + await ops.prune_index_cache({}) + + +@pytest.mark.asyncio +async def test_prune_removes_a_path_no_group_claims_any_more(cache): + """A folder no group's roots reference at all any more — every group + that once shared it was deleted, or the root was removed everywhere.""" + await cache.put("/gone/orphan.mkv", size=1, mtime=1.0, hash="a", + type="video", added_at=1) + state = {"index_cache": cache, "indexers": {}} + + result = await ops.prune_index_cache(state) + + assert result == {"status": "pruned", "removed": 1, "kept": 0} + assert await cache.count() == 0 + + +@pytest.mark.asyncio +async def test_prune_keeps_a_path_whose_file_still_exists_under_an_available_root( + tmp_path, cache): + root_dir = tmp_path / "lib" + root_dir.mkdir() + f = root_dir / "still-here.mkv" + f.write_bytes(b"x") + await cache.put(str(f), size=1, mtime=1.0, hash="a", type="video", added_at=1) + + root = Root(name="lib", path=root_dir, available=True) + state = {"index_cache": cache, "indexers": {"g": _indexer([root])}} + + result = await ops.prune_index_cache(state) + + assert result == {"status": "pruned", "removed": 0, "kept": 1} + assert await cache.count() == 1 + + +@pytest.mark.asyncio +async def test_prune_removes_a_path_deleted_from_an_available_root(tmp_path, cache): + root_dir = tmp_path / "lib" + root_dir.mkdir() + gone = root_dir / "deleted.mkv" + # Never written to disk — simulates a file that was there when cached + # and has since been deleted. + await cache.put(str(gone), size=1, mtime=1.0, hash="a", type="video", added_at=1) + + root = Root(name="lib", path=root_dir, available=True) + state = {"index_cache": cache, "indexers": {"g": _indexer([root])}} + + result = await ops.prune_index_cache(state) + + assert result == {"status": "pruned", "removed": 1, "kept": 0} + assert await cache.count() == 0 + + +@pytest.mark.asyncio +async def test_prune_never_touches_a_path_under_an_unavailable_root(tmp_path, cache): + """ + indexer.py's own rule: a root that goes away freezes, it never empties. + A disconnected drive must not have its cache wiped just because the + files can't be verified right now — that would force a full rehash of + the whole drive the moment it comes back, exactly the cost this cache + exists to avoid. Checked with a root directory that does not even exist + on disk (the strongest form of "can't verify") to prove availability, + not on-disk state, is what decides this. + """ + root_dir = tmp_path / "usb" # never created — simulates "unplugged" + unreachable = root_dir / "movie.mkv" + await cache.put(str(unreachable), size=1, mtime=1.0, hash="a", + type="video", added_at=1) + + root = Root(name="usb", path=root_dir, available=False) + state = {"index_cache": cache, "indexers": {"g": _indexer([root])}} + + result = await ops.prune_index_cache(state) + + assert result == {"status": "pruned", "removed": 0, "kept": 1} + assert await cache.count() == 1 + + +@pytest.mark.asyncio +async def test_prune_across_two_groups_sharing_one_root_path(tmp_path, cache): + """The scenario this whole cache redesign is for: the same folder is a + root of two groups. A path under it must be kept as long as *either* + group's root still resolves it, available.""" + root_dir = tmp_path / "shared" + root_dir.mkdir() + f = root_dir / "track.mp3" + f.write_bytes(b"x") + await cache.put(str(f), size=1, mtime=1.0, hash="a", type="audio", added_at=1) + + root_a = Root(name="shared", path=root_dir, available=True) + root_b = Root(name="shared", path=root_dir, available=True) + state = { + "index_cache": cache, + "indexers": {"g1": _indexer([root_a]), "g2": _indexer([root_b])}, + } + + result = await ops.prune_index_cache(state) + + assert result == {"status": "pruned", "removed": 0, "kept": 1} |