`arborist analyze` crashed during orientation with `sqlite3.OperationalError: table fork_score_branches already exists`: check-sqlite_master-then-CREATE forward migrations let two connect() calls racing a fresh shard both pass the probe and both issue CREATE. - CREATE TABLE/INDEX inside the _migrate_* helpers now IF NOT EXISTS (probe stays as the fast-path skip). - _migrate_audit_mode's ALTER ADD COLUMN routes through _add_column_if_missing (PRAGMA fast-path + duplicate-column catch). - connect() raises busy_timeout=5000 for the one migration pass so a racing connect() waits on a peer's _rebuild_* write txn. - test_store_migration_concurrency.py: source-level "all CREATE is IF NOT EXISTS" pin, busy_timeout assertion, 8-thread connect smoke. - test_store_migration_memoization.py: _MigrationProbeCounter.NAMES was missing two migrations; synced + de-hardcoded counts.
199 lines
6.4 KiB
Python
199 lines
6.4 KiB
Python
"""Regression tests for ticket #000026 Phase 1.
|
||
|
||
Per-process migration memoization. ``arborist.store.connect()`` used
|
||
to run every forward-migration probe on every open — hundreds of
|
||
redundant probe sequences per typical query. With memoization, the
|
||
migration block runs once per (physical file, process) and is skipped
|
||
on every subsequent open of the same shard.
|
||
|
||
Tests pin:
|
||
|
||
- First connect runs migrations.
|
||
- Second connect on the same path skips them.
|
||
- Functional integrity is preserved (all schema is in place after
|
||
the second connect).
|
||
- The cache invalidates when a file is replaced at the same path
|
||
(different inode → migrations re-run).
|
||
- ``_clear_migration_cache()`` resets the memo for tests / explicit
|
||
re-probe.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
from arborist import store
|
||
from arborist.store import (
|
||
_clear_migration_cache,
|
||
connect,
|
||
invalidate_migration_cache,
|
||
)
|
||
|
||
|
||
class _MigrationProbeCounter:
|
||
"""Wraps each migration helper and counts invocations.
|
||
|
||
Patches the module-level names so ``connect()`` (which references
|
||
them via the ``arborist.store`` module globals) calls the
|
||
counted versions.
|
||
"""
|
||
|
||
# Mirrors the forward-migration sequence in ``store.connect()`` — keep
|
||
# in sync when a migration is added (otherwise its memoization goes
|
||
# unverified, and the count assertions below silently under-count).
|
||
NAMES = (
|
||
"_migrate_audit_mode",
|
||
"_migrate_mesh_peer_chains",
|
||
"_migrate_document_http_meta",
|
||
"_migrate_selfmodel_tables",
|
||
"_migrate_capital_ledger",
|
||
"_migrate_memory_root",
|
||
"_migrate_adapter_loss_reports",
|
||
"_migrate_controller_events",
|
||
"_migrate_fork_score_branches",
|
||
)
|
||
|
||
def __init__(self, monkeypatch):
|
||
self.counts: dict[str, int] = {n: 0 for n in self.NAMES}
|
||
for name in self.NAMES:
|
||
original = getattr(store, name)
|
||
|
||
def _wrapped(conn, *, _orig=original, _name=name):
|
||
self.counts[_name] += 1
|
||
return _orig(conn)
|
||
|
||
monkeypatch.setattr(store, name, _wrapped)
|
||
|
||
@property
|
||
def total(self) -> int:
|
||
return sum(self.counts.values())
|
||
|
||
|
||
def test_first_connect_runs_all_forward_migrations(tmp_path: Path, monkeypatch):
|
||
_clear_migration_cache()
|
||
counter = _MigrationProbeCounter(monkeypatch)
|
||
db = tmp_path / "first.db"
|
||
|
||
conn = connect(db)
|
||
conn.close()
|
||
|
||
# Every probe ran exactly once.
|
||
assert counter.total == len(counter.NAMES)
|
||
for name in counter.NAMES:
|
||
assert counter.counts[name] == 1
|
||
|
||
|
||
def test_second_connect_skips_migrations(tmp_path: Path, monkeypatch):
|
||
_clear_migration_cache()
|
||
db = tmp_path / "second.db"
|
||
|
||
# Warm the cache with a fresh open (no probe counter yet).
|
||
conn = connect(db)
|
||
conn.close()
|
||
|
||
# Now patch in counters and open again. Zero probes should run.
|
||
counter = _MigrationProbeCounter(monkeypatch)
|
||
conn = connect(db)
|
||
conn.close()
|
||
assert counter.total == 0
|
||
|
||
|
||
def test_second_connect_schema_is_intact(tmp_path: Path):
|
||
"""Belt-and-suspenders: migration skipping must not break schema
|
||
visibility. Open + close + reopen, then read every table the
|
||
migrations create."""
|
||
_clear_migration_cache()
|
||
db = tmp_path / "intact.db"
|
||
connect(db).close()
|
||
|
||
conn = connect(db)
|
||
try:
|
||
# Smoke-check tables that each migration is responsible for.
|
||
for table in (
|
||
"providence_cache", # _migrate_audit_mode
|
||
"mesh_peer_chains", # _migrate_mesh_peer_chains
|
||
"document_http_meta", # _migrate_document_http_meta
|
||
"selfmodel_records", # _migrate_selfmodel_tables
|
||
"capital_ledger", # _migrate_capital_ledger
|
||
"memory_records", # _migrate_memory_root
|
||
"adapter_loss_reports", # _migrate_adapter_loss_reports
|
||
"controller_events", # _migrate_controller_events
|
||
"fork_score_branches", # _migrate_fork_score_branches
|
||
):
|
||
row = conn.execute(
|
||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||
(table,),
|
||
).fetchone()
|
||
assert row is not None, f"missing table after re-open: {table}"
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def test_clear_migration_cache_forces_reprobe(tmp_path: Path, monkeypatch):
|
||
_clear_migration_cache()
|
||
db = tmp_path / "clear.db"
|
||
|
||
connect(db).close()
|
||
|
||
counter = _MigrationProbeCounter(monkeypatch)
|
||
|
||
# Without clearing — zero probes.
|
||
connect(db).close()
|
||
assert counter.total == 0
|
||
|
||
# After clearing — full re-probe.
|
||
_clear_migration_cache()
|
||
connect(db).close()
|
||
assert counter.total == len(counter.NAMES)
|
||
|
||
|
||
def test_invalidate_migration_cache_after_file_replacement(
|
||
tmp_path: Path, monkeypatch
|
||
):
|
||
"""Caller-driven invalidation contract: after replacing a shard
|
||
at the same path, the caller must call
|
||
``invalidate_migration_cache(path)`` so the next ``connect()``
|
||
re-runs the full probe sequence on the new file.
|
||
|
||
We don't auto-detect replacement — (dev, inode) is unreliable
|
||
under tmpfs inode reuse, and (mtime, size) drifts naturally as
|
||
SQLite operates. Path-only with explicit invalidation is the
|
||
honest contract; tests + snapshot-restore flows are responsible
|
||
for calling the invalidator."""
|
||
_clear_migration_cache()
|
||
db = tmp_path / "replaced.db"
|
||
|
||
# First open — populate cache.
|
||
connect(db).close()
|
||
|
||
# Replace the file at the same path.
|
||
db.unlink()
|
||
|
||
# Without invalidation, the cache still claims migrations are
|
||
# current — schema would be silently absent on the new file.
|
||
invalidate_migration_cache(db)
|
||
|
||
counter = _MigrationProbeCounter(monkeypatch)
|
||
conn = connect(db)
|
||
conn.close()
|
||
assert counter.total == len(counter.NAMES)
|
||
|
||
|
||
def test_distinct_paths_each_get_one_probe(tmp_path: Path, monkeypatch):
|
||
"""Two different shard files get one probe each — the cache is
|
||
per-path, not global."""
|
||
_clear_migration_cache()
|
||
db_a = tmp_path / "a.db"
|
||
db_b = tmp_path / "b.db"
|
||
|
||
counter = _MigrationProbeCounter(monkeypatch)
|
||
connect(db_a).close()
|
||
connect(db_b).close()
|
||
# Two paths × the full migration sequence.
|
||
assert counter.total == 2 * len(counter.NAMES)
|
||
|
||
# Re-open both — cache hit on each, zero new probes.
|
||
before = counter.total
|
||
connect(db_a).close()
|
||
connect(db_b).close()
|
||
assert counter.total == before
|