diff --git a/hermes_state_schema.py b/hermes_state_schema.py index f9b78e05cd..877636d87a 100644 --- a/hermes_state_schema.py +++ b/hermes_state_schema.py @@ -282,6 +282,41 @@ class SessionSchemaMixin: high_water = cursor.execute("SELECT COALESCE(MAX(id), 0) FROM messages").fetchone()[0] cursor.execute(_STATE_META_UPSERT_SQL, (FTS_TOOL_FULL_CONTENT_HIGH_WATER_KEY, str(high_water))) + @staticmethod + def _execute_ddl_skipping_settled_triggers(cursor: sqlite3.Cursor, ddl: str) -> None: + """Run *ddl* statement by statement, skipping each ``DROP TRIGGER IF EXISTS x`` / + ``CREATE TRIGGER IF NOT EXISTS x …`` pair whose trigger already exists with the same body. + + ``DROP TRIGGER IF EXISTS`` on an existing trigger takes SQLite's write lock (the + ``CREATE … IF NOT EXISTS`` forms do not), so the unconditional drop+recreate that lets a + changed trigger body roll out would otherwise block every open of a settled database + behind a sibling process's transaction. ``sqlite_master.sql`` stores the CREATE text + verbatim minus ``IF NOT EXISTS`` and the ``;``, so an exact comparison decides. + """ + stored = dict(cursor.execute("SELECT name, sql FROM sqlite_master WHERE type = 'trigger'").fetchall()) + pending_drop: Optional[str] = None + statement = "" + for line in ddl.splitlines(): + statement += line + "\n" + if not sqlite3.complete_statement(statement): + continue + statement, current = "", statement.strip() + upper = current.upper() + if upper.startswith("DROP TRIGGER IF EXISTS "): + pending_drop = current + continue + if upper.startswith("CREATE TRIGGER IF NOT EXISTS "): + desired = current.replace("IF NOT EXISTS ", "", 1).rstrip(";") + if stored.get(desired.split(None, 3)[2]) == desired: + pending_drop = None + continue + if pending_drop is not None: + cursor.execute(pending_drop) + pending_drop = None + cursor.execute(current) + if statement.strip(): + raise sqlite3.OperationalError("incomplete DDL statement") + @staticmethod def _execute_ddl_script_transactional(cursor: sqlite3.Cursor, ddl: str) -> None: """Execute a DDL script without ``executescript``'s implicit commit.""" @@ -884,7 +919,7 @@ class SessionSchemaMixin: ) except sqlite3.OperationalError as exc: logger.debug("idx_messages_platform_msg_id create skipped: %s", exc) - cursor.executescript(DEFERRED_INDEX_SQL) # same ordering constraint (``active``) + self._execute_ddl_skipping_settled_triggers(cursor, DEFERRED_INDEX_SQL) # same ordering constraint (``active``) # Heal NULL ``active`` rows on every startup: older reconciler builds added ``active`` # without NOT NULL DEFAULT 1, so ``WHERE active = 1`` loaders hid whole histories. A diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 4c9ff406a9..803a9c37b3 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -640,7 +640,7 @@ class TestMessageStorage: conn = real_connect(*args, **kwargs) conn.set_trace_callback( lambda stmt: writes.append(stmt) - if re.match(r"\s*(INSERT|UPDATE|DELETE|REPLACE)\b", stmt, re.I) and "temp." not in stmt + if re.match(r"\s*(INSERT|UPDATE|DELETE|REPLACE|ALTER|DROP\s+TRIGGER)\b", stmt, re.I) and "temp." not in stmt else None ) return conn