diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-06 01:33:16 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-06 01:33:37 +0200 |
| commit | f2da33a648f86e34ddbbb5f6bec124828ef2a847 (patch) | |
| tree | 670cbf62ff92598847e067b5835713dfd854dfb5 /packages/meshbay-node/tests/test_indexer.py | |
| parent | fff1974edf19cf1186e0f49da5f8a4d237bcb13e (diff) | |
| download | meshbay-f2da33a648f86e34ddbbb5f6bec124828ef2a847.tar.gz | |
feat(node): indexing v2 — partial-read hashing for files above 40 MB
Files above 40 MB are no longer read in full. Instead, blake3 hashes
45 MB of samples (first 20 MB + last 20 MB + 5 MB at 50% offset).
Files at or below 40 MB are unchanged (full read, hash_version 1).
A new `hash_version` field on IndexEntry (default 1) travels on the
wire and through the cache so both versions coexist without breaking
existing nodes or clients.
The IndexCache auto-migrates its schema on open (ALTER TABLE), so no
manual step is required on upgrade. A standalone migration script is
available in QE/migration/ for operators who want to preview or force
a full re-hash.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests/test_indexer.py')
| -rw-r--r-- | packages/meshbay-node/tests/test_indexer.py | 145 |
1 files changed, 145 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_indexer.py b/packages/meshbay-node/tests/test_indexer.py index 729dade..6aee1b5 100644 --- a/packages/meshbay-node/tests/test_indexer.py +++ b/packages/meshbay-node/tests/test_indexer.py @@ -723,3 +723,148 @@ async def test_reconcile_backoff_resets_when_something_actually_changes( await task except asyncio.CancelledError: pass + + +# ── Indexing v2 — partial-read hashing ──────────────────────────────────────── + + +def test_small_file_gets_hash_version_1(tmp_path): + from meshbay_node.indexer.indexer import _scan_file + d = tmp_path / "root" + d.mkdir() + f = d / "small.mp4" + f.write_bytes(os.urandom(1024)) + + entry = _scan_file(one_root(d).roots[0], f) + assert entry is not None + assert entry.hash_version == 1 + + +def test_large_file_gets_hash_version_2(tmp_path): + from meshbay_node.indexer.indexer import _scan_file, _PARTIAL_THRESHOLD + d = tmp_path / "root" + d.mkdir() + f = d / "big.mkv" + size = _PARTIAL_THRESHOLD + 1 + f.write_bytes(os.urandom(size)) + + entry = _scan_file(one_root(d).roots[0], f) + assert entry is not None + assert entry.hash_version == 2 + assert entry.size == size + + +def test_file_at_threshold_gets_hash_version_1(tmp_path): + from meshbay_node.indexer.indexer import _scan_file, _PARTIAL_THRESHOLD + d = tmp_path / "root" + d.mkdir() + f = d / "exact.mkv" + f.write_bytes(os.urandom(_PARTIAL_THRESHOLD)) + + entry = _scan_file(one_root(d).roots[0], f) + assert entry is not None + assert entry.hash_version == 1 + + +def test_partial_hash_differs_from_full_hash(tmp_path): + """For a file above the threshold, the partial hash must differ from what + a full-file blake3 would produce (they read different bytes).""" + import blake3 as b3 + from meshbay_node.indexer.indexer import _scan_file, _PARTIAL_THRESHOLD + d = tmp_path / "root" + d.mkdir() + f = d / "big.mkv" + content = os.urandom(_PARTIAL_THRESHOLD + 1024 * 1024) + f.write_bytes(content) + + entry = _scan_file(one_root(d).roots[0], f) + full_hash = b3.blake3(content).hexdigest() + + assert entry.id != full_hash + assert entry.hash_version == 2 + + +def test_partial_hash_is_deterministic(tmp_path): + from meshbay_node.indexer.indexer import _scan_file, _PARTIAL_THRESHOLD + d = tmp_path / "root" + d.mkdir() + f = d / "big.mkv" + f.write_bytes(os.urandom(_PARTIAL_THRESHOLD + 1)) + + e1 = _scan_file(one_root(d).roots[0], f) + e2 = _scan_file(one_root(d).roots[0], f) + assert e1.id == e2.id + + +def test_group_index_roundtrip_preserves_hash_version(sk_node, gek): + from meshbay_common.protocol import IndexEntry + idx = GroupIndex(group_id="hv-test", sk_node=sk_node, gek=gek) + idx.add_entry(IndexEntry( + id="aaa", name="small.mp4", path="root", size=1024, + type="video", added_at=100, hash_version=1)) + idx.add_entry(IndexEntry( + id="bbb", name="big.mkv", path="root", size=50_000_000, + type="video", added_at=200, hash_version=2)) + + wire = idx.serialize() + recovered = GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek) + + by_id = {e.id: e for e in recovered.entries} + assert by_id["aaa"].hash_version == 1 + assert by_id["bbb"].hash_version == 2 + + +def test_deserialize_without_hash_version_defaults_to_1(sk_node, gek): + """Entries serialized by old code (no hash_version field) must deserialize + as hash_version=1.""" + from meshbay_common.protocol import IndexEntry + idx = GroupIndex(group_id="compat", sk_node=sk_node, gek=gek) + idx.add_entry(IndexEntry( + id="old", name="f.mp4", path="root", size=1024, + type="video", added_at=100)) + wire = idx.serialize() + recovered = GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek) + assert recovered.entries[0].hash_version == 1 + + +def test_index_entry_wire_includes_hash_version(): + from meshbay_common.protocol import IndexEntry, index_entry_wire + e = IndexEntry(id="x", name="f.mp4", path="root", size=1, + type="video", added_at=0, hash_version=2) + w = index_entry_wire(e) + assert w["hash_version"] == 2 + + +@pytest.mark.asyncio +async def test_cache_aware_scan_uses_hash_version(tmp_path, sk_node, gek): + from meshbay_node.indexer.indexer import _PARTIAL_THRESHOLD + d = tmp_path / "root" + d.mkdir() + small = d / "small.mp4" + small.write_bytes(os.urandom(1024)) + big = d / "big.mkv" + big.write_bytes(os.urandom(_PARTIAL_THRESHOLD + 1)) + + cache = IndexCache(db_path=tmp_path / "cache.db") + await cache.open() + + indexer = DirectoryIndexer( + roots=one_root(d), group_id="g", sk_node=sk_node, gek=gek, + cache=cache) + await indexer.initial_scan() + + by_name = {e.name: e for e in indexer.index.entries} + assert by_name["small.mp4"].hash_version == 1 + assert by_name["big.mkv"].hash_version == 2 + + hit_small = await cache.lookup( + str(small), small.stat().st_size, small.stat().st_mtime, + hash_version=1) + assert hit_small is not None + + hit_big = await cache.lookup( + str(big), big.stat().st_size, big.stat().st_mtime, + hash_version=2) + assert hit_big is not None + + await cache.close() |