analyze: walk audit chains read-only — no migration DDL on a read op

`arborist analyze`'s per-shard audit-chain loop opened each shard with
connect(), which on first open of a file runs executescript(SCHEMA_SQL)
plus all forward migrations — DDL on a read-only walk, on 6 GB shards,
and (until 71c9848) the trigger for the fork_score_branches crash.

New store.connect_readonly(db_path): mode=ro URI, no schema bootstrap,
no migration probes. _cmd_analyze's chain-check loop uses it.
This commit is contained in:
russell@unturf.com 2026-05-11 07:36:39 -04:00
parent 71c98487b7
commit 1dc47e01b3
No known key found for this signature in database
2 changed files with 25 additions and 1 deletions

View file

@ -18,6 +18,7 @@ from arborist.store import (
append_audit,
connect,
connect_query,
connect_readonly,
stats,
transaction,
)
@ -2934,7 +2935,9 @@ def _cmd_analyze(args: argparse.Namespace) -> int:
total_events = 0
total_breaks = 0
for sp in discover_shards(args.global_shards_dir):
sc = connect(sp)
# Read-only: an audit-chain walk must not run migration DDL
# or take a write lock on the shard (see connect_readonly).
sc = connect_readonly(sp)
try:
ev, br = _check_audit_chain(sc)
finally:

View file

@ -1475,6 +1475,27 @@ def connect_query(
return conn
def connect_readonly(db_path: Path | str) -> sqlite3.Connection:
"""Open an existing DB read-only — ``mode=ro`` URI, no schema
bootstrap, no migration probes.
For CLI paths that only walk data (audit-chain checks, per-shard
counts): a read op must neither run DDL nor take a write lock, but
``connect()`` does both — it runs ``executescript(SCHEMA_SQL)`` plus
the forward migrations on the first open of each file in a process.
(That migration pass on a read-only walk is exactly what surfaced
the ``fork_score_branches already exists`` crash.) Raises
``sqlite3.OperationalError`` if the file is missing or unreadable;
callers iterating a shard set should catch and skip if they expect
stragglers.
"""
conn = sqlite3.connect(
f"file:{Path(db_path)}?mode=ro", uri=True, isolation_level=None
)
conn.row_factory = sqlite3.Row
return conn
@contextmanager
def transaction(conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
"""BEGIN IMMEDIATE / COMMIT / ROLLBACK around a block."""