`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.
283 lines
10 KiB
Python
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]
|