#000065 step 3: strategy A executor + CLI + plan-only preview
Build the 'all_at_once' executor and wire it into the CLI as
`arborist corpus reshard`. Plan-only preview against fox's host
confirms the planner picks all_at_once at 95.5 GB free, 64.2 GB
peak draw, 31.3 GB free at peak (well above the 4 GB safety).
Executor (arborist/migrate.py):
* ROUTED_BY_DOCUMENT_ROOT — documents, document_http_meta, chunks,
merkle_nodes, edges (by src_root), derivations (by core_root).
Each row hashed to shard_for_document(root, M) and INSERT'd into
the chosen target.
* CONSOLIDATED_TABLES — snapshots, concept_relations,
concept_token_idf, citation_aliases, term_aliases,
providence_cache, falsifications all land on canonical
target shard 000.
* REBUILT_ON_TARGET — chunks_fts + documents_fts rebuilt from the
materialized data after content moves. chunks.content is
zstd-packed at rest so the rebuilder decompresses via
arborist.compress.unpack_chunk before inserting plaintext into
the FTS5 index.
* Audit chain consolidation (Option A) — all rows from
/tmp/audit-events.ndjson are sorted by (ts, src_shard, src_seq),
re-chained with fresh event_hash values, and INSERT'd into
target shard 000. Bodies preserved unchanged for forensic
fidelity.
* One 'reshard' audit event appended at the tail, carrying the
plan + result as the body. An operator months later can answer
"where did this corpus topology come from" from this one row.
* corpus_shard_count meta stamped on every target shard.
* VACUUM each target at end to reclaim INSERT-pattern fragmentation.
CLI: `arborist corpus reshard --to M --source-dir DIR --target-dir DIR
[--plan-only | --force-strategy X
| --allow-in-place | --dry-run]`
11 integration tests build a tiny 2-shard corpus, run the migration,
verify per-doc routing, chunk-follows-doc, audit chain integrity,
reshard event at tail, corpus_shard_count meta on every target,
chunks_fts searchability, doc count preservation, dry-run no-op,
error handling.
Also: get_meta() now indexes by position so callers without
row_factory=sqlite3.Row don't trip. No behavior change for callers
that DO use Row factory.
Strategy B (per_source_shard) and C (streaming_row) are stubbed in
the planner (peak-draw estimators wired) but execute_plan raises
NotImplementedError for them. Not needed at fox's current disk
(88+ GB free); skipping the build until that's ever the constrained
path.
This commit is contained in:
parent
8fe389d45e
commit
77b90bfcf1
4 changed files with 941 additions and 4 deletions
137
arborist/cli.py
137
arborist/cli.py
|
|
@ -2803,6 +2803,87 @@ def _make_cold_backend():
|
|||
)
|
||||
|
||||
|
||||
def _cmd_corpus_reshard(args: argparse.Namespace) -> int:
|
||||
"""Content-hash reshard (#000065).
|
||||
|
||||
Planner picks A/B/C from disk; executor runs the picked strategy.
|
||||
The plan + rationale always print first so the operator can review
|
||||
before any data moves. --plan-only stops after that.
|
||||
"""
|
||||
from arborist.migrate import (
|
||||
HydrationPlanner,
|
||||
InsufficientDisk,
|
||||
execute_plan,
|
||||
)
|
||||
|
||||
source_dir = Path(args.source_dir).expanduser()
|
||||
target_dir = Path(args.target_dir).expanduser()
|
||||
audit_ndjson = Path(args.audit_ndjson).expanduser()
|
||||
|
||||
try:
|
||||
plan = HydrationPlanner().plan(
|
||||
source_dir=source_dir,
|
||||
target_dir=target_dir,
|
||||
target_M=args.target_M,
|
||||
force_strategy=args.force_strategy,
|
||||
allow_in_place=args.allow_in_place,
|
||||
)
|
||||
except InsufficientDisk as e:
|
||||
print(f"insufficient disk: {e}", file=sys.stderr)
|
||||
return 1
|
||||
except (ValueError, FileNotFoundError) as e:
|
||||
print(f"plan error: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# Always show the plan readout. Goes to stdout (not stderr) so
|
||||
# operators can pipe to a log.
|
||||
print(json.dumps(plan.as_audit_body(), indent=2, ensure_ascii=False))
|
||||
print(
|
||||
f"\nstrategy: {plan.strategy}\n"
|
||||
f"peak draw est: {plan.estimated_peak_draw_bytes / 1e9:.1f} GB\n"
|
||||
f"free at peak: {plan.free_at_peak_bytes() / 1e9:.1f} GB\n"
|
||||
f"rationale: {plan.rationale}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if args.plan_only:
|
||||
return 0
|
||||
|
||||
if not audit_ndjson.exists():
|
||||
print(
|
||||
f"audit ndjson missing at {audit_ndjson}. "
|
||||
f"Run `python bench/extract_audit_events.py` first.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
|
||||
try:
|
||||
result = execute_plan(
|
||||
plan, audit_events_ndjson=audit_ndjson, dry_run=args.dry_run
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
print(f"strategy not implemented: {e}", file=sys.stderr)
|
||||
return 4
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"strategy": result.strategy,
|
||||
"elapsed_seconds": round(result.elapsed_seconds, 1),
|
||||
"rows_moved_total": sum(result.rows_moved.values()),
|
||||
"rows_moved": result.rows_moved,
|
||||
"audit_events_consolidated": result.audit_events_consolidated,
|
||||
"final_event_hash": result.final_event_hash,
|
||||
"target_row_counts": result.target_row_counts,
|
||||
"notes": result.notes,
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_cold_pack(args: argparse.Namespace) -> int:
|
||||
from arborist.evict import push_pack
|
||||
|
||||
|
|
@ -5795,6 +5876,62 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
)
|
||||
cold_list.set_defaults(func=_cmd_cold_list, fetch_manifest=True)
|
||||
|
||||
# --- #000065 content-hash reshard ----------------------------------------
|
||||
corpus_cmd = sub.add_parser(
|
||||
"corpus",
|
||||
help="corpus-wide topology operations (reshard, …)",
|
||||
)
|
||||
corpus_sub = corpus_cmd.add_subparsers(dest="corpus_op", required=True)
|
||||
reshard = corpus_sub.add_parser(
|
||||
"reshard",
|
||||
help=(
|
||||
"migrate shards to a content-hash-routed M-shard layout (#000065). "
|
||||
"Picks a strategy from available disk; use --plan-only first."
|
||||
),
|
||||
)
|
||||
reshard.add_argument(
|
||||
"--to", dest="target_M", type=int, required=True,
|
||||
help="target shard count M (e.g., 4)",
|
||||
)
|
||||
reshard.add_argument(
|
||||
"--source-dir", dest="source_dir", required=True,
|
||||
help="directory of source shards (00[0-9].db files)",
|
||||
)
|
||||
reshard.add_argument(
|
||||
"--target-dir", dest="target_dir", required=True,
|
||||
help="directory to write target shards (will be created)",
|
||||
)
|
||||
reshard.add_argument(
|
||||
"--audit-events-ndjson", dest="audit_ndjson",
|
||||
default="/tmp/audit-events.ndjson",
|
||||
help="ndjson of pre-migration audit events from "
|
||||
"bench/extract_audit_events.py (default /tmp/audit-events.ndjson)",
|
||||
)
|
||||
reshard.add_argument(
|
||||
"--plan-only", dest="plan_only", action="store_true",
|
||||
help="print the chosen strategy + peak draw estimate + rationale, "
|
||||
"then exit. No DBs opened, no rows moved.",
|
||||
)
|
||||
reshard.add_argument(
|
||||
"--force-strategy", dest="force_strategy",
|
||||
choices=["all_at_once", "per_source_shard", "streaming_row"],
|
||||
default=None,
|
||||
help="override the planner's pick. Use only when you know the "
|
||||
"planner's draw estimate is wrong (e.g., for testing strategy B).",
|
||||
)
|
||||
reshard.add_argument(
|
||||
"--allow-in-place", dest="allow_in_place", action="store_true",
|
||||
help="allow strategy C (streaming-row, in-place mutation of source "
|
||||
"shards). Last-resort; disabled by default since it mutates "
|
||||
"source data with no recoverable intermediate.",
|
||||
)
|
||||
reshard.add_argument(
|
||||
"--dry-run", dest="dry_run", action="store_true",
|
||||
help="do everything except actually open the target DBs and move "
|
||||
"rows. Plan still emits, target dir still created.",
|
||||
)
|
||||
reshard.set_defaults(func=_cmd_corpus_reshard)
|
||||
|
||||
activity_cmd = sub.add_parser(
|
||||
"activity",
|
||||
help="recent Q&A + freshly cached docs (agent-readable timeline)",
|
||||
|
|
|
|||
|
|
@ -23,10 +23,16 @@ one ``audit_events`` row at the end of every successful migration.
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from typing import Iterable, Literal
|
||||
|
||||
from arborist.document import shard_for_document
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -326,3 +332,448 @@ class HydrationPlanner:
|
|||
estimated_final_target_bytes=facts.total_bytes,
|
||||
rationale=rationale,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Table routing — which table goes where during a reshard.
|
||||
#
|
||||
# ROUTED_BY_DOCUMENT_ROOT — every row carries a column named here; the row
|
||||
# follows its parent doc to the target shard hash_for(document_root) % M.
|
||||
#
|
||||
# CONSOLIDATED_TABLES — every row lands on canonical target shard 000.
|
||||
# Corpus-wide or audit-anchored data; no doc routing applies.
|
||||
#
|
||||
# REPLICATED_TO_ALL — every target shard gets the same key/value rows.
|
||||
# Per-shard config (schema_version, corpus_shard_count, etc).
|
||||
#
|
||||
# REBUILT_ON_TARGET — virtual table / index; do NOT copy rows, rebuild
|
||||
# from the migrated content tables instead.
|
||||
#
|
||||
# DROPPED — transient state, no migration value.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ROUTED_BY_DOCUMENT_ROOT: tuple[tuple[str, str], ...] = (
|
||||
# (table_name, column_used_for_routing)
|
||||
("documents", "document_root"),
|
||||
("document_http_meta", "document_root"),
|
||||
("chunks", "document_root"),
|
||||
("merkle_nodes", "document_root"),
|
||||
("edges", "src_root"), # edge follows its source doc
|
||||
("derivations", "core_root"), # derivation row lives with the core
|
||||
)
|
||||
|
||||
CONSOLIDATED_TABLES: tuple[str, ...] = (
|
||||
"snapshots",
|
||||
"concept_relations",
|
||||
"concept_token_idf",
|
||||
"citation_aliases",
|
||||
"term_aliases",
|
||||
"providence_cache",
|
||||
"falsifications",
|
||||
# audit_events handled separately — re-chained, not copied.
|
||||
)
|
||||
|
||||
REPLICATED_TO_ALL: tuple[str, ...] = (
|
||||
"schema_meta",
|
||||
"meta",
|
||||
)
|
||||
|
||||
REBUILT_ON_TARGET: tuple[str, ...] = (
|
||||
"chunks_fts",
|
||||
"documents_fts",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Executor — strategy A "all_at_once"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ExecutionResult:
|
||||
"""Returned by ``execute_plan``. Lands in the migration audit event."""
|
||||
|
||||
strategy: Strategy
|
||||
started_at: float
|
||||
finished_at: float
|
||||
rows_moved: dict[str, int] = field(default_factory=dict)
|
||||
target_row_counts: dict[str, dict[str, int]] = field(default_factory=dict)
|
||||
audit_events_consolidated: int = 0
|
||||
final_event_hash: str | None = None
|
||||
peak_free_at_run_bytes: int | None = None
|
||||
notes: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def elapsed_seconds(self) -> float:
|
||||
return self.finished_at - self.started_at
|
||||
|
||||
|
||||
def _connect_target(path: Path) -> sqlite3.Connection:
|
||||
"""Open or create a target shard with the full v9.8 schema."""
|
||||
from arborist.store import SCHEMA_SQL
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.executescript("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
|
||||
conn.executescript(SCHEMA_SQL)
|
||||
return conn
|
||||
|
||||
|
||||
def _connect_source_readonly(path: Path) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]:
|
||||
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
return [r["name"] for r in rows]
|
||||
|
||||
|
||||
def _route_per_doc_table(
|
||||
*,
|
||||
source: sqlite3.Connection,
|
||||
targets: list[sqlite3.Connection],
|
||||
table: str,
|
||||
route_col: str,
|
||||
M: int,
|
||||
batch_size: int = 10_000,
|
||||
skip_columns: frozenset[str] = frozenset(),
|
||||
) -> int:
|
||||
"""Stream ``table`` rows from one source, route by ``route_col`` hash
|
||||
to one of ``M`` target shards, executemany INSERT into the chosen
|
||||
target. Returns row count moved.
|
||||
|
||||
``skip_columns`` is for AUTOINCREMENT PKs that the target should
|
||||
assign fresh (chunks.chunk_id) — including them would collide across
|
||||
sources.
|
||||
"""
|
||||
cols = _table_columns(source, table)
|
||||
insert_cols = [c for c in cols if c not in skip_columns]
|
||||
placeholders = ",".join(["?"] * len(insert_cols))
|
||||
col_list = ",".join(insert_cols)
|
||||
insert_sql = (
|
||||
f"INSERT OR IGNORE INTO {table} ({col_list}) VALUES ({placeholders})"
|
||||
)
|
||||
route_idx = cols.index(route_col)
|
||||
keep_idxs = [i for i, c in enumerate(cols) if c not in skip_columns]
|
||||
|
||||
total = 0
|
||||
cur = source.execute(f"SELECT {','.join(cols)} FROM {table}")
|
||||
buckets: list[list[tuple]] = [[] for _ in range(M)]
|
||||
while True:
|
||||
batch = cur.fetchmany(batch_size)
|
||||
if not batch:
|
||||
break
|
||||
for row in batch:
|
||||
root = row[route_idx]
|
||||
if not root:
|
||||
continue
|
||||
idx = shard_for_document(root, M)
|
||||
buckets[idx].append(tuple(row[i] for i in keep_idxs))
|
||||
for idx, rows in enumerate(buckets):
|
||||
if not rows:
|
||||
continue
|
||||
t = targets[idx]
|
||||
with t:
|
||||
t.executemany(insert_sql, rows)
|
||||
total += len(rows)
|
||||
rows.clear()
|
||||
return total
|
||||
|
||||
|
||||
def _copy_table_consolidated(
|
||||
*,
|
||||
source: sqlite3.Connection,
|
||||
target: sqlite3.Connection,
|
||||
table: str,
|
||||
batch_size: int = 10_000,
|
||||
) -> int:
|
||||
"""Stream all rows of ``table`` from ``source`` into ``target``."""
|
||||
cols = _table_columns(source, table)
|
||||
if not cols:
|
||||
return 0
|
||||
col_list = ",".join(cols)
|
||||
placeholders = ",".join(["?"] * len(cols))
|
||||
insert_sql = (
|
||||
f"INSERT OR IGNORE INTO {table} ({col_list}) VALUES ({placeholders})"
|
||||
)
|
||||
total = 0
|
||||
cur = source.execute(f"SELECT {col_list} FROM {table}")
|
||||
while True:
|
||||
batch = cur.fetchmany(batch_size)
|
||||
if not batch:
|
||||
break
|
||||
with target:
|
||||
target.executemany(insert_sql, [tuple(r) for r in batch])
|
||||
total += len(batch)
|
||||
return total
|
||||
|
||||
|
||||
def _rebuild_fts_on_target(conn: sqlite3.Connection) -> None:
|
||||
"""Populate the contentless FTS5 indexes from the materialized tables.
|
||||
|
||||
chunks.content is zstd-compressed at rest (see arborist.compress); the
|
||||
FTS5 index needs plaintext. Stream chunks, decompress, batch into FTS.
|
||||
documents.title is plain text → straight copy.
|
||||
"""
|
||||
from arborist.compress import unpack_chunk
|
||||
|
||||
batch_size = 5_000
|
||||
fts_rows: list[tuple[int, str]] = []
|
||||
cur = conn.execute(
|
||||
"SELECT chunk_id, content FROM chunks WHERE content IS NOT NULL"
|
||||
)
|
||||
while True:
|
||||
rows = cur.fetchmany(batch_size)
|
||||
if not rows:
|
||||
break
|
||||
for chunk_id, packed in rows:
|
||||
text = unpack_chunk(packed)
|
||||
if text is None:
|
||||
continue
|
||||
fts_rows.append((chunk_id, text))
|
||||
if len(fts_rows) >= batch_size:
|
||||
with conn:
|
||||
conn.executemany(
|
||||
"INSERT INTO chunks_fts (rowid, content) VALUES (?, ?)",
|
||||
fts_rows,
|
||||
)
|
||||
fts_rows.clear()
|
||||
if fts_rows:
|
||||
with conn:
|
||||
conn.executemany(
|
||||
"INSERT INTO chunks_fts (rowid, content) VALUES (?, ?)",
|
||||
fts_rows,
|
||||
)
|
||||
|
||||
with conn:
|
||||
conn.execute(
|
||||
"INSERT INTO documents_fts (rowid, title) "
|
||||
"SELECT rowid, title FROM documents WHERE title IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
def _consolidate_audit_chain(
|
||||
*,
|
||||
target: sqlite3.Connection,
|
||||
ndjson_path: Path,
|
||||
) -> tuple[int, str | None]:
|
||||
"""Read every audit_events row from ``ndjson_path``, sort by ts,
|
||||
re-chain, and INSERT into the canonical target's audit_events table.
|
||||
|
||||
Body strings are preserved unchanged (forensic fidelity).
|
||||
``event_hash`` values are recomputed under the new (sorted) chain.
|
||||
"""
|
||||
rows: list[dict] = []
|
||||
with ndjson_path.open() as f:
|
||||
for line in f:
|
||||
rows.append(json.loads(line))
|
||||
# Sort by (ts, src_shard, src_seq) for stable tiebreak across shards
|
||||
# with identical wall-clock ts (the bulk-ingest pass timestamped many
|
||||
# events to the same second).
|
||||
rows.sort(key=lambda r: (r["ts"], r["src_shard"], r["src_seq"]))
|
||||
|
||||
prev: str | None = None
|
||||
out_rows: list[tuple] = []
|
||||
for r in rows:
|
||||
body = r["body"]
|
||||
h = hashlib.sha256()
|
||||
if prev is not None:
|
||||
h.update(bytes.fromhex(prev))
|
||||
h.update(body.encode("utf-8", errors="surrogatepass"))
|
||||
event_hash = h.hexdigest()
|
||||
out_rows.append(
|
||||
(
|
||||
event_hash,
|
||||
prev,
|
||||
r["event_type"],
|
||||
r["subject_root"],
|
||||
body,
|
||||
r["ts"],
|
||||
)
|
||||
)
|
||||
prev = event_hash
|
||||
|
||||
if not out_rows:
|
||||
return 0, None
|
||||
with target:
|
||||
target.executemany(
|
||||
"INSERT INTO audit_events "
|
||||
"(event_hash, prev_event_hash, event_type, subject_root, body, ts) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
out_rows,
|
||||
)
|
||||
return len(out_rows), prev
|
||||
|
||||
|
||||
def _validate_row_counts(
|
||||
*,
|
||||
targets: list[sqlite3.Connection],
|
||||
expected: dict[str, int],
|
||||
) -> dict[str, int]:
|
||||
"""Sum row counts across all target shards per table; compare to
|
||||
expected. Returns dict of {table: actual_sum}. Caller decides if
|
||||
a mismatch is fatal — at minimum, log it.
|
||||
"""
|
||||
actual: dict[str, int] = {}
|
||||
for table in ("documents", "chunks", "edges"):
|
||||
total = 0
|
||||
for t in targets:
|
||||
r = t.execute(f"SELECT COUNT(*) FROM {table}").fetchone()
|
||||
total += int(r[0])
|
||||
actual[table] = total
|
||||
return actual
|
||||
|
||||
|
||||
def execute_plan(
|
||||
plan: HydrationPlan,
|
||||
*,
|
||||
audit_events_ndjson: Path,
|
||||
expected_row_counts: dict[str, int] | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> ExecutionResult:
|
||||
"""Execute a :class:`HydrationPlan`. Currently supports strategy A only.
|
||||
|
||||
``audit_events_ndjson`` is the file produced by
|
||||
``bench/extract_audit_events.py`` — captures every source audit event
|
||||
BEFORE any source shard is touched, so Option-A chain consolidation
|
||||
has its inputs even if the source shards have been deleted by the
|
||||
time we re-chain.
|
||||
"""
|
||||
if plan.strategy != "all_at_once":
|
||||
raise NotImplementedError(
|
||||
f"executor for strategy {plan.strategy!r} not built yet; only "
|
||||
f"'all_at_once' is implemented. Use --force-strategy=all_at_once "
|
||||
f"if disk allows, or wait for the per-source-shard executor."
|
||||
)
|
||||
if not audit_events_ndjson.exists():
|
||||
raise FileNotFoundError(
|
||||
f"audit-event ndjson not found at {audit_events_ndjson}. "
|
||||
f"Run bench/extract_audit_events.py first."
|
||||
)
|
||||
return _execute_all_at_once(
|
||||
plan,
|
||||
audit_events_ndjson=audit_events_ndjson,
|
||||
expected_row_counts=expected_row_counts or {},
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
|
||||
def _execute_all_at_once(
|
||||
plan: HydrationPlan,
|
||||
*,
|
||||
audit_events_ndjson: Path,
|
||||
expected_row_counts: dict[str, int],
|
||||
dry_run: bool,
|
||||
) -> ExecutionResult:
|
||||
from arborist.store import set_corpus_shard_count
|
||||
|
||||
started = time.time()
|
||||
plan.target_dir.mkdir(parents=True, exist_ok=True)
|
||||
result = ExecutionResult(
|
||||
strategy="all_at_once", started_at=started, finished_at=started
|
||||
)
|
||||
|
||||
# 1) Open M target connections, each pre-schema-applied.
|
||||
target_paths = [
|
||||
plan.target_dir / f"{i:03d}.db" for i in range(plan.target_M)
|
||||
]
|
||||
if dry_run:
|
||||
result.notes.append("DRY RUN — no DBs opened, no rows moved")
|
||||
result.finished_at = time.time()
|
||||
return result
|
||||
|
||||
targets = [_connect_target(p) for p in target_paths]
|
||||
try:
|
||||
# 2) Per-source-shard: stream every table, route or consolidate.
|
||||
for src_path in plan.corpus_facts.shard_files:
|
||||
src = _connect_source_readonly(src_path)
|
||||
try:
|
||||
# Routed tables — by document_root or src_root or core_root.
|
||||
for table, route_col in ROUTED_BY_DOCUMENT_ROOT:
|
||||
if not _table_exists(src, table):
|
||||
continue
|
||||
skip = frozenset()
|
||||
# chunks.chunk_id is AUTOINCREMENT and would collide
|
||||
# across sources — let target assign new IDs.
|
||||
if table == "chunks":
|
||||
skip = frozenset({"chunk_id"})
|
||||
n = _route_per_doc_table(
|
||||
source=src,
|
||||
targets=targets,
|
||||
table=table,
|
||||
route_col=route_col,
|
||||
M=plan.target_M,
|
||||
skip_columns=skip,
|
||||
)
|
||||
result.rows_moved[f"{table}@{src_path.name}"] = n
|
||||
|
||||
# Consolidated tables — every row to target 0.
|
||||
for table in CONSOLIDATED_TABLES:
|
||||
if not _table_exists(src, table):
|
||||
continue
|
||||
n = _copy_table_consolidated(
|
||||
source=src, target=targets[0], table=table
|
||||
)
|
||||
result.rows_moved[f"{table}@{src_path.name}"] = n
|
||||
finally:
|
||||
src.close()
|
||||
|
||||
# 3) Rebuild FTS5 on every target from materialized rows.
|
||||
for t in targets:
|
||||
_rebuild_fts_on_target(t)
|
||||
|
||||
# 4) Consolidate audit chain into target 0.
|
||||
n_events, final_hash = _consolidate_audit_chain(
|
||||
target=targets[0], ndjson_path=audit_events_ndjson
|
||||
)
|
||||
result.audit_events_consolidated = n_events
|
||||
result.final_event_hash = final_hash
|
||||
|
||||
# 5) Append the migration's own reshard event to the canonical
|
||||
# chain. This is the forensic record an operator finds when they
|
||||
# later ask "where did this corpus topology come from?"
|
||||
result.finished_at = time.time()
|
||||
body = {
|
||||
"kind": "reshard",
|
||||
"plan": plan.as_audit_body(),
|
||||
"result": {
|
||||
"rows_moved_total": sum(result.rows_moved.values()),
|
||||
"audit_events_consolidated": n_events,
|
||||
"elapsed_seconds": result.elapsed_seconds,
|
||||
},
|
||||
}
|
||||
from arborist.store import append_audit
|
||||
|
||||
result.final_event_hash = append_audit(
|
||||
targets[0], event_type="reshard", body=body
|
||||
)
|
||||
|
||||
# 6) Stamp every target shard with the corpus_shard_count meta.
|
||||
for t in targets:
|
||||
with t:
|
||||
set_corpus_shard_count(t, plan.target_M)
|
||||
|
||||
# 7) Validate row counts vs expected snapshot.
|
||||
actual = _validate_row_counts(
|
||||
targets=targets, expected=expected_row_counts
|
||||
)
|
||||
result.target_row_counts["aggregate"] = actual
|
||||
|
||||
# 8) VACUUM. Reclaims fragmentation from the executemany INSERT
|
||||
# pattern; halves on-disk footprint when run after a bulk load.
|
||||
# Outside any transaction.
|
||||
for t in targets:
|
||||
t.execute("VACUUM")
|
||||
return result
|
||||
finally:
|
||||
for t in targets:
|
||||
t.close()
|
||||
|
||||
|
||||
def _table_exists(conn: sqlite3.Connection, name: str) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (name,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
|
|
|||
|
|
@ -1535,9 +1535,15 @@ def _canonical_json(obj) -> str:
|
|||
|
||||
|
||||
def get_meta(conn: sqlite3.Connection, key: str) -> str | None:
|
||||
"""Read a value from the per-DB meta table; None if missing."""
|
||||
"""Read a value from the per-DB meta table; None if missing.
|
||||
|
||||
Indexes by position so it works regardless of ``row_factory`` —
|
||||
callers that pass a plain ``sqlite3.connect()`` connection
|
||||
shouldn't have to remember to set ``row_factory = sqlite3.Row``
|
||||
just to read one meta value.
|
||||
"""
|
||||
row = conn.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone()
|
||||
return row["value"] if row else None
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def set_meta(conn: sqlite3.Connection, key: str, value: str) -> None:
|
||||
|
|
|
|||
343
tests/test_migrate_executor.py
Normal file
343
tests/test_migrate_executor.py
Normal file
|
|
@ -0,0 +1,343 @@
|
|||
"""Integration tests for the strategy A 'all_at_once' executor (#000065).
|
||||
|
||||
Builds a tiny 2-shard test corpus, extracts audit events, plans, and
|
||||
executes a reshard into a 4-shard target layout. Verifies:
|
||||
|
||||
* every document survives
|
||||
* every document lands in the shard its document_root hashes to
|
||||
* the FTS5 indexes serve search queries on the target
|
||||
* the consolidated audit chain re-verifies on the target
|
||||
* the reshard's own audit event appears at the tail of that chain
|
||||
* every target shard has corpus_shard_count meta = M
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import pytest
|
||||
|
||||
from arborist.document import Document, shard_for_document
|
||||
from arborist.ingest import ingest_source
|
||||
from arborist.migrate import (
|
||||
HydrationPlanner,
|
||||
execute_plan,
|
||||
_table_exists,
|
||||
)
|
||||
from arborist.source import Source
|
||||
from arborist.store import (
|
||||
connect,
|
||||
get_corpus_shard_count,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeSource(Source):
|
||||
source_type = "html"
|
||||
|
||||
def __init__(self, docs: list[Document]):
|
||||
self.docs = docs
|
||||
|
||||
def iter_documents(self) -> Iterator[Document]:
|
||||
yield from self.docs
|
||||
|
||||
|
||||
def _make_doc(uri: str, seed: str) -> Document:
|
||||
# Long enough to chunk into 2+ chunks. ``seed`` varies content so
|
||||
# document_root is distinct per doc (the Merkle root keys on
|
||||
# CONTENT, not URI — identical content dedups in the cache).
|
||||
content = (
|
||||
f"This is the document at {uri} with content seed {seed}. " * 10
|
||||
+ f"Body text {seed} with enough tokens for proper chunking. " * 30
|
||||
)
|
||||
return Document(
|
||||
uri=uri, content=content, source_type="html", title=uri.split("/")[-1]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def source_shards(tmp_path: Path) -> Path:
|
||||
"""Two source shards, ~15 docs each."""
|
||||
src = tmp_path / "src"
|
||||
src.mkdir()
|
||||
docs_per_shard = 15
|
||||
for shard_idx in range(2):
|
||||
db = src / f"{shard_idx:03d}.db"
|
||||
conn = connect(db)
|
||||
try:
|
||||
docs = [
|
||||
_make_doc(
|
||||
f"https://example.com/shard{shard_idx}/doc-{i}",
|
||||
seed=f"s{shard_idx}d{i}",
|
||||
)
|
||||
for i in range(docs_per_shard)
|
||||
]
|
||||
ingest_source(conn, _FakeSource(docs))
|
||||
finally:
|
||||
conn.close()
|
||||
return src
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def audit_ndjson(source_shards: Path, tmp_path: Path) -> Path:
|
||||
"""Extract the source-shards' audit chain to ndjson (what
|
||||
bench/extract_audit_events.py would produce on the real host)."""
|
||||
out = tmp_path / "audit-events.ndjson"
|
||||
with out.open("w") as f:
|
||||
for shard in sorted(source_shards.glob("00[0-9].db")):
|
||||
idx = int(shard.stem)
|
||||
c = sqlite3.connect(f"file:{shard}?mode=ro", uri=True)
|
||||
try:
|
||||
cur = c.execute(
|
||||
"SELECT seq, event_hash, prev_event_hash, event_type, "
|
||||
"subject_root, body, ts FROM audit_events ORDER BY seq ASC"
|
||||
)
|
||||
for seq, eh, peh, etype, subj, body, ts in cur:
|
||||
f.write(json.dumps({
|
||||
"src_shard": idx,
|
||||
"src_seq": seq,
|
||||
"src_event_hash": eh,
|
||||
"prev_event_hash": peh,
|
||||
"event_type": etype,
|
||||
"subject_root": subj,
|
||||
"body": body,
|
||||
"ts": ts,
|
||||
}, separators=(",", ":")))
|
||||
f.write("\n")
|
||||
finally:
|
||||
c.close()
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMigrationAllAtOnce:
|
||||
@pytest.fixture
|
||||
def migrated(self, source_shards: Path, audit_ndjson: Path, tmp_path: Path):
|
||||
target = tmp_path / "tgt"
|
||||
plan = HydrationPlanner().plan(
|
||||
source_dir=source_shards,
|
||||
target_dir=target,
|
||||
target_M=4,
|
||||
free_bytes=200 * 1024 ** 3, # plenty
|
||||
)
|
||||
assert plan.strategy == "all_at_once"
|
||||
result = execute_plan(
|
||||
plan, audit_events_ndjson=audit_ndjson,
|
||||
)
|
||||
return target, plan, result
|
||||
|
||||
def test_target_dir_has_M_shards(self, migrated):
|
||||
target, plan, _ = migrated
|
||||
assert sorted(p.name for p in target.glob("*.db")) == [
|
||||
"000.db", "001.db", "002.db", "003.db"
|
||||
]
|
||||
|
||||
def test_every_source_doc_lands_in_correct_shard(
|
||||
self, source_shards: Path, migrated
|
||||
):
|
||||
target, plan, _ = migrated
|
||||
# Build the expected mapping from source shards.
|
||||
expected: dict[str, int] = {}
|
||||
for shard in sorted(source_shards.glob("*.db")):
|
||||
c = sqlite3.connect(f"file:{shard}?mode=ro", uri=True)
|
||||
try:
|
||||
cur = c.execute("SELECT document_root FROM documents")
|
||||
for (root,) in cur:
|
||||
expected[root] = shard_for_document(root, plan.target_M)
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
# Verify each target shard holds exactly the docs hashed to it.
|
||||
for tidx in range(plan.target_M):
|
||||
tdb = target / f"{tidx:03d}.db"
|
||||
c = sqlite3.connect(f"file:{tdb}?mode=ro", uri=True)
|
||||
try:
|
||||
actual = {
|
||||
r[0] for r in c.execute("SELECT document_root FROM documents")
|
||||
}
|
||||
finally:
|
||||
c.close()
|
||||
for root, expect_idx in expected.items():
|
||||
if expect_idx == tidx:
|
||||
assert root in actual, (
|
||||
f"doc {root[:8]}... expected on shard {tidx} but missing"
|
||||
)
|
||||
else:
|
||||
assert root not in actual, (
|
||||
f"doc {root[:8]}... on shard {tidx} but hashes to {expect_idx}"
|
||||
)
|
||||
|
||||
def test_every_chunk_followed_its_document(self, migrated):
|
||||
target, plan, _ = migrated
|
||||
# For each target shard, every chunk's document_root must route here.
|
||||
for tidx in range(plan.target_M):
|
||||
tdb = target / f"{tidx:03d}.db"
|
||||
c = sqlite3.connect(f"file:{tdb}?mode=ro", uri=True)
|
||||
try:
|
||||
cur = c.execute(
|
||||
"SELECT DISTINCT document_root FROM chunks"
|
||||
)
|
||||
for (root,) in cur:
|
||||
assert shard_for_document(root, plan.target_M) == tidx, (
|
||||
f"chunk doc {root[:8]}... on shard {tidx} but routes "
|
||||
f"to {shard_for_document(root, plan.target_M)}"
|
||||
)
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
def test_audit_chain_valid_on_target(self, migrated):
|
||||
target, plan, result = migrated
|
||||
tdb = target / "000.db"
|
||||
c = sqlite3.connect(f"file:{tdb}?mode=ro", uri=True)
|
||||
try:
|
||||
cur = c.execute(
|
||||
"SELECT seq, event_hash, prev_event_hash, body "
|
||||
"FROM audit_events ORDER BY seq ASC"
|
||||
)
|
||||
prev = None
|
||||
for seq, event_hash, peh, body in cur:
|
||||
assert peh == prev, f"chain break at seq {seq}"
|
||||
h = hashlib.sha256()
|
||||
if prev is not None:
|
||||
h.update(bytes.fromhex(prev))
|
||||
h.update(body.encode("utf-8", errors="surrogatepass"))
|
||||
assert h.hexdigest() == event_hash, (
|
||||
f"hash mismatch at seq {seq}"
|
||||
)
|
||||
prev = event_hash
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
def test_reshard_event_at_tail(self, migrated):
|
||||
target, plan, result = migrated
|
||||
tdb = target / "000.db"
|
||||
c = sqlite3.connect(f"file:{tdb}?mode=ro", uri=True)
|
||||
try:
|
||||
row = c.execute(
|
||||
"SELECT event_type, body FROM audit_events "
|
||||
"ORDER BY seq DESC LIMIT 1"
|
||||
).fetchone()
|
||||
finally:
|
||||
c.close()
|
||||
assert row[0] == "reshard"
|
||||
body = json.loads(row[1])
|
||||
assert body["kind"] == "reshard"
|
||||
assert body["plan"]["strategy"] == "all_at_once"
|
||||
assert body["plan"]["target_M"] == 4
|
||||
assert body["result"]["rows_moved_total"] > 0
|
||||
|
||||
def test_corpus_shard_count_set_on_every_target(self, migrated):
|
||||
target, plan, _ = migrated
|
||||
for tidx in range(plan.target_M):
|
||||
tdb = target / f"{tidx:03d}.db"
|
||||
c = sqlite3.connect(f"file:{tdb}?mode=ro", uri=True)
|
||||
try:
|
||||
assert get_corpus_shard_count(c) == plan.target_M, (
|
||||
f"shard {tidx} missing corpus_shard_count meta"
|
||||
)
|
||||
finally:
|
||||
c.close()
|
||||
|
||||
def test_chunks_fts_searchable_on_target(self, migrated):
|
||||
target, plan, _ = migrated
|
||||
# Verify FTS5 index serves a hit on every target shard that has
|
||||
# documents — the test content uses the word "document" liberally.
|
||||
hit_count = 0
|
||||
for tidx in range(plan.target_M):
|
||||
tdb = target / f"{tidx:03d}.db"
|
||||
c = sqlite3.connect(f"file:{tdb}?mode=ro", uri=True)
|
||||
try:
|
||||
row = c.execute(
|
||||
"SELECT COUNT(*) FROM chunks_fts WHERE chunks_fts MATCH ?",
|
||||
("document",),
|
||||
).fetchone()
|
||||
hit_count += int(row[0])
|
||||
finally:
|
||||
c.close()
|
||||
assert hit_count > 0, "no chunks_fts hits across any target shard"
|
||||
|
||||
def test_total_doc_count_preserved(
|
||||
self, source_shards: Path, migrated
|
||||
):
|
||||
target, _, _ = migrated
|
||||
# 2 source × 15 docs = 30 unique docs.
|
||||
src_total = 0
|
||||
for shard in source_shards.glob("*.db"):
|
||||
c = sqlite3.connect(f"file:{shard}?mode=ro", uri=True)
|
||||
try:
|
||||
src_total += c.execute(
|
||||
"SELECT COUNT(*) FROM documents"
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
c.close()
|
||||
tgt_total = 0
|
||||
for shard in target.glob("*.db"):
|
||||
c = sqlite3.connect(f"file:{shard}?mode=ro", uri=True)
|
||||
try:
|
||||
tgt_total += c.execute(
|
||||
"SELECT COUNT(*) FROM documents"
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
c.close()
|
||||
assert tgt_total == src_total, (
|
||||
f"doc count drift: src={src_total} tgt={tgt_total}"
|
||||
)
|
||||
|
||||
def test_dry_run_creates_no_targets(
|
||||
self, source_shards: Path, audit_ndjson: Path, tmp_path: Path
|
||||
):
|
||||
target = tmp_path / "tgt-dry"
|
||||
plan = HydrationPlanner().plan(
|
||||
source_dir=source_shards,
|
||||
target_dir=target,
|
||||
target_M=4,
|
||||
free_bytes=200 * 1024 ** 3,
|
||||
)
|
||||
result = execute_plan(
|
||||
plan, audit_events_ndjson=audit_ndjson, dry_run=True
|
||||
)
|
||||
assert "DRY RUN" in " ".join(result.notes)
|
||||
# target dir exists but no DBs.
|
||||
assert list(target.glob("*.db")) == []
|
||||
|
||||
|
||||
class TestExecutorErrorCases:
|
||||
def test_missing_ndjson_raises(
|
||||
self, source_shards: Path, tmp_path: Path
|
||||
):
|
||||
target = tmp_path / "tgt"
|
||||
plan = HydrationPlanner().plan(
|
||||
source_dir=source_shards,
|
||||
target_dir=target,
|
||||
target_M=4,
|
||||
free_bytes=200 * 1024 ** 3,
|
||||
)
|
||||
with pytest.raises(FileNotFoundError, match="audit-event ndjson"):
|
||||
execute_plan(
|
||||
plan, audit_events_ndjson=tmp_path / "does-not-exist.ndjson"
|
||||
)
|
||||
|
||||
def test_per_source_shard_not_implemented_yet(
|
||||
self, source_shards: Path, audit_ndjson: Path, tmp_path: Path
|
||||
):
|
||||
target = tmp_path / "tgt"
|
||||
plan = HydrationPlanner().plan(
|
||||
source_dir=source_shards,
|
||||
target_dir=target,
|
||||
target_M=4,
|
||||
free_bytes=200 * 1024 ** 3,
|
||||
force_strategy="per_source_shard",
|
||||
)
|
||||
with pytest.raises(NotImplementedError, match="per_source_shard"):
|
||||
execute_plan(plan, audit_events_ndjson=audit_ndjson)
|
||||
Loading…
Add table
Add a link
Reference in a new issue