diff options
Diffstat (limited to 'packages/meshbay-node/tests/test_root_availability.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_root_availability.py | 301 |
1 files changed, 301 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_root_availability.py b/packages/meshbay-node/tests/test_root_availability.py new file mode 100644 index 0000000..028c308 --- /dev/null +++ b/packages/meshbay-node/tests/test_root_availability.py @@ -0,0 +1,301 @@ +""" +A root that goes away freezes; it never empties. + +This is the property the whole per-root availability design exists for. Unplug a +drive while the node is running and the filesystem watcher either reports every +file under it as deleted, or the next scan sees an empty directory. Acting on +either propagates deletions for a whole library, to every member, as though the +owner had erased it — and the index is what the node serves, so the loss is not +local. + +Every test here is written as "the entries are still there". They fail against +an indexer that treats a vanished root as a set of deletions, which is what the +straightforward implementation does. +""" + +import asyncio +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from meshbay_node.indexer.indexer import DirectoryIndexer +from meshbay_node.roots import RootSet + +pytestmark = pytest.mark.asyncio + + +def _roots(*paths: Path) -> RootSet: + specs = [{"path": str(p)} for p in paths] + specs[0]["upload"] = True + return RootSet.build(specs) + + +async def _indexer(roots: RootSet) -> DirectoryIndexer: + idx = DirectoryIndexer(roots=roots, group_id="g" * 32, + sk_node=Ed25519PrivateKey.generate(), gek=None) + await idx.initial_scan() + return idx + + +def _names(idx: DirectoryIndexer) -> set[str]: + return {e.name for e in idx.index.entries} + + +# ── The freeze ─────────────────────────────────────────────────────────────── + +async def test_a_vanished_root_does_not_empty_the_index(tmp_path): + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + (films / "b.mkv").write_bytes(b"b") + + idx = await _indexer(_roots(films)) + assert _names(idx) == {"a.mkv", "b.mkv"} + + # The volume goes away. Watchdog would now report both files as deleted. + for f in films.iterdir(): + f.unlink() + films.rmdir() + + for f in ("a.mkv", "b.mkv"): + await idx._update_entry(films / f, deleted=True) + + assert _names(idx) == {"a.mkv", "b.mkv"}, ( + "an unplugged drive emptied the index — every member would see the " + "library as deleted") + assert idx.roots.roots[0].available is False + + +async def test_reconcile_does_not_delete_from_an_unavailable_root(tmp_path): + """The sweep must skip roots it cannot read: there is nothing to compare + against, and comparing anyway deletes everything.""" + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + idx = await _indexer(_roots(films)) + (films / "a.mkv").unlink() + films.rmdir() + + await idx.reconcile() + + assert _names(idx) == {"a.mkv"} + assert idx.roots.roots[0].available is False + + +async def test_one_root_going_away_leaves_the_others_alone(tmp_path): + films = tmp_path / "Films" + music = tmp_path / "Music" + films.mkdir() + music.mkdir() + (films / "a.mkv").write_bytes(b"a") + (music / "b.mp3").write_bytes(b"b") + + idx = await _indexer(_roots(films, music)) + assert _names(idx) == {"a.mkv", "b.mp3"} + + (music / "b.mp3").unlink() + music.rmdir() + await idx.reconcile() + + assert _names(idx) == {"a.mkv", "b.mp3"} + by_name = {r.name: r.available for r in idx.roots} + assert by_name == {"Films": True, "Music": False} + + +async def test_members_are_told_which_roots_are_unavailable(tmp_path): + """Frozen entries stay listed, so without this a member cannot tell + "temporarily unavailable" from "still there".""" + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + idx = await _indexer(_roots(films)) + assert idx.index.roots == [ + {"name": "Films", "kind": "generic", "available": True, "upload": True}] + + (films / "a.mkv").unlink() + films.rmdir() + await idx.reconcile() + + assert idx.index.roots[0]["available"] is False + + +# ── The counter-property ───────────────────────────────────────────────────── + +async def test_a_file_deleted_from_a_live_root_is_removed(tmp_path): + """ + The freeze must not become "deletions never happen". A root that is readable + and a file that is genuinely gone is an ordinary deletion. + """ + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + (films / "b.mkv").write_bytes(b"b") + + idx = await _indexer(_roots(films)) + (films / "a.mkv").unlink() + await idx._update_entry(films / "a.mkv", deleted=True) + + assert _names(idx) == {"b.mkv"} + + +async def test_reconcile_removes_what_is_genuinely_gone(tmp_path): + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + (films / "b.mkv").write_bytes(b"b") + + idx = await _indexer(_roots(films)) + (films / "a.mkv").unlink() + await idx.reconcile() + + assert _names(idx) == {"b.mkv"} + + +async def test_reconcile_picks_up_a_file_the_watcher_missed(tmp_path): + """ + `ReadDirectoryChangesW` drops events under load and inotify on a FUSE mount + misses changes made outside it. Both are the common case here, so the sweep + is the only thing that recovers. + """ + films = tmp_path / "Films" + films.mkdir() + idx = await _indexer(_roots(films)) + + (films / "late.mkv").write_bytes(b"x") # no event delivered + await idx.reconcile() + + assert _names(idx) == {"late.mkv"} + + +async def test_a_returning_root_is_rescanned(tmp_path): + films = tmp_path / "Films" + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + + idx = await _indexer(_roots(films)) + (films / "a.mkv").unlink() + films.rmdir() + await idx.reconcile() + assert _names(idx) == {"a.mkv"} # frozen + + films.mkdir() + (films / "a.mkv").write_bytes(b"a") + (films / "c.mkv").write_bytes(b"c") + await idx.reconcile() + + assert _names(idx) == {"a.mkv", "c.mkv"} + assert idx.roots.roots[0].available is True + + +async def test_a_root_absent_at_startup_is_not_an_error(tmp_path): + """ + Someone starts the node with the drive unplugged. The group still exists and + the other roots still serve; this one fills in when it returns. + """ + films = tmp_path / "Films" + music = tmp_path / "Music" + films.mkdir() + music.mkdir() + (films / "a.mkv").write_bytes(b"a") + roots = _roots(films, music) + music.rmdir() + + idx = await _indexer(roots) + + assert _names(idx) == {"a.mkv"} + assert {r.name: r.available for r in idx.roots} == {"Films": True, "Music": False} + + +# ── Paths carry their root ─────────────────────────────────────────────────── + +async def test_every_path_starts_with_its_root_name(tmp_path): + films = tmp_path / "Films" + (films / "2024").mkdir(parents=True) + (films / "top.mkv").write_bytes(b"t") + (films / "2024" / "deep.mkv").write_bytes(b"d") + + idx = await _indexer(_roots(films)) + by_name = {e.name: e.path for e in idx.index.entries} + + assert by_name == {"top.mkv": "Films", "deep.mkv": "Films/2024"} + + +async def test_a_single_root_group_is_not_a_special_case(tmp_path): + """One path shape has to be got right once; two have to be kept right + forever. A lone root prefixes exactly like any other.""" + only = tmp_path / "Shared" + only.mkdir() + (only / "x.txt").write_bytes(b"x") + + idx = await _indexer(_roots(only)) + assert [e.path for e in idx.index.entries] == ["Shared"] + + +async def test_same_relative_path_in_two_roots_stays_distinct(tmp_path): + films = tmp_path / "Films" + music = tmp_path / "Music" + (films / "2024").mkdir(parents=True) + (music / "2024").mkdir(parents=True) + (films / "2024" / "same.dat").write_bytes(b"film") + (music / "2024" / "same.dat").write_bytes(b"music") + + idx = await _indexer(_roots(films, music)) + paths = sorted(e.path for e in idx.index.entries) + + assert paths == ["Films/2024", "Music/2024"] + assert len(idx.index.entries) == 2 + + +# ── Duplicate content ──────────────────────────────────────────────────────── + +async def test_identical_files_do_not_churn_the_index(tmp_path): + """ + The index is keyed by content hash, so the same bytes at two paths are one + entry. Reconciliation compares paths, so without care it decides the second + path is a missed event **every cycle** — rewriting that entry, bumping the + version, and pushing an index update to every connected peer once a minute. + + Found on a live node: `clip.mp4` sat at the root of a shared directory and + in `uploads/` with identical bytes. + """ + films = tmp_path / "Films" + (films / "uploads").mkdir(parents=True) + (films / "clip.mp4").write_bytes(b"same bytes") + (films / "uploads" / "clip.mp4").write_bytes(b"same bytes") + + idx = await _indexer(_roots(films)) + assert len(idx.index.entries) == 1, "content-addressed index, so one entry" + + await idx.reconcile() + first = (idx.index.version, idx.index.entries[0].path) + await idx.reconcile() + second = (idx.index.version, idx.index.entries[0].path) + + assert first == second, ( + "reconciliation rewrote the entry for a duplicate it cannot represent — " + "every peer would receive an index update every cycle") + + +async def test_deleting_one_copy_keeps_the_other_listed(tmp_path): + """ + The mirror case: the recorded path goes, identical content stays. Dropping + the entry would delist a file that is still on disk and still servable. + """ + films = tmp_path / "Films" + (films / "uploads").mkdir(parents=True) + (films / "clip.mp4").write_bytes(b"same bytes") + (films / "uploads" / "clip.mp4").write_bytes(b"same bytes") + + idx = await _indexer(_roots(films)) + recorded = idx.index.entries[0].path + survivor = "Films/uploads" if recorded == "Films" else "Films" + + (films / "clip.mp4").unlink() if recorded == "Films" else \ + (films / "uploads" / "clip.mp4").unlink() + await idx.reconcile() + + assert len(idx.index.entries) == 1, "the surviving copy was delisted" + assert idx.index.entries[0].path == survivor |