Files
hermes-agent/hermes_state_repair.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

1640 lines
71 KiB
Python

"""state.db repair, backup and writability preflight (split from hermes_state).
Every name is re-imported into ``hermes_state``; intra-module calls to
patchable helpers go through a lazy ``from hermes_state import ...`` at call
time so monkeypatches there still intercept.
"""
from __future__ import annotations
import contextlib
import hashlib
import json
import logging
import os
import sqlite3
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from hermes_constants import get_hermes_home
from hermes_startup_watchdog import report_startup_progress
from hermes_state_common import (
_acquire_db_flock,
_clear_lock_holder_record,
_describe_lock_holder,
_read_lock_holder_record,
is_advisory_lock_contention,
)
# Log-record parity with the origin module (caplog tests pin "hermes_state").
logger = logging.getLogger("hermes_state")
def _claim_repair_attempt(db_path: Path) -> bool:
"""Claim the one-shot per-process repair attempt for *db_path*.
True for the first caller, False afterwards: bounds the repair/reopen loop
and stops concurrent callers racing surgery on one file.
"""
from hermes_state import _repair_attempt_lock, _repair_attempted_paths
key = str(db_path)
with _repair_attempt_lock:
if key in _repair_attempted_paths:
return False
_repair_attempted_paths.add(key)
return True
_REPAIR_LOCK_POLL_SECONDS = 0.1
# Snapshot copies are data transfer, not inter-process locking: bound them
# separately at 10 MiB/s, with the historical two-minute floor.
_REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND = 10 * 1024 * 1024
@contextlib.contextmanager
def _cross_process_repair_lock(db_path: Path):
"""Serialize state.db schema surgery across processes.
Yields True when this process holds the repair lock for *db_path*, False
when the bounded acquire timed out or the lock file could not be opened.
Unlike the kanban init lock (idempotent critical section), running surgery
unlocked IS the unsafe interleaving this prevents: a caller that gets
False must NOT do surgery.
``flock`` because the kernel drops it when the holder dies (a pidfile
would wedge every future repair); a forked child that inherited the fd is
the exception, so the acquire records the holder's pid + start time and
breaks the lock when that holder is provably dead (``_acquire_db_flock``).
The acquire is bounded because a *live* repairer can sit in ``VACUUM``
for minutes, and an unbounded wait would hang the caller's open silently.
"""
from hermes_state import _IS_WINDOWS, _REPAIR_LOCK_TIMEOUT_SECONDS
lock_path = db_path.with_name(db_path.name + ".repair.lock")
try:
lock_path.parent.mkdir(parents=True, exist_ok=True)
handle = lock_path.open("a+b")
except OSError as exc:
# Fail closed, like a timed-out acquire. An unopenable lock file means
# out of space/inodes/descriptors — and a sibling that opened ITS
# handle before the disk filled may still be inside surgery; yielding
# True here once let two processes run surgery on the same live
# state.db. Callers handle False by re-probing.
logger.warning(
"Could not open state.db repair lock %s (%s) — skipping schema "
"surgery rather than running it without cross-process authority.",
lock_path, exc,
)
yield False
return
acquired = False
try:
if _IS_WINDOWS:
deadline = time.monotonic() + _REPAIR_LOCK_TIMEOUT_SECONDS
while True:
try:
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
acquired = True
break
except (BlockingIOError, OSError) as exc:
if not is_advisory_lock_contention(exc):
logger.warning(
"Could not acquire state.db repair lock %s (%s) — "
"skipping schema surgery on a non-contention error.",
lock_path, exc,
)
acquired = None
break
if time.monotonic() >= deadline:
break
time.sleep(_REPAIR_LOCK_POLL_SECONDS)
else:
acquired, handle = _acquire_db_flock(
str(lock_path),
handle,
_REPAIR_LOCK_TIMEOUT_SECONDS,
_REPAIR_LOCK_POLL_SECONDS,
"state.db repair lock",
)
if acquired is None:
# Non-contention failure already logged with its errno.
acquired = False
elif not acquired:
record = None if _IS_WINDOWS else _read_lock_holder_record(handle)
logger.warning(
"state.db repair lock %s held by another process for more "
"than %.0fs — skipping schema surgery in this process to "
"avoid racing the repairer. Recorded holder: %s.",
lock_path, _REPAIR_LOCK_TIMEOUT_SECONDS,
_describe_lock_holder(record),
)
yield acquired
finally:
try:
if acquired:
if _IS_WINDOWS:
import msvcrt
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
import fcntl
_clear_lock_holder_record(handle)
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
except OSError: # pragma: no cover - best effort release
pass
finally:
handle.close()
def _try_acquire_auto_maintenance_lock(db_path: Path) -> Optional[Any]:
"""Non-blocking cross-process lock for one auto-maintenance pass.
Advisory lock the kernel releases if the holder exits. A caller that cannot
acquire it must skip the pass: otherwise two startups both pass the interval
check and the second prunes a row the first has only just closed recoverably.
"""
from hermes_state import _IS_WINDOWS
lock_path = db_path.with_name(db_path.name + ".auto-maintenance.lock")
try:
lock_path.parent.mkdir(parents=True, exist_ok=True)
handle = lock_path.open("a+b")
except OSError as exc:
logger.warning(
"Could not open state.db auto-maintenance lock %s (%s) — skipping "
"automatic maintenance.",
lock_path,
exc,
)
return None
try:
if _IS_WINDOWS:
import msvcrt
handle.seek(0)
msvcrt.locking( # type: ignore[attr-defined]
handle.fileno(), msvcrt.LK_NBLCK, 1 # type: ignore[attr-defined]
)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except (BlockingIOError, OSError):
handle.close()
return None
return handle
def _release_auto_maintenance_lock(handle: Any) -> None:
"""Release a handle returned by :func:`_try_acquire_auto_maintenance_lock`."""
from hermes_state import _IS_WINDOWS
try:
if _IS_WINDOWS:
import msvcrt
handle.seek(0)
msvcrt.locking( # type: ignore[attr-defined]
handle.fileno(), msvcrt.LK_UNLCK, 1 # type: ignore[attr-defined]
)
else:
import fcntl
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
except OSError: # pragma: no cover - best effort release
pass
finally:
handle.close()
def _bump_schema_cookie(conn: sqlite3.Connection) -> None:
"""Increment the schema cookie after direct ``sqlite_master`` surgery.
Ordinary DDL bumps this counter and other connections compare it before
running a prepared statement — that is how they discard a cached schema.
Editing ``sqlite_master`` under ``writable_schema=ON`` does NOT bump it,
so live connections elsewhere keep compiling against the old schema (e.g.
firing triggers into ``messages_fts*`` shadow tables that no longer
exist). Best-effort, never raises: a failed bump leaves the status quo.
"""
try:
current = conn.execute("PRAGMA schema_version").fetchone()[0]
# Wrap within SQLite's 32-bit signed range; peers compare for equality.
conn.execute(f"PRAGMA schema_version={(int(current) + 1) & 0x7FFFFFFF}")
except (sqlite3.DatabaseError, TypeError, IndexError) as exc:
logger.warning("Could not bump state.db schema cookie: %s", exc)
_MAX_PERSISTENT_REPAIR_ATTEMPTS = 3
_MAX_MALFORMED_BACKUPS = 3
# Sidecars copied alongside a damaged DB and pruned with it. ``-journal``
# matters because rollback-journal (DELETE) mode — Hermes's fallback on
# NFS/SMB/FUSE/ZFS and WAL-reset-vulnerable SQLite builds — leaves a hot
# journal whenever a transaction was open; without it the forensic copy
# cannot be rolled back to a consistent state by hand.
_DB_SIDECAR_SUFFIXES = ("-wal", "-shm", "-journal")
# Head/tail bytes sampled by ``_db_fingerprint``: changes on any genuine
# repair/truncation/restore while staying O(1) on a multi-GB file.
_FINGERPRINT_SAMPLE_BYTES = 65536
# Header ranges that move on ordinary commits rather than on repair, masked
# out of the content sample: file change counter (24-27) and version-valid-for
# (92-95). In DELETE mode a commit writes the main file directly and a
# malformed-SCHEMA DB still accepts writes, so without the mask any live write
# re-keys the ledger and the repair budget resets to 1 forever. (WAL mode
# routes commits to the -wal sidecar; masking is harmless there.) The page-1
# sqlite_master b-tree — what repair identity depends on — sits after byte 100
# and stays in the sample.
_FINGERPRINT_VOLATILE_HEADER_RANGES = ((24, 28), (92, 96))
def _mask_volatile_header(head: bytes) -> bytes:
"""Zero the commit-counter fields so ordinary writes don't re-key the ledger."""
if len(head) < 96:
return head
buf = bytearray(head)
for start, end in _FINGERPRINT_VOLATILE_HEADER_RANGES:
buf[start:end] = b"\x00" * (end - start)
return bytes(buf)
# Free-space headroom for the pre-repair forensic backup: a full raw copy of
# the damaged DB plus sidecars, so a repair loop on a large state.db is a disk
# amplifier (one incident wrote ~98MB every ~10s until the volume was nearly
# full). Proportional, not a flat floor: an absolute multi-GB reserve would
# refuse backups that fit on small container/VM volumes, and since a refused
# backup is a HARD STOP that would turn "repair loops" into "repair never
# runs" there. Require the copy plus a small slice of the volume, with a
# modest floor.
_REPAIR_BACKUP_MIN_FREE_BYTES = 256 * 1024 * 1024 # 256 MiB absolute floor
_REPAIR_BACKUP_FREE_FRACTION = 0.02 # plus 2% of the volume
def _repair_backup_headroom_bytes(total_bytes: int) -> int:
"""Free space required *beyond* the copy itself, for a volume of *total_bytes*."""
return max(
_REPAIR_BACKUP_MIN_FREE_BYTES,
int(total_bytes * _REPAIR_BACKUP_FREE_FRACTION),
)
def _repair_scratch_space_error(db_path: Path) -> Optional[str]:
"""Return an error unless snapshot, VACUUM and promotion can fit safely."""
import shutil
try:
main_bytes = db_path.stat().st_size
snapshot_bytes = main_bytes
for suffix in _DB_SIDECAR_SUFFIXES:
sidecar = db_path.with_name(db_path.name + suffix)
if sidecar.exists():
snapshot_bytes += sidecar.stat().st_size
usage = shutil.disk_usage(db_path.parent)
headroom = _repair_backup_headroom_bytes(usage.total)
# Strategy 2 runs VACUUM on the staged DB, which SQLite documents may
# need up to 2x the database size in extra space; the same reserve then
# covers transactional promotion into the live DB.
required = snapshot_bytes + (2 * snapshot_bytes) + headroom
if usage.free >= required:
return None
return (
f"only {usage.free / 1e9:.2f}GB free on {db_path.parent}; the "
f"repair snapshot needs up to {snapshot_bytes / 1e9:.2f}GB, "
f"VACUUM may need another {(2 * snapshot_bytes) / 1e9:.2f}GB, and "
f"{headroom / 1e9:.2f}GB must remain as headroom. Free disk space, "
"then retry."
)
except OSError as exc:
return (
f"could not determine free space on {db_path.parent} ({exc}); "
"refusing the repair snapshot rather than risk filling the volume"
)
def _repair_snapshot_timeout_seconds(source_path: Path) -> float:
"""Bound one SQLite snapshot by source size, including live sidecars.
A WAL can hold committed rows not yet in the main file; count it so a
healthy large-database copy is not cut off by the repair-lock timeout.
"""
from hermes_state import _REPAIR_LOCK_TIMEOUT_SECONDS, _REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND
source_bytes = 0
for suffix in ("", *_DB_SIDECAR_SUFFIXES):
candidate = (
source_path
if not suffix
else source_path.with_name(source_path.name + suffix)
)
try:
source_bytes += candidate.stat().st_size
except FileNotFoundError:
continue
return max(
_REPAIR_LOCK_TIMEOUT_SECONDS,
source_bytes / _REPAIR_SNAPSHOT_MIN_THROUGHPUT_BYTES_PER_SECOND,
)
def _repair_failure_consumes_attempt(exc: BaseException) -> bool:
"""Whether a pre-strategy SQLite failure proves deterministic corruption.
Lock contention, timeouts, disk-full, I/O and filesystem failures are
environmental — a retry may succeed, so they must not burn the repair
ledger. Only SQLite's corruption/image result codes prove deterministic
damage, even when SQLite cannot stage a snapshot far enough to run a
named strategy.
"""
if not isinstance(exc, sqlite3.DatabaseError):
return False
error_code = getattr(exc, "sqlite_errorcode", None)
if isinstance(error_code, int):
# Extended result codes keep the primary code in the low byte.
primary_code = error_code & 0xFF
return primary_code in (sqlite3.SQLITE_CORRUPT, sqlite3.SQLITE_NOTADB)
# Older sqlite3 without result-code attributes: narrow message match only,
# never turning generic "disk is full"/"readonly" into permanent failures.
message = str(exc).lower()
return (
"file is not a database" in message
or "database disk image is malformed" in message
)
def _repair_ledger_path(db_path: Path) -> Path:
return db_path.with_name(db_path.name + ".repair-attempts.json")
def _db_fingerprint(db_path: Path) -> "Optional[str]":
"""Cheap identity for a damaged DB file: size + a bounded content sample.
Deliberately EXCLUDES mtime: the malformed-schema class still accepts
writes, so live writers, WAL checkpoints and the strategies themselves move
mtime between passes; keyed on mtime, every pass looked like a NEW file,
the attempt counter reset to 1 forever and each pass wrote another
full-size forensic copy. Hashing a multi-GB file on every open is the cost
this ledger exists to avoid, so sample the head/tail slices any real
repair, truncation or restore necessarily changes.
Runs under ``offline_file_access``: ``close()`` on ANY raw descriptor
cancels every POSIX advisory lock this process holds on the file, including
a peer connection's RESERVED lock (``hermes_cli.sqlite_safe_read`` rule 1),
and a live peer is the expected case here (this runs BEFORE
``_backup_db_file``'s ``has_live_connection`` guard). Returns ``None``
("identity unavailable") when that makes the read unsafe. Callers MUST NOT
substitute a differently-shaped key: the ledger compares keys for equality,
so alternating shapes never matches and the unbounded loop returns; the
ledger helpers keep the recorded key instead.
"""
try:
st = db_path.stat()
try:
from hermes_cli.sqlite_safe_read import (
LiveConnectionError,
offline_file_access,
)
except ImportError:
# Scaffold/embed installs ship hermes_state without hermes_cli; no
# tracked connections exist there, so the raw read is safe.
@contextmanager
def offline_file_access(_path, **_kw):
yield
class LiveConnectionError(Exception):
pass
try:
with offline_file_access(db_path, what="fingerprint"):
with open(db_path, "rb") as fh:
head = fh.read(_FINGERPRINT_SAMPLE_BYTES)
if st.st_size > _FINGERPRINT_SAMPLE_BYTES:
fh.seek(max(0, st.st_size - _FINGERPRINT_SAMPLE_BYTES))
tail = fh.read(_FINGERPRINT_SAMPLE_BYTES)
else:
tail = b""
except LiveConnectionError:
return None
digest = hashlib.sha256(_mask_volatile_header(head) + tail).hexdigest()[:32]
return f"{st.st_size}:{digest}"
except OSError:
return None
def _backup_content_identity(db_path: Path) -> "Optional[str]":
"""Recovery-image identity for forensic-backup dedupe: whole file + sidecars.
A DIFFERENT equivalence relation from :func:`_db_fingerprint`; never
conflate them. The fingerprint answers "same repair epoch?" and masks
commit counters / samples only head+tail so an ordinary write does not mint
a fresh repair budget. A live writer can commit rows into an *interior*
page while preserving size and the first/last 64 KiB, so two materially
different recovery images share one fingerprint; reusing a backup on that
basis hands the operator a snapshot predating real user data. A forensic
copy must claim byte identity, so this digests the ENTIRE main file plus
every present sidecar (the WAL can hold uncheckpointed committed
frames). The O(n) read is cheaper than the O(n) write it avoids. Runs under
``offline_file_access`` (same POSIX-lock reason as ``_db_fingerprint``);
``None`` when a live connection makes the read unsafe — the caller then
takes a fresh backup, never a false reuse.
"""
try:
from hermes_cli.sqlite_safe_read import (
LiveConnectionError,
offline_file_access,
)
except ImportError:
@contextmanager
def offline_file_access(_path, **_kw):
yield
class LiveConnectionError(Exception):
pass
def _hash_whole(path: Path, hasher: "Any") -> None:
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
hasher.update(chunk)
try:
hasher = hashlib.sha256()
with offline_file_access(db_path, what="backup-identity"):
# Length-delimit every member (main file included) so the
# concatenation is prefix-free; otherwise a main-file tail could
# coincide with a main+sidecar split and dedupe two images together.
hasher.update(f"\0main:{db_path.stat().st_size}\0".encode())
_hash_whole(db_path, hasher)
for suffix in _DB_SIDECAR_SUFFIXES:
sidecar = db_path.with_name(db_path.name + suffix)
if sidecar.exists():
hasher.update(f"\0{suffix}:{sidecar.stat().st_size}\0".encode())
_hash_whole(sidecar, hasher)
return hasher.hexdigest()
except LiveConnectionError:
return None
except OSError:
return None
def _read_repair_ledger(db_path: Path) -> "Dict[str, Any]":
try:
raw = json.loads(_repair_ledger_path(db_path).read_text(encoding="utf-8"))
if isinstance(raw, dict):
return raw
except (OSError, ValueError):
pass
return {}
def _persistent_repair_attempts_exhausted(db_path: Path) -> bool:
"""Whether *db_path* has already burned its cross-restart repair budget.
True only when the ledger records ``_MAX_PERSISTENT_REPAIR_ATTEMPTS``
failures against the CURRENT fingerprint. Never raises; a missing/corrupt
ledger or unstatable DB reads as "not exhausted" (the in-process claim and
cross-process lock still bound one run). When a live connection makes the
fingerprint unavailable, fall back to the SIZE the ledger recorded —
otherwise a peer connection hides an exhausted budget on every pass.
"""
ledger = _read_repair_ledger(db_path)
recorded = ledger.get("fingerprint")
fp = _db_fingerprint(db_path)
if fp is None:
# Size is the one key component that needs no raw read.
try:
size_prefix = f"{db_path.stat().st_size}:"
except OSError:
return False
if not isinstance(recorded, str) or not recorded.startswith(size_prefix):
return False
elif recorded != fp:
return False
return int(ledger.get("failed_attempts", 0)) >= _MAX_PERSISTENT_REPAIR_ATTEMPTS
def _persistent_repair_exhausted_error(db_path: Path) -> str:
"""The stable operator-facing diagnostic for an exhausted repair budget."""
return (
f"automatic repair has already failed "
f"{_MAX_PERSISTENT_REPAIR_ATTEMPTS} times on this exact file — "
"the corruption is beyond the schema/FTS repair strategies "
"(likely b-tree page damage). Manual recovery required: restore "
f"a backup, or salvage with `sqlite3 {db_path} \".recover\"`. "
f"Delete {_repair_ledger_path(db_path).name} to force another "
"automatic attempt."
)
def _record_repair_outcome(
db_path: Path, *, repaired: bool, fingerprint: "Optional[str]" = None
) -> None:
"""Update the persistent attempt ledger after a repair pass. Never raises.
Defaults to the post-attempt fingerprint (what the NEXT exhaustion probe
observes). When a live connection makes it unavailable, keep the recorded
key and still increment — dropping the pass would let a peer connection
reset the budget every time. Never write a differently shaped key.
"""
ledger_path = _repair_ledger_path(db_path)
try:
if repaired:
ledger_path.unlink(missing_ok=True)
return
ledger = _read_repair_ledger(db_path)
recorded = ledger.get("fingerprint")
fp = fingerprint if fingerprint is not None else _db_fingerprint(db_path)
if fp is None:
if not isinstance(recorded, str):
# No prior key to extend and no safe way to mint one; the
# in-process claim and cross-process lock still bound this run.
return
fp = recorded
attempts = (
int(ledger.get("failed_attempts", 0)) + 1 if recorded == fp else 1
)
import datetime
ledger_path.write_text(
json.dumps(
{
"fingerprint": fp,
"failed_attempts": attempts,
"last_attempt": datetime.datetime.now().isoformat(
timespec="seconds"
),
}
),
encoding="utf-8",
)
except Exception as exc: # pragma: no cover - best effort
logger.warning("Could not update state.db repair ledger: %s", exc)
def _existing_malformed_backups(db_path: Path) -> "List[Path]":
"""Timestamped forensic backups of *db_path*, newest first."""
prefix = f"{db_path.name}.malformed-backup-"
try:
found = [
p
for p in db_path.parent.iterdir()
if p.name.startswith(prefix)
and not p.name.endswith(_DB_SIDECAR_SUFFIXES)
]
except OSError:
return []
return sorted(found, key=lambda p: p.name, reverse=True)
def _prune_malformed_backups(db_path: Path, keep: int = _MAX_MALFORMED_BACKUPS) -> None:
"""Delete all but the *keep* newest forensic backups (and sidecars)."""
for stale in _existing_malformed_backups(db_path)[keep:]:
for victim in (
stale,
*(stale.with_name(stale.name + suffix) for suffix in _DB_SIDECAR_SUFFIXES),
):
try:
victim.unlink(missing_ok=True)
except OSError as exc: # pragma: no cover - best effort
logger.warning("Could not prune stale DB backup %s: %s", victim, exc)
def _backup_db_file(db_path: Path) -> "Tuple[Optional[Path], Optional[str]]":
"""Raw-copy a (possibly malformed) DB plus sidecars to a timestamped backup.
Raw bytes on purpose: the DB won't open cleanly, so preserve them exactly
for forensics / manual restore. Returns ``(backup_path, None)`` or
``(None, reason)``; the repair path treats a refused backup as a HARD STOP
because the forensic bundle is the recovery path when every strategy fails.
Refuses while a connection to this DB is live in the process: reading the
file would ``close()`` a descriptor and cancel that connection's POSIX
advisory locks (see ``hermes_cli.sqlite_safe_read``) — a real case, since
one SessionDB can enter repair while the gateway holds others.
"""
import datetime
import shutil
try:
from hermes_cli.sqlite_safe_read import has_live_connection
except ImportError:
has_live_connection = None # type: ignore[assignment]
if has_live_connection is not None and has_live_connection(db_path):
reason = (
f"a connection to {db_path} is still open in this process; "
"raw-copying it would cancel that connection's POSIX advisory "
"locks. Close all SessionDB handles first."
)
logger.error("Refusing to raw-copy %s for backup: %s", db_path, reason)
return None, reason
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = db_path.with_name(f"{db_path.name}.malformed-backup-{stamp}")
# Same-second collision (two damaged states within one second) must not
# overwrite the earlier forensic copy.
seq = 1
while backup_path.exists():
backup_path = db_path.with_name(
f"{db_path.name}.malformed-backup-{stamp}_{seq}"
)
seq += 1
try:
# Sweep staging debris from an earlier interrupted pass BEFORE the
# dedupe: leftover staging is byte-identical to the damaged DB, so
# dedupe would hand it back as a legitimate backup. Also sweeps the
# pre-merge ``.incomplete`` spelling, which prefix-matches as a backup,
# sorts NEWEST and would otherwise survive prune forever.
for pattern in (
f"{db_path.name}.backup-staging-*",
f"{db_path.name}.malformed-backup-*.incomplete*",
):
for old in db_path.parent.glob(pattern):
try:
old.unlink(missing_ok=True)
except OSError: # pragma: no cover - best effort
pass
# Dedupe: a repair loop used to copy the SAME damaged bytes on every
# restart (~900MB a pass, 89GB over 11 days). If the newest existing
# backup is byte-identical to the current recovery image, reuse it.
# Match on ``_backup_content_identity`` — NOT mtime (the malformed-
# SCHEMA class still accepts writes, so mtime missed every pass) and
# NOT ``_db_fingerprint`` (see its docstring: an interior-page write
# changes the recovery image without changing the fingerprint).
try:
# Only hash the source when there is a candidate to compare
# against; hashing a multi-GB source right before copying it is
# pure waste on the common first-corruption pass.
existing_backups = _existing_malformed_backups(db_path)[:1]
if existing_backups:
src_id = _backup_content_identity(db_path)
for existing in existing_backups:
if src_id is not None and _backup_content_identity(existing) == src_id:
logger.info(
"Reusing existing forensic backup %s (identical to the "
"damaged DB).", existing,
)
return existing, None
except OSError:
pass
# Disk guard: a full raw copy on a nearly-full volume (which a
# preceding repair loop may itself have caused) can finish off the
# disk and every process on the machine. Refuse while there is room to.
try:
need = db_path.stat().st_size
for suffix in _DB_SIDECAR_SUFFIXES:
sidecar = db_path.with_name(db_path.name + suffix)
if sidecar.exists():
need += sidecar.stat().st_size
usage = shutil.disk_usage(db_path.parent)
headroom = _repair_backup_headroom_bytes(usage.total)
if usage.free - need < headroom:
reason = (
f"only {usage.free / 1e9:.2f}GB free on {db_path.parent}; "
f"copying the damaged DB needs {need / 1e9:.2f}GB and must "
f"leave {headroom / 1e9:.2f}GB headroom. Free disk space, "
f"then retry (or recover manually with `sqlite3 {db_path} "
'".recover"`).'
)
logger.error("Refusing forensic backup of %s: %s", db_path, reason)
return None, reason
except OSError as exc:
# Fail CLOSED: the nearly-full volume this guard exists for is
# exactly where stat()/disk_usage() is most likely to fail, and
# proceeding would take the copy that finishes off the disk. Repair
# then waits (HARD STOP) for a human to free space — the safe side.
reason = (
f"could not determine free space on {db_path.parent} ({exc}); "
"refusing the forensic copy rather than risk filling the "
f"volume. Free disk space, then retry (or recover manually "
f'with `sqlite3 {db_path} ".recover"`).'
)
logger.error("Refusing forensic backup of %s: %s", db_path, reason)
return None, reason
# Copy to a staging name OUTSIDE the ``.malformed-backup-`` prefix and
# rename into place only once every copy succeeded. A staging name
# inside the prefix (e.g. ``…-<stamp>.incomplete``) counts as a backup,
# sorts NEWEST (so prune kept partials and deleted intact copies), and
# dedupe could return it as ``backup_path`` — passing the hard stop with
# no real forensic copy on disk.
staging = db_path.with_name(f"{db_path.name}.backup-staging-{stamp}")
# (staging_src, final_dst) pairs. PUBLICATION ORDER MATTERS: the main
# DB name is the bundle's commit marker (what
# ``_existing_malformed_backups`` counts), so sidecars go FIRST and the
# main DB LAST; a failure partway then never leaves a countable main
# backup over a missing sidecar that would pass the hard stop and dedupe.
staged_sidecars: "List[Tuple[Path, Path, Path]]" = []
for suffix in _DB_SIDECAR_SUFFIXES:
sidecar = db_path.with_name(db_path.name + suffix)
if sidecar.exists():
side_staging = staging.with_name(staging.name + suffix)
side_dst = backup_path.with_name(backup_path.name + suffix)
staged_sidecars.append((sidecar, side_staging, side_dst))
main_pair = (staging, backup_path)
published: "List[Path]" = []
all_staging_srcs = [staging] + [s for _src, s, _d in staged_sidecars]
try:
shutil.copy2(db_path, staging)
for sidecar, side_staging, _side_dst in staged_sidecars:
shutil.copy2(sidecar, side_staging)
publish_order = [
(s, d) for _src, s, d in staged_sidecars
] + [main_pair]
for src, dst in publish_order:
os.replace(src, dst)
published.append(dst)
except Exception:
# Roll back unpublished staging files AND anything already promoted,
# so a failure after the main os.replace leaves no official backup_path.
for src in all_staging_srcs:
try:
src.unlink(missing_ok=True)
except OSError:
pass
for dst in published:
try:
dst.unlink(missing_ok=True)
except OSError:
pass
try:
staging.unlink(missing_ok=True)
except OSError:
pass
raise
_prune_malformed_backups(db_path)
return backup_path, None
except Exception as exc: # pragma: no cover - best effort
logger.warning("Could not back up malformed DB %s: %s", db_path, exc)
return None, f"backup copy failed: {exc}"
def preflight_db_writability(
db_path: Path,
*,
db_label: str = "state.db",
) -> None:
"""Refuse-or-repair read-only DB files BEFORE the first connection opens.
A stray read-only ``state.db`` / ``-wal`` / ``-shm`` (sudo run, restored
backup, copied dotfiles) otherwise surfaces as an opaque "attempt to write
a readonly database" deep inside ``_init_schema``, and the obvious wrong
"fix" (deleting the ``-wal``) silently loses committed transactions.
Repairs with ``chmod u+rw`` only inside the Hermes home tree (Hermes owns
those files, and ``chmod`` fails on files the user doesn't own, which
bounds the repair exactly); otherwise fails fast naming the exact file and
``chmod`` command. Never deletes or truncates a WAL sidecar — once
writable, the normal open path checkpoints its committed frames.
``:memory:`` and ``file:`` URIs are skipped. Shared with ``kanban_db``.
"""
raw = str(db_path)
if raw == ":memory:" or raw.startswith("file:"):
return
try:
home: Optional[Path] = Path(get_hermes_home()).resolve()
except Exception: # pragma: no cover - defensive
home = None
def _in_repair_scope(p: Path) -> bool:
if home is None:
return False
try:
return p.resolve().is_relative_to(home)
except (OSError, ValueError):
return False
def _ensure_writable(p: Path, *, is_dir: bool = False) -> None:
import stat as _stat
if os.access(p, os.R_OK | os.W_OK):
return
if _in_repair_scope(p):
try:
add = _stat.S_IRUSR | _stat.S_IWUSR | (_stat.S_IXUSR if is_dir else 0)
os.chmod(p, p.stat().st_mode | add)
except OSError:
pass
if os.access(p, os.R_OK | os.W_OK):
logger.info(
"%s preflight: repaired read-only %s (chmod u+rw%s)",
db_label,
p,
"x" if is_dir else "",
)
return
kind = "directory" if is_dir else "file"
wal_note = (
" Do NOT delete the -wal file — it contains committed data that "
"will be merged into the database once it is writable."
if p.name.endswith("-wal")
else ""
)
raise sqlite3.OperationalError(
f"{db_label} is not writable: {kind} {p} is read-only for this "
f"user. Hermes needs read-write access to open the database. "
f"Fix with: chmod u+rw{'x' if is_dir else ''} '{p}'"
f" (files owned by another user may need sudo/chown).{wal_note}"
)
parent = db_path.parent
if parent.is_dir():
# SQLite needs a writable directory in every journal mode (WAL/SHM
# sidecars, or the rollback journal in DELETE mode).
_ensure_writable(parent, is_dir=True)
for suffix in ("", "-wal", "-shm"):
p = db_path.with_name(db_path.name + suffix) if suffix else db_path
if p.is_file():
_ensure_writable(p)
def _connect_repair_durable(
db_path: Path, *, timeout: float = 5.0
) -> sqlite3.Connection:
"""``sqlite3.connect`` for the repair/probe paths, with macOS write barriers.
These paths open ``state.db`` directly (not via ``SessionDB`` /
:func:`apply_wal_with_fallback`), so they inherited ``synchronous=NORMAL``
and no ``checkpoint_fullfsync`` — on Darwin, where ``fsync()`` guarantees
neither data-on-platter nor ordering, an interrupted rewrite leaves
half-written b-tree pages, and ``REINDEX``/``VACUUM``/``writable_schema``
surgery rewrite nearly every page. Autocommit (``isolation_level=None``)
is preserved: DDL and ``VACUUM`` are illegal inside an implicit
transaction. Barriers are best-effort by necessity: SQLite loads the schema
before any statement, so on a malformed schema even ``PRAGMA
synchronous=FULL`` raises — and a malformed DB is this helper's input.
Whole-file rewrites call :func:`_reapply_durability_barriers` once the
schema parses again.
"""
conn = sqlite3.connect(str(db_path), timeout=timeout, isolation_level=None)
_reapply_durability_barriers(conn)
return conn
def _reapply_durability_barriers(conn: sqlite3.Connection) -> bool:
"""Best-effort (re)application of the macOS write barriers. Never raises.
True when the pragmas were accepted. Call before ``VACUUM``/``REINDEX``
once the schema parses: a connection opened on a malformed schema could
not take them at open time.
"""
from hermes_state import _apply_macos_checkpoint_barrier, _enforce_macos_synchronous_full
try:
_apply_macos_checkpoint_barrier(conn)
_enforce_macos_synchronous_full(conn)
return True
except sqlite3.DatabaseError:
# Schema still unparseable — pragmas cannot be set yet.
return False
except Exception:
return False
def apply_durability_barriers(conn: sqlite3.Connection) -> bool:
"""Apply state-store durability barriers without changing journal mode.
Public entry point for secondary users of ``state.db`` that must inherit
its owner's journal mode. Also applies the configured
``database.synchronous`` level, a per-connection pragma that otherwise
only rides on the journal-mode setup path guests must not run.
"""
from hermes_state import _apply_synchronous_pragma
ok = _reapply_durability_barriers(conn)
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()
raw_synchronous = cfg_get(cfg, "database", "synchronous", default=None)
if raw_synchronous is not None:
_apply_synchronous_pragma(
conn, raw_synchronous, db_label="state.db (guest)"
)
except Exception:
pass
return ok
@contextmanager
def _exclusive_repair_db_guard(db_path: Path):
"""Yield one live connection that excludes writers for repair surgery.
``locking_mode=EXCLUSIVE`` retains file-level exclusion after the short
``BEGIN EXCLUSIVE`` is rolled back. The rollback is essential:
``Connection.backup`` uses this connection as *source* and later as the
promotion *destination*, both of which require it transaction-free. It
stays open across the whole snapshot -> strategies -> promotion window, so
no other writer can commit a change promotion would overwrite. Existing
readers make acquisition fail rather than being disturbed: repair fails
closed unless this process owns the whole window.
"""
guard: Optional[sqlite3.Connection] = None
try:
# The cross-process repair lock already serializes repairers. Do not
# wait behind an ordinary application connection: a partial repair is
# less safe than an explicit "stop the gateway and retry".
guard = _connect_repair_durable(db_path, timeout=0.0)
guard.execute("PRAGMA locking_mode=EXCLUSIVE")
guard.execute("BEGIN EXCLUSIVE")
guard.execute("ROLLBACK")
except (sqlite3.Error, OSError) as exc:
if guard is not None:
try:
guard.execute("PRAGMA locking_mode=NORMAL")
except Exception:
pass
guard.close()
yield None, exc
return
try:
yield guard, None
finally:
try:
# Release the exclusive locks before close; also keeps a close-time
# checkpoint from being mistaken for a repair write by callers that
# immediately reopen state.db.
guard.execute("PRAGMA locking_mode=NORMAL")
except Exception:
pass
guard.close()
def _copy_database_snapshot(
source_path: Path,
destination_path: Path,
*,
source_connection: Optional[sqlite3.Connection] = None,
destination_connection: Optional[sqlite3.Connection] = None,
) -> None:
"""Copy one complete SQLite snapshot without replacing either file inode.
The online backup API folds committed WAL frames into the source snapshot
and writes the destination in one transaction (rolled back if interrupted),
so ``state.db`` is never swapped out from under handles that refer to it.
"""
# Compute the deadline before opening an owned source connection: a
# sidecar vanishing mid-stat must not leak a just-opened descriptor.
deadline_seconds = _repair_snapshot_timeout_seconds(source_path)
deadline = time.monotonic() + deadline_seconds
source = source_connection or _connect_repair_durable(source_path)
destination = destination_connection
own_source = source_connection is None
own_destination = destination_connection is None
def _check_deadline(_status: int, _remaining: int, _total: int) -> None:
if time.monotonic() >= deadline:
raise TimeoutError(
"timed out copying SQLite repair snapshot after "
f"{deadline_seconds:.0f}s"
)
try:
if destination is None:
destination = _connect_repair_durable(destination_path)
elif destination.in_transaction:
# sqlite3_backup needs a transaction-free destination; the exclusive
# guard retains exclusion via locking_mode, not a transaction.
raise sqlite3.ProgrammingError(
"SQLite repair backup destination has an active transaction"
)
source.backup(
destination,
pages=256,
progress=_check_deadline,
sleep=_REPAIR_LOCK_POLL_SECONDS,
)
finally:
if own_destination and destination is not None:
destination.close()
if own_source:
source.close()
def _db_opens_cleanly(db_path: Path) -> Optional[str]:
"""Probe a DB on a fresh connection. Returns None if healthy, else a reason.
Runs the first statement that trips the malformed-schema parse (``PRAGMA
journal_mode``), ``integrity_check``, a ``sessions`` read, FTS5 MATCH
probes, and a rolled-back ``messages`` write — so FTS5 index corruption,
which leaves reads and ``integrity_check`` passing while every ``INSERT
INTO messages`` fails through the FTS triggers, is reported as unhealthy.
"""
from hermes_state import SessionDB, load_fts5_cjk_extension
conn = _connect_repair_durable(db_path)
try:
# Best-effort tokenizer load: messages_fts_cjk needs cjk_unicode61
# before any statement (incl. the trigger-driven write probe) can touch
# it. Without it this probe sees the DB as a tokenizer-less SessionDB
# would (which drops the cjk triggers), so tokenizer absence must never
# classify as corruption.
load_fts5_cjk_extension(conn)
conn.execute("PRAGMA journal_mode").fetchone()
rows = conn.execute("PRAGMA integrity_check").fetchall()
problems = [str(r[0]) for r in rows if r and str(r[0]).lower() != "ok"]
if problems:
return "; ".join(problems[:3])
conn.execute("SELECT COUNT(*) FROM sessions").fetchone()
# FTS5 read probe. The write probe below misses partial shadow-table
# corruption where MATCH / snippet / rank raise DatabaseError("database
# disk image is malformed"), silently breaking session_search and
# /resume title resolution while check-only reports healthy.
for fts_table in ("messages_fts", "messages_fts_trigram", "messages_fts_cjk"):
try:
# Trigram backs title resolution, so probe it too. MATCH '""'
# (empty phrase) parses, scans zero rows and exercises the
# shadow-table read path; FTS5 rejects MATCH '' outright.
conn.execute(
f"SELECT 1 FROM {fts_table} WHERE {fts_table} MATCH '\"\"' LIMIT 1"
).fetchone()
except sqlite3.OperationalError as exc:
# Canonical capability classifier: on builds without fts5 a
# legacy messages_fts table may exist and MATCH raises "no such
# module: fts5"; treating that as corruption would send the DB
# into repair, whose final fallback deletes the messages_fts%
# schema. Covers "no such tokenizer: trigram" too.
if SessionDB._is_fts5_unavailable_error(exc):
continue
msg = str(exc).lower()
if "no such table" in msg or "no such column" in msg:
# FTS5 not built yet (brand new file mid-init).
continue
return f"fts5 read probe failed on {fts_table}: {exc}"
except sqlite3.DatabaseError as exc:
# Partial shadow-table damage: MATCH raises though the table parses.
return f"fts5 read probe failed on {fts_table}: {exc}"
# FTS write probe: drive a row through the messages_fts* triggers in a
# transaction that is always rolled back. Missing messages/sessions
# tables (brand new file mid-init) mean "not yet populated", not corruption.
probe_session_id = f"_hermes_fts_health_probe_{time.time_ns()}"
try:
conn.execute("BEGIN IMMEDIATE")
conn.execute(
"INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)",
(probe_session_id, "_health_probe", time.time()),
)
conn.execute(
"INSERT INTO messages (session_id, role, content, timestamp) "
"VALUES (?, ?, ?, ?)",
(probe_session_id, "user", "_fts_health_probe", time.time()),
)
conn.execute("ROLLBACK")
except sqlite3.OperationalError as exc:
# Missing tables / FTS disabled — not the corruption class we probe.
try:
conn.execute("ROLLBACK")
except sqlite3.Error:
pass
msg = str(exc).lower()
if "no such table" in msg or "no such column" in msg:
return None
if "no such tokenizer: cjk_unicode61" in msg:
# This process couldn't load the cjk extension while the DB
# carries the cjk index — capability gap, not corruption. A
# tokenizer-less SessionDB self-heals by dropping the triggers.
return None
return str(exc)
return None
except sqlite3.DatabaseError as exc:
return str(exc)
finally:
conn.close()
def _live_writer_holds_db(db_path: Path) -> bool:
"""True when a connection outside this call still holds ``db_path`` open.
Asks SQLite for what a repair needs and a live holder cannot grant:
``PRAGMA locking_mode=EXCLUSIVE`` then ``BEGIN IMMEDIATE``. In WAL mode
that needs exclusive locks on the WAL index, so any other open connection
fails it with SQLITE_BUSY; neither statement parses the schema, so it
works on malformed DBs. Fails **open** (False) on anything but a positive
busy/locked signal — refusing to repair a DB nobody holds would strand the
self-heal path.
Scope: WAL mode only. In ``journal_mode=DELETE`` (Hermes's fallback on
WAL-reset-vulnerable builds and NFS/SMB) a held reader takes only SHARED
and this returns False; repair is then serialised only by the cross-process
repairer lock. Broadening to DELETE mode is a follow-up.
"""
probe = None
try:
probe = _connect_repair_durable(db_path, timeout=0.0)
probe.execute("PRAGMA locking_mode=EXCLUSIVE")
probe.execute("BEGIN IMMEDIATE")
probe.execute("ROLLBACK")
return False
except sqlite3.OperationalError as exc:
lowered = str(exc).lower()
return "locked" in lowered or "busy" in lowered
except sqlite3.DatabaseError:
# Malformed/unreadable: no evidence of a live holder either way.
return False
except Exception:
return False
finally:
if probe is not None:
try:
# Drop exclusive mode before close so the probe never leaves
# the file pinned.
probe.execute("PRAGMA locking_mode=NORMAL")
except Exception:
pass
try:
probe.close()
except Exception:
pass
def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, Any]:
"""Repair a state.db whose ``sqlite_master`` is malformed or whose FTS
indexes reject writes.
Two corruption classes: malformed schema / "duplicate object definition"
(even ``PRAGMA`` fails), and FTS write-corruption (base tables read fine,
``integrity_check`` passes, writes fail through ``messages_fts*``
triggers). Least-destructive first: (1) rebuild FTS in place via FTS5
``'rebuild'``; (2) de-duplicate ``sqlite_master`` (lowest rowid per
``type``/``name``), FTS preserved; (3) drop the FTS schema + ``VACUUM``,
rebuilt on the next ``SessionDB()`` open. Canonical rows are never
modified by a failed attempt: strategies run on a complete SQLite snapshot
and a successful result is copied back transactionally. A raw backup is
taken first unless ``backup=False``. Surgery is serialised across
processes (:func:`_cross_process_repair_lock`): the gateway, Desktop
backend and CLI all open the same file, and concurrent ``writable_schema``
surgery is itself a corruption source.
Returns ``{repaired: bool, strategy: str|None, backup_path: str|None,
error: str|None}``.
"""
from hermes_state import _cross_process_repair_lock, _db_opens_cleanly, _live_writer_holds_db, _persistent_repair_attempts_exhausted, _probe_journal_mode_for_repair, _record_repair_outcome, _repair_state_db_schema_locked
report: Dict[str, Any] = {
"repaired": False,
"strategy": None,
"backup_path": None,
"error": None,
}
# Startup-watchdog progress lease: repair is I/O-bound (near-zero CPU),
# which the watchdog's CPU fallback would misread as a parked deadlock. A
# single lease (clamped to _MAX_LEASE_S=900) is deliberate: up to that much
# zombie time on a wedged repair beats per-chunk renewal complexity.
report_startup_progress(900.0, phase="state_db_repair")
db_path = Path(db_path)
if not db_path.exists():
report["error"] = f"{db_path} does not exist"
return report
# Cross-restart attempt cap: the in-memory claim bounds one process, but a
# class the strategies cannot heal (b-tree page damage) used to re-run the
# whole surgery, with a fresh forensic backup, on EVERY restart. After
# _MAX_PERSISTENT_REPAIR_ATTEMPTS failures on the same file, stop.
if _persistent_repair_attempts_exhausted(db_path):
report["error"] = _persistent_repair_exhausted_error(db_path)
logger.error("state.db repair skipped: %s", report["error"])
return report
result = report
with _cross_process_repair_lock(db_path) as holding_lock:
if not holding_lock:
# Another process is inside its critical section, or the lock file
# could not be opened. It may have healed the file already (long
# VACUUM after a successful strategy), so re-probe before failing.
if _db_opens_cleanly(db_path) is None:
report["repaired"] = True
report["strategy"] = "repaired_by_other_process"
else:
report["error"] = (
"could not obtain the state.db repair lock (held by "
"another process, or the lock file was unopenable); "
"skipped schema surgery to avoid racing a concurrent "
"repairer"
)
else:
# Recheck exhaustion after acquisition: a queued repairer can have
# recorded the final failure while this process waited, and this
# process must not start a fourth attempt.
if _persistent_repair_attempts_exhausted(db_path):
report["error"] = _persistent_repair_exhausted_error(db_path)
logger.error("state.db repair skipped: %s", report["error"])
# WAL-holder preflight: fail-closed for active readers before a
# forensic backup is taken. Not the race defence — the exclusive
# guard in the locked routine excludes writers through promotion and
# rejects DELETE-mode readers this probe cannot see.
elif _live_writer_holds_db(db_path):
report["error"] = (
"a live writer still holds state.db; skipped schema surgery "
"to avoid tearing b-tree pages under a concurrent writer. "
"Stop the gateway (hermes gateway stop) and retry."
)
logger.error("state.db repair skipped: %s", report["error"])
else:
# Probe the journal mode BEFORE surgery: a rebuilt file comes
# back in the default (delete) mode and nothing else records
# the flip (see _restore_journal_mode_after_repair). The probe
# may fail on a damaged file; then database.journal_mode is
# the restore target.
before_mode = _probe_journal_mode_for_repair(db_path)
result = _repair_state_db_schema_locked(
db_path, backup=backup, report=report
)
if result.get("repaired"):
result["journal_mode_before"] = before_mode
_restore_journal_mode_after_repair(db_path, before_mode)
# Environmental aborts happen before a strategy mutates the
# snapshot; they are retriable, not proof a strategy was exhausted.
# Keep that private marker out of the public report. The ledger
# update stays under the same cross-process lock as surgery so two
# repairers cannot lose each other's updates; a queued loser must
# not record at all.
attempted = bool(result.pop("_repair_attempted", False))
if attempted or result.get("repaired"):
_record_repair_outcome(
db_path, repaired=bool(result.get("repaired"))
)
return result
def _probe_journal_mode_for_repair(db_path: Path) -> Optional[str]:
"""Best-effort journal-mode probe for a (possibly malformed) DB file.
Returns ``wal``/``delete``, or ``None`` when the file cannot be opened or
probed (malformed header, concurrent opener's locks — both expected on
the repair path); callers then fall back to ``database.journal_mode``.
"""
from hermes_state import _on_disk_journal_mode
try:
conn = _connect_repair_durable(db_path)
try:
return _on_disk_journal_mode(conn)
finally:
conn.close()
except (sqlite3.Error, OSError):
return None
def _restore_journal_mode_after_repair(db_path: Path, before_mode: Optional[str]) -> None:
"""Re-apply the journal mode after schema surgery.
A rebuilt SQLite file comes back in the default (delete) mode; without
this, a corruption event silently moves a WAL store out of WAL (the
open-time WAL-reset gate never sees a flip made inside repair). Routed
through :func:`apply_wal_with_fallback`, not a direct pragma, so it
inherits the vulnerable-SQLite WAL-reset gate (a rebuilt file IS a new
database; on a vulnerable runtime the gate deliberately keeps DELETE, and
"could not reach WAL" is expected there), the macOS-NFS silent-refusal
handling, and the WAL companions (size limit, checkpoint barrier,
synchronous=FULL). ``before_mode`` (None if unprobeable) is only for the
log comparison; the target comes from ``database.journal_mode``.
Best-effort: the repair already succeeded, so failures log at WARNING.
"""
from hermes_state import apply_wal_with_fallback
try:
conn = _connect_repair_durable(db_path)
try:
after = apply_wal_with_fallback(conn, db_label=db_path.name)
finally:
conn.close()
if before_mode and after != before_mode:
logger.warning(
"state.db repair changed journal_mode %r -> %r "
"(pre-surgery probe %r; restore resolved through "
"apply_wal_with_fallback per database.journal_mode and the "
"WAL-reset gate)",
before_mode, after, before_mode,
)
except (sqlite3.Error, OSError) as exc:
logger.warning(
"state.db repair at %s: post-surgery journal-mode restore "
"failed (%s); verify with PRAGMA journal_mode on the next open",
db_path, exc,
)
def _repair_state_db_schema_locked(
db_path: Path, *, backup: bool, report: Dict[str, Any]
) -> Dict[str, Any]:
"""Repair strategies for :func:`repair_state_db_schema`.
Caller must hold the cross-process repair lock for *db_path*. Strategies
run on a SCRATCH COPY; the result is copied back through SQLite's
transactional backup API only once proven to open cleanly, so a failed
repair cannot modify or lose committed canonical data. (A WAL checkpoint
of already-committed frames on guard release is not a repair mutation.)
WHY not in place: Strategy 2 ends in ``VACUUM``, which rebuilds the file
from the schema SQLite can still parse. When the damage IS in the schema
b-tree — the ``malformed database schema ()`` class handled here — every
table hanging off the unreadable part is silently dropped, the probe then
correctly reports STILL malformed, and repair returned ``repaired=False``
having destroyed what it was asked to save. The forensic backup does not
close this (nothing reads it back). Not mutating the original is the
property that holds without a human in the loop.
"""
from hermes_state import _backup_db_file, _copy_database_snapshot, _db_opens_cleanly, _repair_scratch_space_error, _run_repair_strategies, _unlink_db_triple
scratch = db_path.with_name(f"{db_path.name}.repair-scratch")
cleanup_error = _unlink_db_triple(scratch)
if cleanup_error is not None:
report["error"] = (
"could not remove a stale repair snapshot before probing state.db: "
f"{cleanup_error}"
)
logger.error("state.db repair aborted: %s", report["error"])
return report
# Re-probe under the lock: a process we queued behind may have just
# repaired the file; redoing surgery would undo its work (the
# repair/re-corrupt cascade this lock exists to break).
if _db_opens_cleanly(db_path) is None:
report["repaired"] = True
report["strategy"] = "already_healthy"
return report
if backup:
bpath, backup_error = _backup_db_file(db_path)
report["backup_path"] = str(bpath) if bpath else None
if bpath is None:
# HARD STOP: the forensic image is still required when corruption
# defeats every strategy, even though strategies run on a snapshot.
report["error"] = (
"pre-repair backup refused; aborting schema repair to avoid "
f"mutating the only copy of the damaged DB: {backup_error}"
)
logger.error("state.db repair aborted: %s", report["error"])
return report
# The forensic copy deliberately precedes this guard: its raw-copy safety
# checks inspect real live holders and would be poisoned by our exclusive
# connection. Everything affecting the repair image or live promotion
# happens only after writer exclusion is held.
with _exclusive_repair_db_guard(db_path) as (live_guard, guard_error):
if live_guard is None:
report["error"] = (
"could not acquire exclusive state.db repair ownership; "
"skipped schema surgery to avoid overwriting a concurrent "
f"writer. Stop the gateway and retry: {guard_error}"
)
if guard_error is not None and _repair_failure_consumes_attempt(
guard_error
):
report["_repair_attempted"] = True
logger.error("state.db repair skipped: %s", report["error"])
return report
space_error = _repair_scratch_space_error(db_path)
if space_error is not None:
report["error"] = space_error
logger.error("state.db repair aborted: %s", report["error"])
return report
try:
# Reuse live_guard rather than a second source connection: the
# guard owns the exclusion, and a second connection could be
# blocked by our own EXCLUSIVE lock on some SQLite builds.
_copy_database_snapshot(
db_path, scratch, source_connection=live_guard
)
except (OSError, sqlite3.Error, TimeoutError) as exc:
report["error"] = (
f"could not stage a complete SQLite repair snapshot of {db_path}: {exc}"
)
if _repair_failure_consumes_attempt(exc):
report["_repair_attempted"] = True
logger.error("state.db repair aborted: %s", report["error"])
_unlink_db_triple(scratch)
return report
try:
# Private marker consumed by the outer wrapper: a strategy failure
# consumes the persistent budget, but a later promotion failure is
# classified separately (disk/I/O/permission/lock = environmental).
report["_repair_attempted"] = True
_run_repair_strategies(scratch, report)
if report.get("repaired"):
try:
# Do not os.replace the live DB: Windows rejects replacement
# under open handles and POSIX would leave those handles on
# the old inode. The guard that staged the live image
# receives the promotion, keeping writer exclusion throughout.
_copy_database_snapshot(
scratch,
db_path,
destination_connection=live_guard,
)
except (OSError, sqlite3.Error, TimeoutError) as exc:
report["repaired"] = False
report["strategy"] = None
report["_repair_attempted"] = _repair_failure_consumes_attempt(
exc
)
report["error"] = (
"repaired snapshot could not be promoted transactionally: "
f"{exc}"
)
logger.error("state.db repair promotion failed: %s", exc)
else:
logger.warning(
"state.db repaired via '%s' and promoted transactionally: %s",
report.get("strategy"),
db_path,
)
if not report.get("repaired"):
# Logged HERE, not in the strategies: they see the scratch copy,
# and the one message a human acts on must not name a path
# that no longer exists by the time they read it.
logger.error(
"state.db schema repair could not recover %s automatically "
"(no committed canonical data was modified or lost; backup: %s); "
"manual restore from backup may be required.",
db_path,
report["backup_path"],
)
return report
finally:
# Never leave a half-repaired file beside the DB for a later probe
# or human to mistake for the real thing.
cleanup_error = _unlink_db_triple(scratch)
if cleanup_error is not None:
logger.warning(
"Could not remove state.db repair snapshot after repair: %s",
cleanup_error,
)
def _unlink_db_triple(path: Path) -> Optional[str]:
"""Remove *path* and every SQLite sidecar; return any cleanup failure."""
from hermes_state import _IS_WINDOWS
failures: List[str] = []
for suffix in ("", *_DB_SIDECAR_SUFFIXES):
victim = path if not suffix else path.with_name(path.name + suffix)
for attempt in range(10):
try:
victim.unlink()
break
except FileNotFoundError:
break
except PermissionError as exc:
# Windows may retain a just-closed SQLite handle for a few
# scheduler ticks; bounded retry. A later open still fails
# safely if the handle truly remains live.
if _IS_WINDOWS and attempt < 9:
time.sleep(0.05)
continue
failures.append(f"{victim}: {exc}")
break
except OSError as exc:
failures.append(f"{victim}: {exc}")
break
return "; ".join(failures) or None
def _run_repair_strategies(
db_path: Path, report: Dict[str, Any]
) -> Dict[str, Any]:
"""Escalating repair attempts, applied to *db_path* IN PLACE.
Every strategy mutates its argument, so this is only ever called by
:func:`_repair_state_db_schema_locked` on a scratch copy nothing else
holds open — never on the user's database.
"""
from hermes_state import _db_opens_cleanly, load_fts5_cjk_extension
# ── Strategy 0: rebuild FTS indexes in place (FTS write-corruption) ──
# FTS5 'rebuild' rewrites the index from the content table: the
# least-destructive fix for an index that rejects writes while reads work.
try:
conn = _connect_repair_durable(db_path)
try:
# The cjk index can only be rebuilt with its tokenizer loaded
# (best-effort; a tokenizer-less host skips it below).
load_fts5_cjk_extension(conn)
for table_name in (
"messages_fts", "messages_fts_trigram", "messages_fts_cjk"
):
try:
conn.execute(
f"INSERT INTO {table_name}({table_name}) VALUES('rebuild')"
)
except sqlite3.OperationalError:
# Table absent (FTS disabled / trigram off / cjk not present
# or tokenizer unavailable).
continue
finally:
conn.close()
if _db_opens_cleanly(db_path) is None:
report["repaired"] = True
report["strategy"] = "rebuild_fts"
logger.warning(
"state.db FTS indexes rebuilt in place (schema preserved): %s",
db_path,
)
return report
except sqlite3.DatabaseError as exc:
logger.warning("state.db FTS in-place rebuild pass failed: %s", exc)
# ── Strategy 0.5: rebuild stale B-tree indexes ──
# integrity_check reports "wrong # of entries in index" when a B-tree index
# drifts from its base table; REINDEX rewrites it from the canonical rows.
try:
conn = _connect_repair_durable(db_path)
try:
# REINDEX rewrites every index b-tree; take the barriers now that
# the schema parses, in case the open-time attempt was refused.
_reapply_durability_barriers(conn)
conn.execute("REINDEX")
conn.commit()
finally:
conn.close()
if _db_opens_cleanly(db_path) is None:
report["repaired"] = True
report["strategy"] = "reindex_btree"
logger.warning(
"state.db B-tree indexes rebuilt via REINDEX: %s", db_path
)
return report
except sqlite3.DatabaseError as exc:
logger.warning("state.db REINDEX pass failed: %s", exc)
# ── Strategy 1: de-duplicate sqlite_master (keeps FTS index) ──
try:
conn = _connect_repair_durable(db_path)
try:
conn.execute("PRAGMA writable_schema=ON")
dupes = conn.execute(
"SELECT type, name, COUNT(*) AS c, MIN(rowid) AS keep "
"FROM sqlite_master GROUP BY type, name HAVING c > 1"
).fetchall()
for type_, name, _count, keep in dupes:
conn.execute(
"DELETE FROM sqlite_master "
"WHERE type IS ? AND name IS ? AND rowid <> ?",
(type_, name, keep),
)
if dupes:
_bump_schema_cookie(conn)
conn.execute("PRAGMA writable_schema=OFF")
conn.commit()
finally:
conn.close()
if _db_opens_cleanly(db_path) is None:
report["repaired"] = True
report["strategy"] = "dedup_schema"
logger.warning(
"state.db schema repaired by de-duplicating sqlite_master "
"(FTS index preserved): %s", db_path
)
return report
except sqlite3.DatabaseError as exc:
logger.warning("state.db dedup repair pass failed: %s", exc)
# ── Strategy 2: drop all FTS schema, VACUUM, rebuild on next open ──
# The destructive one, and why this path runs on a scratch copy: on a
# damaged schema b-tree VACUUM silently drops every table hanging off the
# unreadable part (see _repair_state_db_schema_locked).
try:
conn = _connect_repair_durable(db_path)
try:
conn.execute("PRAGMA writable_schema=ON")
conn.execute("DELETE FROM sqlite_master WHERE name LIKE 'messages_fts%'")
_bump_schema_cookie(conn)
conn.execute("PRAGMA writable_schema=OFF")
conn.commit()
# The schema parses now, so the barriers can finally stick — and
# VACUUM rewrites the entire file, the worst operation to lose halfway.
_reapply_durability_barriers(conn)
conn.execute("VACUUM")
finally:
conn.close()
reason = _db_opens_cleanly(db_path)
if reason is None:
report["repaired"] = True
report["strategy"] = "drop_fts_rebuild"
logger.warning(
"state.db schema repaired by dropping FTS schema; indexes "
"will rebuild from messages on next open: %s", db_path
)
return report
report["error"] = reason
except sqlite3.DatabaseError as exc:
report["error"] = str(exc)
# The "could not recover" log lives in the caller: it must name the user's
# database, not the scratch copy.
return report