arborist/tests/test_store_migration_memoization.py
russell@unturf.com 71c98487b7
store: idempotent forward migrations — survive concurrent connect()
`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.
2026-05-11 07:28:35 -04:00

199 lines
6.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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