From 1dc47e01b34b2185aa9daccc707d63fd97d964b7 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 11 May 2026 07:36:39 -0400 Subject: [PATCH] =?UTF-8?q?analyze:=20walk=20audit=20chains=20read-only=20?= =?UTF-8?q?=E2=80=94=20no=20migration=20DDL=20on=20a=20read=20op?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- arborist/cli.py | 5 ++++- arborist/store.py | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/arborist/cli.py b/arborist/cli.py index f05ceaa..534eaa3 100644 --- a/arborist/cli.py +++ b/arborist/cli.py @@ -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: diff --git a/arborist/store.py b/arborist/store.py index a2ffe19..62b6af8 100644 --- a/arborist/store.py +++ b/arborist/store.py @@ -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."""