Files
hermes-agent/tests/test_state_db_write_durability.py
T
Teknium d15c61b5dc refactor(state): split SessionDB into domain mixins and free-function modules; unify SQL boilerplate
hermes_state.py 17,220 -> 6,442 LOC. Behavior-neutral: every moved body is
AST-identical to the original, verified per extraction.

SessionDB core
- _write_sql / _write_rowcount / _read_one / _read_all replace ~120 copies of
  the `def _do(conn): conn.execute(...)` + `_execute_write(_do)` and
  `with self._read_ctx() as conn: row = conn.execute(...).fetchone()` shapes.
- _set_lineage_column replaces four copies of the recursive compression-lineage
  UPDATE (archived / pinned / hidden / last_read_at).
- _read_session_number unifies the three compression counter readers.
- Dead (zero refs repo-wide): restore_rewound, delete_gateway_routing_entries,
  _is_duplicate_replayed_user_message, SessionPortabilityMixin.get_first_assistant_text.

New mixins bound onto SessionDB via the MRO (logger name stays "hermes_state"):
  hermes_state_messages    SessionMessagesMixin       48 methods
  hermes_state_compression SessionCompressionMixin    30
  hermes_state_gateway     SessionGatewayMixin        26
  hermes_state_maintenance SessionMaintenanceMixin    13
  hermes_state_usage       SessionUsageMixin          12
  hermes_state_titles      SessionTitlesMixin         13
  hermes_state_telegram    SessionTelegramTopicsMixin 11
Origin-internal symbols resolve through a lazy `from hermes_state import ...`
inside the few methods that need them (no import cycle).

New free-function modules, every name re-imported into hermes_state so
`hermes_state.<name>` (and test monkeypatches on it) keep working; intra-module
calls to patched helpers go through the lazy origin import:
  hermes_state_repair   repair/backup/preflight (43 defs)
  hermes_state_wal      journal-mode / PRAGMA policy (33 defs)
  hermes_state_dbfile   header probes, zeroed-db quarantine, stats, holders (21 defs)

Existing mixins: search — shared FTS MATCH/LIKE builders, unified rebuild
status/step/finish engines, state_meta helpers; schema — one legacy/v23 FTS init
branch, shared _live_pk_columns, Row/tuple dual access dropped; portability —
shared _PREVIEW_RAW_SUBQUERY_SQL and _rich_row; common — single
stat_db_file_identity (was 3 copies), AUTO_VACUUM_MIN_FREELIST_RATIO.

Docstrings/comments hand-compacted (AST-identical) keeping every invariant,
ordering rule, failure mode and WHY. Schema SQL, migration order and PRAGMAs
untouched. test_repair_path_has_no_bare_connects repointed to hermes_state_repair.
2026-09-02 13:32:13 -07:00

174 lines
6.7 KiB
Python

"""Regression: state.db repair-path writes must be durable on macOS.
Incident (2026-08-19, recurrence of 2026-08-18/19): `state.db` was recovered
clean at 01:02, tore again in the pages holding rows written 02:18-02:22, and
the damage went undetected until 13:36 when a write finally landed on a
damaged page (`append_message failed: constraint failed`). `PRAGMA
integrity_check` on the file reported the torn-b-tree signature:
Tree 5 page 47256 cell 423..429: 2nd reference to page ...
Tree 5 page 60788 cell 4: Rowid 34637 out of order
Page 50549..52587: never used
The defect: hermes_state already knows macOS `fsync()` does not guarantee
write ordering, and mitigates it with `synchronous=FULL` +
`checkpoint_fullfsync=1` (see `_enforce_macos_synchronous_full`, whose
docstring names this exact failure: "a WAL checkpoint race with process
termination ... can leave the main DB with half-written btree pages").
Those pragmas are per-connection and were applied only via
`apply_wal_with_fallback()`. The repair path opened `state.db` with a bare
`sqlite3.connect()` five times and then ran REINDEX, VACUUM and
`writable_schema` surgery through it — the operations that rewrite nearly
every page of the file — with no barrier at all.
(The proactive `verify_state_db_integrity()` gate the original PR #90747 also
carried is deferred to the follow-up that wires it into gateway startup —
PR #91754 — since it ships as dead code without that caller. This file covers
only the repair-connection durability half.)
"""
from __future__ import annotations
import ast
import sqlite3
import sys
from pathlib import Path
import hermes_state
from hermes_state import (
_connect_repair_durable,
repair_state_db_schema,
)
def _make_db(tmp_path: Path) -> Path:
db = tmp_path / "state.db"
conn = sqlite3.connect(str(db))
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("CREATE TABLE sessions (session_id TEXT PRIMARY KEY)")
conn.execute("CREATE TABLE messages (id INTEGER PRIMARY KEY, body TEXT)")
conn.execute("INSERT INTO messages (body) VALUES ('seed')")
conn.commit()
conn.close()
return db
# ── Repair-path write durability ────────────────────────────────────────
def test_connect_repair_durable_sets_macos_barriers(tmp_path: Path) -> None:
"""The repair connection must carry both macOS durability barriers."""
db = _make_db(tmp_path)
conn = _connect_repair_durable(db)
try:
synchronous = conn.execute("PRAGMA synchronous").fetchone()[0]
checkpoint_fullfsync = conn.execute(
"PRAGMA checkpoint_fullfsync"
).fetchone()[0]
finally:
conn.close()
if sys.platform == "darwin":
# SQLite: 0=OFF, 1=NORMAL, 2=FULL, 3=EXTRA. NORMAL is what tore the
# b-tree pages; FULL is what _enforce_macos_synchronous_full sets.
assert synchronous == 2, (
f"repair connection opened with synchronous={synchronous}; on "
"Darwin this lets REINDEX/VACUUM leave half-written b-tree pages"
)
assert checkpoint_fullfsync == 1, (
"repair connection has no F_FULLFSYNC barrier at checkpoint "
"boundaries; macOS fsync() does not flush the drive cache"
)
else:
# Elsewhere the helper is a plain connect — no behaviour change.
assert synchronous in (0, 1, 2, 3)
def test_connect_repair_durable_is_autocommit(tmp_path: Path) -> None:
"""Must preserve isolation_level=None — repair runs DDL and VACUUM."""
db = _make_db(tmp_path)
conn = _connect_repair_durable(db)
try:
assert conn.isolation_level is None
# VACUUM is only legal outside an implicit transaction.
conn.execute("VACUUM")
finally:
conn.close()
def test_repair_path_has_no_bare_connects() -> None:
"""No repair/probe site may bypass the durability helper.
Source-level guard: the bare form is exactly what regressed, and a unit
test on the helper alone would not notice a sixth site being added.
"""
# The repair/probe helpers live in hermes_state_repair; hermes_state only
# re-imports them.
import hermes_state_repair
source = Path(hermes_state_repair.__file__).read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(hermes_state_repair.__file__))
def is_db_path_connect(node: ast.AST) -> bool:
if not isinstance(node, ast.Call):
return False
callee = node.func
if not (
isinstance(callee, ast.Attribute)
and isinstance(callee.value, ast.Name)
and callee.value.id == "sqlite3"
and callee.attr == "connect"
):
return False
if not node.args:
return False
first = node.args[0]
return (
isinstance(first, ast.Call)
and isinstance(first.func, ast.Name)
and first.func.id == "str"
and len(first.args) == 1
and isinstance(first.args[0], ast.Name)
and first.args[0].id == "db_path"
)
helper = next(
node
for node in tree.body
if isinstance(node, ast.FunctionDef)
and node.name == "_connect_repair_durable"
)
helper_calls = [node for node in ast.walk(helper) if is_db_path_connect(node)]
assert len(helper_calls) == 1, (
"_connect_repair_durable must own exactly one sqlite3.connect(str(db_path), ...)"
)
all_calls = [node for node in ast.walk(tree) if is_db_path_connect(node)]
elsewhere = [node for node in all_calls if node not in helper_calls]
assert elsewhere == [], (
f"{len(elsewhere)} repair/probe connection(s) still bypass "
"_connect_repair_durable() and write state.db without the macOS "
"fsync barriers"
)
def test_repair_still_works_through_durable_connection(tmp_path: Path) -> None:
"""Routing every strategy through the helper must not break the path.
The helper is entered once per strategy, so a plumbing fault (recursion,
a leaked connection, a refused pragma) surfaces as an exception rather
than a report. Whether this fixture's minimal schema is *repairable* is
beside the point — the assertion is that the path runs to completion.
"""
db = _make_db(tmp_path)
report = repair_state_db_schema(db, backup=False)
assert isinstance(report, dict)
assert set(report) >= {"repaired", "strategy", "backup_path"}
# The file must still open afterwards — repair may fail, but it must not
# leave the database less usable than it found it.
conn = sqlite3.connect(str(db))
try:
assert conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0] == 1
finally:
conn.close()