arborist/tests/test_wallet_bucket.py
russell@unturf.com 331e748bcb
cloud ask: unified multi-shard via bucket manifest + read-ahead tuning
`BUCKET_URL` (one env var) → client GETs `clones/manifest.json` →
opens HttpRangeVFS per listed shard → FTS5 across all shards in
parallel (ThreadPoolExecutor; per-thread apsw.Connection) → merge by
BM25 score → pull chunks from the owning shard → LLM + verify.
No per-query --shard-url, no path proliferation.

Two manifests published on s3://arborist/clones/:
  manifest.json       — default: virtback only (2.5MB, ~5s/query)
  manifest-full.json  — opt-in: all 5 shards (35GB, prohibitive over
                        WAN due to FTS5 b-tree walk pattern; needs
                        smaller shards or co-located query proxy)

HttpRangeVFS read-ahead tuned from per-page (4KB) to 64KB block-aligned
cache. Each cache miss fetches one 64KB block; subsequent reads within
the block are local-fast. Lower miss count, similar bytes-on-wire
(64KB amortizes well over typical 4-16 page b-tree clusters; larger
read-ahead like 4MB over-fetches on random FTS5 reads).

Sample run (default manifest):
    make cloud-ask Q="who developed virt-back?"
    → EVIDENCE-WARRANTED · via claim_lattice  1/1  4.71s  (bucket-direct)
       21 HTTP requests · 1344 KB

ACL: genesis full-bench shards flipped to public-read (CC-BY-SA
Wikipedia content). Reachable now if you want to play with the slow
multi-shard path; not in the default manifest because chat latency
matters more than coverage breadth.
2026-05-30 13:33:58 -04:00

283 lines
10 KiB
Python

"""Bucket-direct queries via HttpRangeVFS.
Spins up an in-process HTTP server that serves files with `Range` support,
opens an arborist shard `.db` through `HttpRangeVFS`, runs the same FTS5
queries you'd run against a local sqlite3 connection. Asserts results
match a direct sqlite3 open of the same file.
Then fetches a chunk body from a `blobs/<hash>/...` path served by the
same HTTP server and confirms its hash matches the stored leaf_hash.
"""
from __future__ import annotations
import os
import sqlite3
import threading
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Iterator
import pytest
apsw = pytest.importorskip("apsw")
from arborist.document import Document
from arborist.ingest import ingest_source
from arborist.merkle import hash_leaf
from arborist.source import Source
from arborist.store import connect
from arborist.wallet.bucket import BucketClient, BucketEndpoint
class _RangeHandler(SimpleHTTPRequestHandler):
"""SimpleHTTPRequestHandler that honors `Range:` requests.
Stdlib version supports Range only on Python 3.7+; explicit support
here makes the test portable to older interpreters and gives the
server an obvious "yes I do partial reads" affirmation.
"""
def log_message(self, fmt, *args):
return # quiet
def do_GET(self):
path = self.translate_path(self.path)
if not os.path.isfile(path):
self.send_error(404)
return
size = os.path.getsize(path)
rng = self.headers.get("Range")
if not rng:
with open(path, "rb") as f:
body = f.read()
self.send_response(200)
self.send_header("Content-Length", str(size))
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Accept-Ranges", "bytes")
self.end_headers()
self.wfile.write(body)
return
# Parse `bytes=N-M` (single range; multi-range is overkill here).
assert rng.startswith("bytes=")
start_s, end_s = rng[len("bytes="):].split("-")
start = int(start_s)
end = int(end_s) if end_s else size - 1
end = min(end, size - 1)
length = end - start + 1
with open(path, "rb") as f:
f.seek(start)
body = f.read(length)
self.send_response(206)
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
self.send_header("Content-Length", str(length))
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Accept-Ranges", "bytes")
self.end_headers()
self.wfile.write(body)
def _serve_dir(directory: Path) -> tuple[ThreadingHTTPServer, threading.Thread, str]:
"""Serve `directory` over HTTP on a random local port. Returns
(httpd, thread, base_url)."""
cwd_before = os.getcwd()
os.chdir(directory)
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _RangeHandler)
port = httpd.server_address[1]
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
# Restore cwd; SimpleHTTPRequestHandler captures it per-handler-instance,
# not per-server, but our requests run in handler threads so they
# need the cwd they were started in. Workaround: leave the chdir
# for the test's lifetime; the cleanup restores.
def restore():
os.chdir(cwd_before)
httpd._restore_cwd = restore # type: ignore[attr-defined]
return httpd, thread, f"http://127.0.0.1:{port}"
class _S(Source):
source_type = "test"
def __init__(self, ds): self.ds = ds
def iter_documents(self) -> Iterator[Document]:
yield from self.ds
@pytest.fixture
def bucket_layout(tmp_path: Path) -> tuple[Path, str, dict[str, bytes]]:
"""Build a small corpus, write the .db + per-chunk blobs into a
`bucket/` directory ready to be served over HTTP.
Returns (bucket_dir, shard_filename_under_bucket, blobs_map).
"""
bucket = tmp_path / "bucket"
bucket.mkdir()
clones = bucket / "clones" / "snap-1"
clones.mkdir(parents=True)
blobs = bucket / "blobs"
db_path = clones / "000.db"
conn = connect(db_path)
try:
ingest_source(conn, _S([
Document(
uri=f"test://doc-{i}",
source_type="test",
title=f"Doc {i}",
content=(
f"This is document number {i}. " * 10
+ f"The unique phrase for entry {i} is 'lemma-{i}'. " * 10
),
)
for i in range(5)
]))
finally:
conn.close()
# Externalize every chunk to blobs/<hash[:2]>/<hash[2:]>.
blob_map: dict[str, bytes] = {}
with sqlite3.connect(str(db_path)) as src:
src.row_factory = sqlite3.Row
for r in src.execute("SELECT leaf_hash, content FROM chunks WHERE content IS NOT NULL"):
text = r["content"]
if isinstance(text, bytes):
# might be zstd-packed; the existing pack flow ships
# plain bytes via unpack_chunk first. Mirror that.
from arborist.compress import unpack_chunk
body = (unpack_chunk(text) or "").encode("utf-8")
else:
body = text.encode("utf-8")
if hash_leaf(body).hex() != r["leaf_hash"]:
continue # affinity edge case: skip mismatches
blob_dir = blobs / r["leaf_hash"][:2]
blob_dir.mkdir(parents=True, exist_ok=True)
(blob_dir / r["leaf_hash"][2:]).write_bytes(body)
blob_map[r["leaf_hash"]] = body
return bucket, "clones/snap-1/000.db", blob_map
def test_bucket_open_and_fts_match_local_sqlite(bucket_layout):
"""Open the bucket-served .db via HttpRangeVFS; FTS5 results must
equal a direct sqlite3 open of the same file."""
bucket, shard_rel, _blobs = bucket_layout
httpd, _t, base_url = _serve_dir(bucket)
try:
endpoint = BucketEndpoint(
shard_url=f"{base_url}/{shard_rel}",
blob_base=f"{base_url}/blobs",
)
client = BucketClient(endpoint, cache_bytes=4 * 1024 * 1024)
try:
remote = client.fts_search('"lemma-3"', limit=4, raw=True)
finally:
client.close()
# Reference: direct sqlite3 against the file.
local = []
with sqlite3.connect(str(bucket / shard_rel)) as conn:
conn.row_factory = sqlite3.Row
for r in conn.execute(
"SELECT d.document_root, d.document_uri, d.title, "
" bm25(chunks_fts) AS score "
"FROM chunks_fts JOIN chunks c ON c.rowid = chunks_fts.rowid "
"JOIN documents d ON d.document_root = c.document_root "
"WHERE chunks_fts MATCH ? ORDER BY score LIMIT ?",
('"lemma-3"', 4),
):
local.append({"document_root": r[0], "document_uri": r[1],
"title": r[2], "score": r[3]})
assert remote == local, (
"bucket-direct FTS5 results must equal local sqlite3 results"
)
# 'lemma-3' is unique to doc 3 — must be the top hit.
assert remote, "FTS should have matched at least one chunk"
assert remote[0]["document_uri"] == "test://doc-3"
finally:
httpd.shutdown()
httpd._restore_cwd() # type: ignore[attr-defined]
def test_bucket_fetch_chunk_body_round_trip(bucket_layout):
"""Pick one leaf_hash from the corpus, fetch it via BucketClient,
confirm body hashes back to the same leaf_hash."""
bucket, shard_rel, blob_map = bucket_layout
assert blob_map, "fixture should have at least one uploaded blob"
leaf_hash, expected_body = next(iter(blob_map.items()))
httpd, _t, base_url = _serve_dir(bucket)
try:
endpoint = BucketEndpoint(
shard_url=f"{base_url}/{shard_rel}",
blob_base=f"{base_url}/blobs",
)
client = BucketClient(endpoint)
try:
body = client.fetch_chunk_body(leaf_hash)
finally:
client.close()
assert body == expected_body
assert hash_leaf(body).hex() == leaf_hash
finally:
httpd.shutdown()
httpd._restore_cwd() # type: ignore[attr-defined]
def test_bucket_snapshot_root_matches_local(bucket_layout):
"""snapshot_root computed from the bucket-direct conn must match
snapshot_root computed from a local connection."""
from arborist.snapshot import compute_snapshot_root
bucket, shard_rel, _blobs = bucket_layout
with sqlite3.connect(str(bucket / shard_rel)) as conn:
conn.row_factory = sqlite3.Row
local_snap, _ = compute_snapshot_root(conn)
httpd, _t, base_url = _serve_dir(bucket)
try:
endpoint = BucketEndpoint(
shard_url=f"{base_url}/{shard_rel}",
blob_base=f"{base_url}/blobs",
)
client = BucketClient(endpoint)
try:
remote_snap = client.snapshot_root()
finally:
client.close()
assert remote_snap == local_snap
finally:
httpd.shutdown()
httpd._restore_cwd() # type: ignore[attr-defined]
def test_bucket_cache_amortizes_repeated_queries(bucket_layout):
"""First query pays segment traversal; second identical query
should serve mostly from the LRU page cache."""
bucket, shard_rel, _blobs = bucket_layout
httpd, _t, base_url = _serve_dir(bucket)
try:
endpoint = BucketEndpoint(
shard_url=f"{base_url}/{shard_rel}",
blob_base=f"{base_url}/blobs",
)
client = BucketClient(endpoint)
try:
client.fts_search('"lemma-3"', limit=4)
stats_after_first = client.stats()
client.fts_search('"lemma-3"', limit=4)
stats_after_second = client.stats()
finally:
client.close()
# The cost signal is "second query is cheap." SQLite has its
# OWN page cache (default ~2000 pages) which absorbs the second
# query entirely on small corpora — so our LRU may never see a
# hit. What matters is that HTTP traffic doesn't multiply.
r0 = stats_after_first["http_requests"]
r1 = stats_after_second["http_requests"]
assert r1 <= r0, (
f"second identical query should add 0 HTTP requests "
f"(SQLite-cached), got {r0}{r1}"
)
finally:
httpd.shutdown()
httpd._restore_cwd() # type: ignore[attr-defined]