diff --git a/hermes_state.py b/hermes_state.py index 5285782e4f5..5a06c175760 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -818,6 +818,57 @@ def _log_wal_fallback_once(db_label: str, exc: Exception) -> None: exc, ) + +# --------------------------------------------------------------------------- +# Config-driven database pragmas +# --------------------------------------------------------------------------- +def apply_database_pragmas( + conn: sqlite3.Connection, + *, + db_label: str = "state.db", +) -> None: + """Apply optional WAL-sizing PRAGMAs from ``config.yaml``. + + Reads the ``database:`` section and applies ``wal_autocheckpoint`` + and ``journal_size_limit`` when set to integer values. The journal + mode itself is NOT handled here — ``database.journal_mode`` is owned + by :func:`resolve_journal_mode` inside :func:`apply_wal_with_fallback`, + which layers the operator setting under all the safety guards + (never live-downgrading an on-disk WAL DB, filesystem fallback, + WAL-reset-bug gating). Keeping a single owner prevents a second, + unguarded journal-mode switch path. + + Best-effort: config load or pragma failures are ignored so DB init + never breaks on a malformed ``database:`` section. + """ + try: + # Local import avoids a circular import with hermes_cli.config. + from hermes_cli.config import cfg_get, load_config_readonly + + cfg = load_config_readonly() + except Exception: + return + + for pragma_name in ("wal_autocheckpoint", "journal_size_limit"): + raw_value = cfg_get(cfg, "database", pragma_name, default=None) + if raw_value is None: + continue + try: + value = int(str(raw_value).strip()) + except (TypeError, ValueError): + logger.warning( + "%s: ignoring non-integer database.%s=%r", + db_label, + pragma_name, + raw_value, + ) + continue + try: + conn.execute(f"PRAGMA {pragma_name}={value}") + except sqlite3.OperationalError: + pass + + # --------------------------------------------------------------------------- # Malformed-schema recovery # --------------------------------------------------------------------------- @@ -1837,6 +1888,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) self._wal_active = ( apply_wal_with_fallback(self._conn, db_label="state.db") == "wal" ) + apply_database_pragmas(self._conn, db_label="state.db") self._conn.execute("PRAGMA foreign_keys=ON") self._fts_cjk_loaded = load_fts5_cjk_extension(self._conn) self._init_schema() diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index b83a29fedf4..3b9c9c8e563 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -2856,3 +2856,88 @@ class TestGatewayRoutingPkHeal: cur = db._conn.cursor() db._heal_gateway_routing_pk(cur) assert db.load_gateway_routing_entries(scope="s") == {"k1": "{}"} + + +class TestApplyDatabasePragmas: + """Config-driven WAL-sizing pragma application (database: section).""" + + @staticmethod + def _patch_cfg(monkeypatch, cfg): + monkeypatch.setattr( + "hermes_cli.config.load_config_readonly", + lambda: cfg, + ) + + def test_honors_wal_autocheckpoint_from_config(self, tmp_path, monkeypatch): + import sqlite3 + from hermes_state import apply_database_pragmas + + conn = sqlite3.connect(str(tmp_path / "pragmas.db")) + try: + conn.execute("PRAGMA journal_mode=WAL") + self._patch_cfg(monkeypatch, {"database": {"wal_autocheckpoint": 250}}) + apply_database_pragmas(conn, db_label="test.db") + assert conn.execute("PRAGMA wal_autocheckpoint").fetchone()[0] == 250 + finally: + conn.close() + + def test_honors_journal_size_limit_from_config(self, tmp_path, monkeypatch): + import sqlite3 + from hermes_state import apply_database_pragmas + + conn = sqlite3.connect(str(tmp_path / "pragmas.db")) + try: + conn.execute("PRAGMA journal_mode=WAL") + self._patch_cfg( + monkeypatch, {"database": {"journal_size_limit": 10485760}} + ) + apply_database_pragmas(conn, db_label="test.db") + assert ( + conn.execute("PRAGMA journal_size_limit").fetchone()[0] == 10485760 + ) + finally: + conn.close() + + def test_noop_when_database_section_missing(self, tmp_path, monkeypatch): + import sqlite3 + from hermes_state import apply_database_pragmas + + conn = sqlite3.connect(str(tmp_path / "pragmas.db")) + try: + conn.execute("PRAGMA journal_mode=DELETE") + self._patch_cfg(monkeypatch, {}) + apply_database_pragmas(conn, db_label="test.db") + assert conn.execute("PRAGMA journal_mode").fetchone()[0] == "delete" + finally: + conn.close() + + def test_never_touches_journal_mode(self, tmp_path, monkeypatch): + """journal_mode is owned by apply_wal_with_fallback — a database: + journal_mode entry must NOT cause a second, unguarded mode switch.""" + import sqlite3 + from hermes_state import apply_database_pragmas + + conn = sqlite3.connect(str(tmp_path / "pragmas.db")) + try: + conn.execute("PRAGMA journal_mode=WAL") + self._patch_cfg(monkeypatch, {"database": {"journal_mode": "delete"}}) + apply_database_pragmas(conn, db_label="test.db") + assert conn.execute("PRAGMA journal_mode").fetchone()[0] == "wal" + finally: + conn.close() + + def test_ignores_non_integer_values(self, tmp_path, monkeypatch): + import sqlite3 + from hermes_state import apply_database_pragmas + + conn = sqlite3.connect(str(tmp_path / "pragmas.db")) + try: + conn.execute("PRAGMA journal_mode=WAL") + before = conn.execute("PRAGMA wal_autocheckpoint").fetchone()[0] + self._patch_cfg( + monkeypatch, {"database": {"wal_autocheckpoint": "lots"}} + ) + apply_database_pragmas(conn, db_label="test.db") + assert conn.execute("PRAGMA wal_autocheckpoint").fetchone()[0] == before + finally: + conn.close()