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