Three pieces, all read-only or additive — no shard mutation, no
schema-version bump:
1. shard_for_document(document_root, M) in arborist/document.py.
Pure function: int(document_root[:8], 16) % M. 22 tests cover
determinism, range-bounds, near-uniform distribution (±5pp at
N=20k), and seven lock-in fixtures so peers will disagree
loudly if anyone changes the formula.
2. corpus_shard_count meta field + get/set helpers in store.py.
Lives in the existing key/value meta table; SCHEMA_VERSION
stays at v9.8.0 (the DDL doesn't change and source_root is
layout-independent, so cache records survive a reshard).
Legacy shards (without the field) return None; reshard tool
populates it on every target shard at migration time.
3. Pre-migration snapshot captured to
bench/results/pre-migration-snapshot.json:
docs 3,468,392 (3,468,226 globally unique)
chunks 6,235,764
edges 90,593,537
audit 3,468,403
This is the reference set post-reshard row counts must match.
4. Audit-event extraction script writes all 3.47M events from
all 4 shards to /tmp/audit-events.ndjson (2.0 GB) for the
Option-A canonical-chain consolidation step. Verifies chain
integrity on extract — all 4 source chains report 0 breaks.
5. Fixed a wrong chunk count in docs/corpus-history.md
(had ~3.54M/shard; actual is ~1.56M/shard) and added the
edge-count column (~22.6M/shard, 90.6M total). 6.24M chunks
total, not 14.12M.
Tests: 29 new pass (22 routing + 7 meta). No existing tests
touched.
123 lines
3.9 KiB
Python
123 lines
3.9 KiB
Python
"""Extract every audit_events row from every shard to NDJSON.
|
|
|
|
For #000065 Option-A audit-chain consolidation: the reshard moves
|
|
documents to new shards by content hash, so the per-shard audit
|
|
chains are broken at boundaries. Option A rebuilds a single canonical
|
|
chain on shard 000, sorted by ts. Inputs to that rebuild are the
|
|
union of all source shards' audit_events rows — captured here BEFORE
|
|
any source shard is touched.
|
|
|
|
Output: /tmp/audit-events.ndjson
|
|
fields per line:
|
|
src_shard source shard index (0..3)
|
|
src_seq source rowid (forensic reference)
|
|
src_event_hash original chain hash (forensic reference)
|
|
prev_event_hash original chain predecessor (forensic)
|
|
event_type
|
|
subject_root
|
|
body canonical JSON, preserved unchanged
|
|
ts unix seconds
|
|
|
|
Verifies chain integrity on each source shard during extraction so
|
|
we know what's captured is intact going in. Read-only; never mutates
|
|
source shards.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
SHARDS_DIR = Path(os.path.expanduser("~/.arborist/shards"))
|
|
OUT_PATH = Path("/tmp/audit-events.ndjson")
|
|
SHARD_FILES = sorted(SHARDS_DIR.glob("00[0-9].db"))
|
|
|
|
|
|
def _verify_chain(conn: sqlite3.Connection, shard_idx: int) -> tuple[int, int]:
|
|
"""Walk audit_events in seq order, recompute event_hash, count breaks."""
|
|
cur = conn.execute(
|
|
"SELECT seq, event_hash, prev_event_hash, body "
|
|
"FROM audit_events ORDER BY seq ASC"
|
|
)
|
|
prev = None
|
|
total = 0
|
|
breaks = 0
|
|
for seq, event_hash, prev_event_hash, body in cur:
|
|
total += 1
|
|
if prev_event_hash != prev:
|
|
breaks += 1
|
|
h = hashlib.sha256()
|
|
if prev_event_hash is not None:
|
|
h.update(bytes.fromhex(prev_event_hash))
|
|
h.update(body.encode("utf-8", errors="surrogatepass"))
|
|
if h.hexdigest() != event_hash:
|
|
breaks += 1
|
|
prev = event_hash
|
|
return total, breaks
|
|
|
|
|
|
def _extract_shard(shard_idx: int, path: Path, out) -> int:
|
|
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
|
try:
|
|
total, breaks = _verify_chain(conn, shard_idx)
|
|
if breaks:
|
|
print(
|
|
f" WARNING: shard {shard_idx:03d} has {breaks} chain breaks",
|
|
file=sys.stderr,
|
|
)
|
|
cur = conn.execute(
|
|
"SELECT seq, event_hash, prev_event_hash, event_type, "
|
|
"subject_root, body, ts FROM audit_events ORDER BY seq ASC"
|
|
)
|
|
count = 0
|
|
for seq, event_hash, prev_eh, etype, subj, body, ts in cur:
|
|
row = {
|
|
"src_shard": shard_idx,
|
|
"src_seq": seq,
|
|
"src_event_hash": event_hash,
|
|
"prev_event_hash": prev_eh,
|
|
"event_type": etype,
|
|
"subject_root": subj,
|
|
"body": body,
|
|
"ts": ts,
|
|
}
|
|
out.write(json.dumps(row, separators=(",", ":")))
|
|
out.write("\n")
|
|
count += 1
|
|
return count
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def main() -> int:
|
|
if not SHARD_FILES:
|
|
print(f"no shards under {SHARDS_DIR}", file=sys.stderr)
|
|
return 1
|
|
t0 = time.time()
|
|
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
total = 0
|
|
per_shard: list[int] = []
|
|
with OUT_PATH.open("w") as out:
|
|
for shard in SHARD_FILES:
|
|
idx = int(shard.stem)
|
|
n = _extract_shard(idx, shard, out)
|
|
per_shard.append(n)
|
|
total += n
|
|
print(f" shard {idx:03d}: {n:,} events")
|
|
elapsed = time.time() - t0
|
|
size_bytes = OUT_PATH.stat().st_size
|
|
print()
|
|
print(f"wrote {OUT_PATH}")
|
|
print(f" total events: {total:,}")
|
|
print(f" per shard: {per_shard}")
|
|
print(f" file size: {size_bytes / 1e6:.1f} MB")
|
|
print(f" elapsed: {elapsed:.1f}s")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|