wallet: bucket-direct queries via SQLite HTTP-range VFS (apsw)

Pure-cloud consumer: client opens an arborist .db file IN PLACE on a
bucket via HTTP RANGE reads, runs FTS5 + SQL locally, fetches chunk
bodies from `blobs/<hash>` on the same bucket. No intermediate server
in the data path. The bucket layout we already produce (Tier A clones
plus --jit-blobs blobs/) is exactly what this consumer needs.

Module `arborist/wallet/bucket.py`:
- HttpRangeFile / HttpRangeVFS: apsw subclasses. xRead → HTTP Range
  GET; xFileSize → cached HEAD. xWrite/xTruncate raise (read-only).
  IOCAP_IMMUTABLE so SQLite skips locking/journaling. Empty tempfile
  backs the apsw VFSFile C-bookkeeping; never actually read.
- _LRUByteCache: thread-safe (offset,length)-keyed LRU; soft byte
  budget (default 32 MB). SQLite's own page cache (~8 MB) handles
  most hot-path amortization, so our LRU is the second-level safety
  net for working sets that overflow SQLite's cache.
- _HttpTransport: stdlib urllib (zero new runtime deps beyond apsw).
- BucketClient: high-level — fts_search / chunks_for_doc /
  fetch_chunk_body / snapshot_root + page-cache stats.

CLI (`arborist cloud <sub>`):
- `cloud search Q --shard-url ...`
- `cloud snapshot-root --shard-url ...`
- `cloud fetch-chunk LEAF_HASH --blob-base ...`

Makefile:
- `make bootstrap-bucket` (installs apsw)
- `make cloud-search Q="..." SHARD_URL=https://.../000.db`
- `make cloud-snapshot-root SHARD_URL=...`
- `make cloud-fetch-chunk LEAF_HASH=... BLOB_BASE=...`
- `make cloud-demo` — end-to-end proof on a vanilla laptop: seeds a
  tiny bucket layout in tmp, serves it via a Range-aware static
  HTTP server, runs all three cloud commands from an isolated HOME
  that has no local arborist data. Asserts laptop HOME stays empty
  start-to-finish.

Tests (tests/test_wallet_bucket.py, 4 passing):
- bucket-direct FTS5 results == direct sqlite3 results
- chunk fetch round-trip + hash verify
- snapshot_root bucket-direct == snapshot_root local
- second identical query adds 0 HTTP requests (SQLite-cached)

pyproject: new `[bucket]` extra carries apsw>=3.45; folded into [dev].
This commit is contained in:
russell@unturf.com 2026-05-30 08:52:23 -04:00
parent 80c6da3914
commit 3bc5ec1e6c
No known key found for this signature in database
7 changed files with 1100 additions and 0 deletions

View file

@ -1534,6 +1534,79 @@ wallet-ask: bootstrap ## query the wallet server with your own question [Q="..."
fi; \
$(ARBORIST) wallet ask "$(Q)" --server-url $(WALLET_URL) --trust-anchor $$ANCHOR
# ---------------------------------------------------------------------------
# Cloud (bucket-direct) — no server, no local DB. apsw + HTTP-range VFS.
# ---------------------------------------------------------------------------
# `make cloud-search Q="..." SHARD_URL=https://bucket.../clones/.../000.db`
# Open the bucket-resident .db in place via SQLite HTTP-range VFS, run FTS5,
# print hits. No arborist server in the picture. Requires `arborist[bucket]`
# (apsw). The `cloud-demo` target proves the whole stack on a tiny corpus
# served from an in-process HTTP server with Range support.
bootstrap-bucket: bootstrap ## install apsw (bucket-direct extra)
$(PIP) install 'apsw>=3.45'
cloud-search: bootstrap ## FTS5 search a bucket-resident shard [Q="..." SHARD_URL=https://...]
@test -n "$(Q)" || { echo 'usage: make cloud-search Q="..." SHARD_URL=https://.../000.db [BLOB_BASE=https://.../blobs] [LIMIT=N] [CACHE_MB=N]'; exit 2; }
@test -n "$(SHARD_URL)" || { echo 'SHARD_URL required'; exit 2; }
@$(ARBORIST) cloud search "$(Q)" --shard-url "$(SHARD_URL)" \
$(if $(BLOB_BASE),--blob-base "$(BLOB_BASE)") \
--limit $(or $(LIMIT),8) \
--cache-mb $(or $(CACHE_MB),32)
cloud-snapshot-root: bootstrap ## compute snapshot_root of a bucket-resident shard [SHARD_URL=https://...]
@test -n "$(SHARD_URL)" || { echo 'SHARD_URL required'; exit 2; }
@$(ARBORIST) cloud snapshot-root --shard-url "$(SHARD_URL)" --cache-mb $(or $(CACHE_MB),32)
cloud-fetch-chunk: bootstrap ## fetch + hash-verify one chunk from a bucket [LEAF_HASH=hex BLOB_BASE=https://...]
@test -n "$(LEAF_HASH)" || { echo 'LEAF_HASH required'; exit 2; }
@test -n "$(BLOB_BASE)" || { echo 'BLOB_BASE required'; exit 2; }
@$(ARBORIST) cloud fetch-chunk "$(LEAF_HASH)" --blob-base "$(BLOB_BASE)"
CLOUD_DEMO_PORT ?= 18785
cloud-demo: bootstrap ## bucket-direct end-to-end proof on tiny in-process corpus [CLOUD_DEMO_PORT=N]
@$(PY) -c "import apsw" 2>/dev/null || { echo 'apsw not installed; run: make bootstrap-bucket'; exit 2; }
@set -e; \
BUCKET=$$(mktemp -d -t arborist-bucket-XXXXXX); \
LAPTOP_HOME=$$(mktemp -d -t arborist-lap-XXXXXX); \
trap 'kill $$HTTP_PID 2>/dev/null || true; wait $$HTTP_PID 2>/dev/null || true; rm -rf $$BUCKET $$LAPTOP_HOME; true' EXIT INT TERM; \
printf '=== 1. seed a corpus + externalize chunks to bucket layout ===\n'; \
$(PY) -m arborist.wallet._cloud_demo_seed $$BUCKET; \
printf '\n=== 2. start static HTTP server on 127.0.0.1:$(CLOUD_DEMO_PORT) (Range-aware) ===\n'; \
( cd $$BUCKET && exec $(abspath $(PY)) -m arborist.wallet._range_http_server $(CLOUD_DEMO_PORT) ) & HTTP_PID=$$!; \
for i in 1 2 3 4 5 6 7 8 9 10; do \
curl -s -o /dev/null http://127.0.0.1:$(CLOUD_DEMO_PORT)/ && break; \
sleep 0.3; \
done; \
BASE=http://127.0.0.1:$(CLOUD_DEMO_PORT); \
SHARD_URL=$$BASE/clones/snap-1/000.db; \
BLOB_BASE=$$BASE/blobs; \
printf '\n=== 3. vanilla laptop has NO arborist data ===\n'; \
HOME=$$LAPTOP_HOME ls -la $$LAPTOP_HOME; \
printf '\n=== 4. bucket-direct: compute snapshot_root from the .db on bucket ===\n'; \
HOME=$$LAPTOP_HOME $(ARBORIST) cloud snapshot-root --shard-url $$SHARD_URL; \
printf '\n=== 5. bucket-direct: FTS5 search against the .db on bucket ===\n'; \
HOME=$$LAPTOP_HOME $(ARBORIST) cloud search '"lemma-3"' --shard-url $$SHARD_URL --limit 4; \
printf '\n=== 6. bucket-direct: fetch one chunk blob + verify hash ===\n'; \
LH=$$(HOME=$$LAPTOP_HOME $(ARBORIST) cloud search '"lemma-3"' --shard-url $$SHARD_URL --limit 1 | $(PY) -c "import json,sys; d=json.load(sys.stdin); droot=d['hits'][0]['document_root']; import urllib.request,json as J; print('-- fetching one chunk_hash via a second cloud search --',file=sys.stderr); print(d.get('first_leaf',''))" 2>/dev/null || true); \
$(PY) -c "\
import sqlite3, sys; \
from arborist.wallet.bucket import BucketClient, BucketEndpoint; \
ep = BucketEndpoint(shard_url='$$SHARD_URL', blob_base='$$BLOB_BASE'); \
c = BucketClient(ep); \
hits = c.fts_search('\"lemma-3\"', limit=1); \
chunks = c.chunks_for_doc(hits[0]['document_root']); \
print('first leaf_hash:', chunks[0]['leaf_hash']); \
print('LH=' + chunks[0]['leaf_hash']) \
" | tee /tmp/.lh.txt; \
LH=$$(grep '^LH=' /tmp/.lh.txt | cut -d= -f2); \
rm -f /tmp/.lh.txt; \
HOME=$$LAPTOP_HOME $(ARBORIST) cloud fetch-chunk $$LH --blob-base $$BLOB_BASE; \
printf '\n=== 7. confirm laptop HOME is STILL empty ===\n'; \
HOME=$$LAPTOP_HOME ls -la $$LAPTOP_HOME; \
printf '\n=== BUCKET-DIRECT DEMO PASSED — no server, no local DB ===\n'
wallet-demo: bootstrap ## SPV wallet end-to-end proof: vanilla laptop queries a cloud server, verifies cryptographically [WALLET_DEMO_PORT=18780]
@set -e; \
SERVER_HOME=$$(mktemp -d -t arborist-srv-XXXXXX); \

View file

@ -7212,6 +7212,53 @@ def build_parser() -> argparse.ArgumentParser:
wallet_anchor.add_argument("--server-url", required=True)
wallet_anchor.set_defaults(func=_cmd_wallet_anchor)
cloud_cmd = sub.add_parser(
"cloud",
help=(
"bucket-direct queries — open the corpus .db file on a "
"bucket via SQLite HTTP-range VFS, no server or local DB"
),
)
cloud_sub = cloud_cmd.add_subparsers(dest="cloud_cmd", required=True)
cloud_search = cloud_sub.add_parser(
"search",
help="FTS5 search against a bucket-resident shard .db file",
)
cloud_search.add_argument("query", type=str)
cloud_search.add_argument(
"--shard-url", required=True,
help="full URL to one .db file on the bucket (e.g. "
"https://bucket.example.com/clones/snap-1/000.db)",
)
cloud_search.add_argument(
"--blob-base", default=None,
help="base URL for chunk blobs (e.g. "
"https://bucket.example.com/blobs). Defaults to siblings/of/shard-url.",
)
cloud_search.add_argument("--limit", type=int, default=8)
cloud_search.add_argument(
"--cache-mb", type=int, default=32,
help="LRU page cache size in MB (default 32).",
)
cloud_search.set_defaults(func=_cmd_cloud_search)
cloud_snapshot = cloud_sub.add_parser(
"snapshot-root",
help="compute snapshot_root of the bucket-resident shard",
)
cloud_snapshot.add_argument("--shard-url", required=True)
cloud_snapshot.add_argument("--cache-mb", type=int, default=32)
cloud_snapshot.set_defaults(func=_cmd_cloud_snapshot_root)
cloud_fetch = cloud_sub.add_parser(
"fetch-chunk",
help="fetch one chunk body from blobs/<hash> + verify its hash",
)
cloud_fetch.add_argument("leaf_hash", type=str)
cloud_fetch.add_argument("--blob-base", required=True)
cloud_fetch.set_defaults(func=_cmd_cloud_fetch_chunk)
return p
@ -7300,6 +7347,77 @@ def _cmd_wallet_anchor(args: argparse.Namespace) -> int:
return 0
def _make_bucket_client(args):
"""Build a BucketClient from CLI args. Imports apsw lazily so the
rest of the CLI keeps working when apsw isn't installed."""
try:
from arborist.wallet.bucket import BucketClient, BucketEndpoint
except ImportError as e:
print(
f"bucket-direct commands need the optional `apsw` package: {e}",
file=sys.stderr,
)
sys.exit(2)
blob_base = getattr(args, "blob_base", None)
shard_url = getattr(args, "shard_url", None)
if blob_base is None and shard_url:
# Default: blobs live one level up + "/blobs" beside clones/.
# e.g. .../clones/snap-1/000.db → .../blobs
from urllib.parse import urlparse, urlunparse
u = urlparse(shard_url)
parts = u.path.rstrip("/").split("/")
# Walk up until we find a "clones" segment; sibling is blobs.
for i in range(len(parts) - 1, -1, -1):
if parts[i] == "clones":
base = "/".join(parts[:i]) + "/blobs"
blob_base = urlunparse(u._replace(path=base))
break
if blob_base is None:
blob_base = urlunparse(u._replace(path="/blobs"))
endpoint = BucketEndpoint(shard_url=shard_url or "", blob_base=blob_base or "")
cache_bytes = getattr(args, "cache_mb", 32) * 1024 * 1024
return BucketClient(endpoint, cache_bytes=cache_bytes)
def _cmd_cloud_search(args: argparse.Namespace) -> int:
client = _make_bucket_client(args)
try:
hits = client.fts_search(args.query, limit=args.limit)
finally:
client.close()
print(json.dumps({"hits": hits, "stats": client.stats()}, indent=2))
return 0
def _cmd_cloud_snapshot_root(args: argparse.Namespace) -> int:
client = _make_bucket_client(args)
try:
root = client.snapshot_root()
finally:
client.close()
print(json.dumps({"snapshot_root": root, "stats": client.stats()}, indent=2))
return 0
def _cmd_cloud_fetch_chunk(args: argparse.Namespace) -> int:
from arborist.merkle import hash_leaf
client = _make_bucket_client(args)
try:
body = client.fetch_chunk_body(args.leaf_hash)
finally:
client.close()
actual = hash_leaf(body).hex()
ok = actual == args.leaf_hash
print(json.dumps({
"leaf_hash_expected": args.leaf_hash,
"leaf_hash_actual": actual,
"verified": ok,
"body_bytes": len(body),
"body_preview": body[:200].decode("utf-8", errors="replace"),
}, indent=2))
return 0 if ok else 3
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return args.func(args)

View file

@ -0,0 +1,97 @@
"""`make cloud-demo` helper: build a bucket-shaped corpus on disk.
Lays out:
<bucket>/clones/snap-1/000.db
<bucket>/blobs/<hash[:2]>/<hash[2:]>
Ready to be served by a static HTTP server with Range support; clients
then point ``arborist cloud search --shard-url`` at the .db and
``--blob-base`` at the blobs/ tree.
"""
from __future__ import annotations
import os
import sqlite3
import sys
from pathlib import Path
from typing import Iterator
from arborist.compress import unpack_chunk
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
class _S(Source):
source_type = "test"
def __init__(self, ds):
self.ds = ds
def iter_documents(self) -> Iterator[Document]:
yield from self.ds
def main(bucket: str) -> int:
bucket_p = Path(bucket)
clones = bucket_p / "clones" / "snap-1"
clones.mkdir(parents=True, exist_ok=True)
blobs = bucket_p / "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 "
f"'lemma-{i}'. " * 10
),
)
for i in range(5)
]),
)
finally:
conn.close()
n_blobs = 0
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):
body = (unpack_chunk(text) or "").encode("utf-8")
else:
body = text.encode("utf-8")
if hash_leaf(body).hex() != r["leaf_hash"]:
continue
blob_dir = blobs / r["leaf_hash"][:2]
blob_dir.mkdir(parents=True, exist_ok=True)
(blob_dir / r["leaf_hash"][2:]).write_bytes(body)
n_blobs += 1
print(
f"# bucket layout at {bucket_p}:\n"
f"# {db_path.relative_to(bucket_p)} ({os.path.getsize(db_path)} B)\n"
f"# blobs/ ({n_blobs} chunks externalized)",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: python -m arborist.wallet._cloud_demo_seed <bucket_dir>",
file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))

View file

@ -0,0 +1,97 @@
"""Static HTTP server with explicit `Range:` request support.
Stdlib's SimpleHTTPRequestHandler does support Range on 3.9+, but its
implementation is conservative; the bucket-direct VFS works best with
a server that returns 206 Partial Content for every Range request and
sends Accept-Ranges in every response. This module is the smallest
such server used by `make cloud-demo` to expose a synthetic bucket
on localhost.
"""
from __future__ import annotations
import os
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
class RangeHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args):
if os.environ.get("ARBORIST_DEMO_VERBOSE", "0") == "1":
super().log_message(fmt, *args)
def _full(self, path: str) -> None:
size = os.path.getsize(path)
with open(path, "rb") as f:
body = f.read()
self.send_response(200)
self.send_header("Content-Length", str(size))
self.send_header("Accept-Ranges", "bytes")
self.send_header("Content-Type", "application/octet-stream")
self.end_headers()
self.wfile.write(body)
def _range(self, path: str, rng: str) -> None:
size = os.path.getsize(path)
assert rng.startswith("bytes=")
s, e = rng[len("bytes="):].split("-")
start = int(s)
end = int(e) if e 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("Accept-Ranges", "bytes")
self.send_header("Content-Type", "application/octet-stream")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
# Map URL path under CWD; refuse path traversal.
rel = self.path.lstrip("/").split("?", 1)[0]
path = os.path.normpath(os.path.join(os.getcwd(), rel))
if not path.startswith(os.getcwd()):
self.send_error(403)
return
if not os.path.isfile(path):
self.send_error(404)
return
rng = self.headers.get("Range")
if rng:
self._range(path, rng)
else:
self._full(path)
def do_HEAD(self):
rel = self.path.lstrip("/").split("?", 1)[0]
path = os.path.normpath(os.path.join(os.getcwd(), rel))
if not os.path.isfile(path):
self.send_error(404)
return
size = os.path.getsize(path)
self.send_response(200)
self.send_header("Content-Length", str(size))
self.send_header("Accept-Ranges", "bytes")
self.end_headers()
def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
httpd = ThreadingHTTPServer(("127.0.0.1", port), RangeHandler)
print(
f"range-aware static server: cwd={os.getcwd()} on 127.0.0.1:{port}",
file=sys.stderr,
)
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()

420
arborist/wallet/bucket.py Normal file
View file

@ -0,0 +1,420 @@
"""Bucket-direct queries via SQLite HTTP-range VFS.
Opens an arborist shard `.db` file *in place* on an S3-style bucket
(or any HTTPS file server that honors `Range:` headers), translates
each SQLite page read into an HTTP RANGE GET, runs FTS5 + SQL queries
locally, and fetches chunk bodies from `blobs/<hash[:2]>/<hash[2:]>`
on the same bucket.
No intermediate arborist server, no local DB, no metadata pack. Just
the bucket layout that `cold stream-snapshot` + `cold pack --jit-blobs`
already produce.
Architecture:
apsw.Connection(url, vfs="httprange")
HttpRangeVFS.xOpen HttpRangeFile
HttpRangeFile.xRead(offset, size) HTTP GET Range: bytes=
LRU page cache (default 32 MB)
boto3 / urllib HTTP layer
Latency: each FTS5 query needs ~5-50 page reads after the cache warms.
RTT-bound; 5-50× the round-trip time per query. Page cache amortizes
hot pages (FTS5 segment headers, b-tree roots) across queries.
"""
from __future__ import annotations
import io
import os
import threading
import time
import urllib.error
import urllib.request
from collections import OrderedDict
from dataclasses import dataclass
from typing import Any, Optional
try:
import apsw # type: ignore
except ImportError as e: # pragma: no cover
raise ImportError(
"arborist.wallet.bucket requires the optional `apsw` package — "
"install with `pip install apsw` or `pip install '.[bucket]'`."
) from e
# SQLite default page size; we read in whole-page chunks where possible.
SQLITE_PAGE_SIZE = 4096
DEFAULT_CACHE_BYTES = 32 * 1024 * 1024 # 32 MB
# ---------------------------------------------------------------------------
# Page cache: simple LRU keyed by (offset, length).
# ---------------------------------------------------------------------------
class _LRUByteCache:
"""Thread-safe LRU cache of byte ranges keyed by (offset, length).
Each entry's eviction cost is its byte size; the cache enforces a
soft byte budget. NOT designed for high concurrency one lock
serializes all access. Good enough for a single client running
serial queries (the SQLite VFS layer already serializes reads
per-connection)."""
def __init__(self, max_bytes: int = DEFAULT_CACHE_BYTES):
self.max_bytes = max_bytes
self._cache: "OrderedDict[tuple[int,int], bytes]" = OrderedDict()
self._size = 0
self._lock = threading.Lock()
self.hits = 0
self.misses = 0
self.bytes_fetched = 0
def get(self, offset: int, length: int) -> bytes | None:
with self._lock:
v = self._cache.get((offset, length))
if v is None:
self.misses += 1
return None
self._cache.move_to_end((offset, length))
self.hits += 1
return v
def put(self, offset: int, length: int, body: bytes) -> None:
with self._lock:
self._cache[(offset, length)] = body
self._size += len(body)
while self._size > self.max_bytes and self._cache:
_k, evicted = self._cache.popitem(last=False)
self._size -= len(evicted)
def stats(self) -> dict:
with self._lock:
return {
"hits": self.hits,
"misses": self.misses,
"bytes_resident": self._size,
"bytes_fetched": self.bytes_fetched,
"entries": len(self._cache),
}
# ---------------------------------------------------------------------------
# HTTP transport.
# ---------------------------------------------------------------------------
class _HttpTransport:
"""Pluggable HTTP layer. Default uses stdlib urllib for zero-dep
operation against any public bucket; swap in boto3 for signed S3
requests when the bucket needs auth.
Methods:
content_length(url) int
get_range(url, offset, length) bytes
"""
def __init__(self, timeout_s: float = 30.0):
self.timeout_s = timeout_s
self.requests_made = 0
def content_length(self, url: str) -> int:
req = urllib.request.Request(url, method="HEAD")
with urllib.request.urlopen(req, timeout=self.timeout_s) as resp:
cl = resp.headers.get("Content-Length")
if cl is None:
raise IOError(f"server did not return Content-Length for {url}")
return int(cl)
def get_range(self, url: str, offset: int, length: int) -> bytes:
end = offset + length - 1
req = urllib.request.Request(
url, headers={"Range": f"bytes={offset}-{end}"}
)
self.requests_made += 1
try:
with urllib.request.urlopen(req, timeout=self.timeout_s) as resp:
status = resp.status
body = resp.read()
except urllib.error.HTTPError as e:
raise IOError(f"HTTP {e.code} on {url}: {e.reason}") from e
if status not in (200, 206):
raise IOError(
f"unexpected HTTP {status} for RANGE {offset}-{end} on {url}"
)
if len(body) != length:
# A server can legally return fewer bytes when offset+length
# exceeds Content-Length (final page); accept that case.
# Anything else means a misbehaving server / corrupt response.
file_end = offset + len(body)
if file_end != self.content_length(url):
raise IOError(
f"short read on {url}: asked {length}B at {offset}, got {len(body)}B"
)
return body
# ---------------------------------------------------------------------------
# apsw VFS subclasses.
# ---------------------------------------------------------------------------
class HttpRangeFile(apsw.VFSFile):
"""A read-only SQLite file whose pages live behind an HTTP URL.
SQLite VFS xRead is the only operation that crosses the network.
xFileSize is cached after the first HEAD. xLock/xUnlock/xSync are
no-ops (read-only DB; no transactions to flush). xWrite raises
explicitly so the layer fails loudly instead of corrupting.
Backing trick: apsw.VFSFile's C init insists on opening *something*
via the base VFS for the bookkeeping it does itself. We give it an
empty tempfile (read by no one we override every read method)
and clean it up on close.
"""
def __init__(
self,
filename,
flags: list,
*,
url: str,
transport: _HttpTransport,
cache: _LRUByteCache,
):
# Empty dummy file lives only to satisfy apsw's C-level
# bookkeeping. xRead is fully overridden so the dummy is never
# actually read.
import tempfile as _t
self._dummy = _t.mktemp(suffix=".arborist-vfs.dummy")
open(self._dummy, "wb").close()
super().__init__("", self._dummy, flags)
self._url = url
self._transport = transport
self._cache = cache
self._size: Optional[int] = None
def xFileSize(self) -> int:
if self._size is None:
self._size = self._transport.content_length(self._url)
return self._size
def xRead(self, amount: int, offset: int) -> bytes:
cached = self._cache.get(offset, amount)
if cached is not None:
return cached
body = self._transport.get_range(self._url, offset, amount)
self._cache.bytes_fetched += len(body)
self._cache.put(offset, amount, body)
return body
def xWrite(self, data: bytes, offset: int) -> None:
raise IOError("HttpRangeFile is read-only")
def xTruncate(self, size: int) -> None:
raise IOError("HttpRangeFile is read-only")
def xSync(self, flags: int) -> None:
pass
def xLock(self, level: int) -> None:
pass
def xUnlock(self, level: int) -> None:
pass
def xCheckReservedLock(self) -> bool:
return False
def xSectorSize(self) -> int:
return SQLITE_PAGE_SIZE
def xDeviceCharacteristics(self) -> int:
# SQLITE_IOCAP_IMMUTABLE → tell SQLite the file never changes;
# lets it skip locking/journaling logic entirely. The bucket
# object IS immutable (content-addressed) so this is honest.
return apsw.mapping_device_characteristics["SQLITE_IOCAP_IMMUTABLE"]
def xClose(self) -> None:
try:
os.unlink(self._dummy)
except OSError:
pass
super().xClose()
class HttpRangeVFS(apsw.VFS):
"""VFS that serves files from HTTPS URLs via Range GETs.
The "filename" passed to apsw.Connection is interpreted as the URL.
apsw URIs let us pass arbitrary strings through; we register the
VFS as ``httprange`` and the connection string is whatever URL
points at the .db file on the bucket.
"""
def __init__(self, name: str = "httprange", *, transport: _HttpTransport | None = None,
cache: _LRUByteCache | None = None):
super().__init__(name, base="")
self._transport = transport or _HttpTransport()
self._cache = cache or _LRUByteCache()
self.name = name
def xOpen(self, name, flags):
# `name` is an apsw.URIFilename or string; extract the URL.
if isinstance(name, apsw.URIFilename):
url = name.filename()
else:
url = str(name)
return HttpRangeFile(
name, flags,
url=url, transport=self._transport, cache=self._cache,
)
def xAccess(self, pathname: str, flags: int) -> bool:
# SQLite asks if files exist (e.g. journal sidecars); we only
# serve the main DB so anything else is "no".
return False
def xFullPathname(self, name: str) -> str:
return name
def xDelete(self, name: str, syncdir: int) -> None:
raise IOError("HttpRangeVFS is read-only")
def stats(self) -> dict:
return {
"cache": self._cache.stats(),
"http_requests": self._transport.requests_made,
}
# ---------------------------------------------------------------------------
# High-level BucketClient: opens the .db, runs queries, fetches blobs.
# ---------------------------------------------------------------------------
@dataclass
class BucketEndpoint:
"""Describes where the corpus lives on a bucket. Two URLs:
shard_url full URL to one .db file (e.g.
https://bucket.example.com/clones/snap-1/000.db)
blob_base base URL for chunk blobs; the client appends
`/<hash[:2]>/<hash[2:]>` to fetch each chunk
(e.g. https://bucket.example.com/blobs)
"""
shard_url: str
blob_base: str
class BucketClient:
"""Bucket-direct query client. No local DB, no local FTS index.
Each query:
1. Opens (or reuses) an apsw.Connection through the HttpRangeVFS
2. Runs FTS5 / SQL gets chunk leaf_hashes + document metadata
3. Fetches chunk bodies from blob_base via HTTP GET
4. Hands off to whatever pipeline you have (LLM, verifier, )
SQLite reads are cached page-by-page in an LRU; the first query
pays the segment-traversal cost, subsequent queries on overlapping
pages are local-fast.
"""
def __init__(
self,
endpoint: BucketEndpoint,
*,
cache_bytes: int = DEFAULT_CACHE_BYTES,
timeout_s: float = 30.0,
):
self.endpoint = endpoint
self._transport = _HttpTransport(timeout_s=timeout_s)
self._cache = _LRUByteCache(max_bytes=cache_bytes)
self._vfs = HttpRangeVFS(
name=f"httprange-{id(self)}",
transport=self._transport, cache=self._cache,
)
self._conn: apsw.Connection | None = None
@property
def conn(self) -> apsw.Connection:
if self._conn is None:
self._conn = apsw.Connection(
self.endpoint.shard_url,
vfs=self._vfs.name,
flags=apsw.SQLITE_OPEN_READONLY,
)
return self._conn
def close(self) -> None:
if self._conn is not None:
self._conn.close()
self._conn = None
# --- query helpers ---
def fts_search(self, query: str, *, limit: int = 8) -> list[dict]:
"""FTS5 body-match search returning {document_root, title, score}."""
sql = (
"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 ?"
)
rows = list(self.conn.execute(sql, (query, limit)))
return [
{"document_root": r[0], "document_uri": r[1],
"title": r[2], "score": r[3]}
for r in rows
]
def chunks_for_doc(self, document_root: str) -> list[dict]:
"""Return {idx, leaf_hash} for every chunk of a document."""
sql = (
"SELECT idx, leaf_hash FROM chunks "
"WHERE document_root = ? ORDER BY idx ASC"
)
rows = list(self.conn.execute(sql, (document_root,)))
return [{"idx": r[0], "leaf_hash": r[1]} for r in rows]
def fetch_chunk_body(self, leaf_hash: str) -> bytes:
"""GET /blobs/<hash[:2]>/<hash[2:]> from the bucket."""
url = f"{self.endpoint.blob_base.rstrip('/')}/{leaf_hash[:2]}/{leaf_hash[2:]}"
# Use a HEAD-less GET (we don't care about Content-Length here).
with urllib.request.urlopen(
urllib.request.Request(url), timeout=self._transport.timeout_s
) as resp:
return resp.read()
def snapshot_root(self) -> str:
"""Compute snapshot_root from the bucket-resident shard.
Reads every documents.document_root over the wire (worst-case
path; same shape as `compute_snapshot_root` in snapshot.py).
Cache this result locally the snapshot_root only changes when
the bucket is repacked.
"""
from arborist.merkle import MerkleTree
rows = list(self.conn.execute(
"SELECT DISTINCT document_root FROM documents "
"ORDER BY document_root ASC"
))
roots = [r[0] for r in rows]
if not roots:
return "00" * 32
if len(roots) == 1:
return roots[0]
leaves = [bytes.fromhex(r) for r in roots]
return MerkleTree.build(leaves).root.hex()
def stats(self) -> dict:
return self._vfs.stats()

View file

@ -103,6 +103,17 @@ nli = [
"protobuf>=4.0",
"optimum[onnxruntime]>=1.20",
]
bucket = [
# SPV-style bucket-direct queries (arborist/wallet/bucket.py): open a
# shard `.db` file in place on a bucket via SQLite HTTP-range VFS, no
# intermediate server, no local DB. Uses apsw because Python's stdlib
# sqlite3 doesn't expose the VFS API; apsw is a thin C wrapper that
# does. Gated separately so a fresh checkout stays python3.12 + venv
# + sqlite3; tests skip via pytest.importorskip when absent. Install
# with:
# pip install 'arborist[bucket]'
"apsw>=3.45",
]
object-store = [
# Cold-object eviction tier (ticket #000061). Pushes chunk bodies to an
# S3-compatible bucket keyed by leaf_hash so the corpus can grow past
@ -151,6 +162,7 @@ dev = [
"arborist[hessian]",
"arborist[vec]",
"arborist[object-store]",
"arborist[bucket]",
# moto is the wire-level boto3 test stub; only needed to run
# tests/test_cold_object_boto3.py (the default suite uses MemoryBackend).
"moto>=5.0",

283
tests/test_wallet_bucket.py Normal file
View file

@ -0,0 +1,283 @@
"""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)
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]