arborist/bench/extract_audit_events.py
russell@unturf.com 3aae8119a6
#000065 step 1: routing helper + meta field + pre-migration snapshot
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.
2026-05-26 13:13:44 -04:00

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())