Files
hermes-agent/hermes_state_wal.py
teknium1 8ac45786bf fix(state): SessionDB open waits out a lock lost inside the FTS constructor
In rollback-journal (DELETE) mode a sibling process can take the write lock
between schema load and the messages_fts probe. FTS5's xConnect then fails its
%_config read and SQLite reports SQLITE_BUSY with the text "vtable constructor
failed: messages_fts". Every state.db lock classifier matched on the words
"locked"/"busy", so:

- a writable SessionDB() failed after 1s instead of waiting out the lock with
  _WRITE_PATIENCE_S, and callers disabled persistence for the run;
- a read-only open (dashboard, `hermes sessions list`, cross-profile readers)
  failed on the first busy timeout with no retry at all;
- the error read as not transient (dashboard 500, not 503) and as persistence
  cause "unknown" instead of "locked".

Add hermes_state_errors.is_sqlite_lock_error: SQLITE_BUSY/SQLITE_LOCKED by
result code when SQLite supplies one, text only when it does not (our own
re-raised messages, RPC-wrapped strings). Route the writer open patience loop,
the _execute_write retry, the reconcile re-raise, the WAL->DELETE flip, the
maintenance holder probe, is_transient_sqlite_error and
classify_persistence_error through it. The read-only open retries a lock
inside its existing bounded retry budget, next to the transient IOERR case.
2026-09-23 11:35:07 -07:00

648 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""SQLite journal-mode and PRAGMA policy for state.db (split from hermes_state).
Patchable helpers are looked up as module globals at call time, so tests patch ``hermes_state_wal.<name>``.
"""
from __future__ import annotations
import contextlib
import functools
import logging
import os
import sqlite3
import sys
import threading
import time
from typing import Any, Dict, Optional
from hermes_cli.sqlite_runtime import is_sqlite_wal_reset_vulnerable as _is_sqlite_wal_reset_vulnerable
from hermes_state_errors import is_sqlite_lock_error
# Log-record parity with the origin module (caplog tests pin "hermes_state").
logger = logging.getLogger("hermes_state")
# WAL needs mmap shared memory + fcntl byte-range locks. Network filesystems (NFS, SMB/CIFS, some FUSE, WSL1) raise
# ``locking protocol``; ZFS corrupts the -shm file under concurrent bursts (COW + mmap) -> ``disk I/O error``.
# Either would silently break everything on state.db/kanban.db, so fall back to DELETE (readers block on writes).
# "not authorized": some FUSE mounts block the WAL pragma outright.
_WAL_INCOMPAT_MARKERS = ("locking protocol", "not authorized", "disk i/o error")
# SQLite's default journal_size_limit is -1 (unlimited); see _apply_wal_size_limit.
_WAL_SIZE_LIMIT_BYTES = 64 * 1024 * 1024 # 64 MiB
# Once-per-process-per-db_label dedup sets (kanban_db.connect() runs on every kanban operation, so an undeduped
# line would repeat per connection). Tests clear these via ``hermes_state_wal.<name>``.
_wal_fallback_warned_paths: set[str] = set()
_wal_fallback_warned_lock = threading.Lock()
# Dedup for the both-pragmas-failed WARNING (WAL *and* DELETE rejected, e.g. APFS external SSDs under contention).
_wal_delete_fallback_failed_paths: set[str] = set()
_wal_delete_fallback_failed_lock = threading.Lock()
# Dedup for the probe-unknown WARNING (on-disk journal mode unreadable, nothing touched).
_wal_probe_unknown_paths: set[str] = set()
_wal_probe_unknown_lock = threading.Lock()
_wal_reset_bug_warned_paths: set[str] = set()
_wal_reset_bug_warned_lock = threading.Lock()
_delete_overridden_warned_paths: set[str] = set()
_delete_overridden_warned_lock = threading.Lock()
_journal_upgrade_warned_paths: set = set()
_journal_upgrade_warned_lock = threading.Lock()
_CANNOT_VERIFY_DELETE_MSG = ("could not verify journal mode before applying configured journal_mode=delete (database "
"is locked — possible concurrent openers); refusing to downgrade a database this process "
"does not exclusively own")
def _mode_from_row(row) -> str:
"""Lower-cased mode from a ``PRAGMA journal_mode`` row, ``""`` if no row."""
return str(row[0]).strip().lower() if row and row[0] is not None else ""
def _on_disk_journal_mode(conn: sqlite3.Connection) -> Optional[str]:
"""Read the journal mode from the DB header; ``None`` if undeterminable (new DB, or PRAGMA failed) ->
callers take their fail-closed "refuse to downgrade" branch. ``disk i/o error`` can be transient on
virtualized block devices (XFS on cloud hosts), so it is retried a few times first."""
for _ in range(4):
try:
row = conn.execute("PRAGMA journal_mode").fetchone()
except sqlite3.OperationalError as exc:
if "disk i/o error" not in str(exc).lower():
return None
last_exc = exc
time.sleep(0.05)
continue
mode = row[0] if row else None
if isinstance(mode, bytes): # defensive: sqlite3 occasionally returns bytes
try:
mode = mode.decode("ascii")
except UnicodeDecodeError:
return None
return str(mode).strip().lower() if mode is not None else None
logger.debug("_on_disk_journal_mode: retries exhausted on disk read (%s)", last_exc)
return None
def _apply_wal_size_limit(conn: sqlite3.Connection) -> None:
"""Bound the WAL so it returns space after big transactions. With the default (-1) a checkpointed WAL is
reused in place, never truncated, so ``state.db-wal`` keeps the high-water mark of the largest
transaction ever (a 3 GB optimize left a 3 GB WAL). Best-effort: failure only costs disk slack."""
try:
conn.execute(f"PRAGMA journal_size_limit={_WAL_SIZE_LIMIT_BYTES}")
except sqlite3.OperationalError as exc: # pragma: no cover - defensive
logger.debug("journal_size_limit not applied: %s", exc)
def _darwin_pragma(conn: sqlite3.Connection, pragma: str) -> None:
"""Best-effort PRAGMA on macOS only (no-op elsewhere, never raises)."""
if sys.platform == "darwin":
with contextlib.suppress(sqlite3.OperationalError):
conn.execute(pragma)
def _apply_macos_checkpoint_barrier(conn: sqlite3.Connection) -> None:
"""Enable ``PRAGMA checkpoint_fullfsync`` on macOS. Apple's ``fsync(2)`` guarantees neither data-on-platter nor
ordering, so without ``F_FULLFSYNC`` a launchd shutdown can turn a "durable" checkpoint into a malformed
``state.db``. Checkpoint boundaries only (~+0.1 ms/commit vs ~+4 ms for ``fullfsync=1``).
During a launchd *system* shutdown/reboot the OS page cache is dropped (effectively a power-loss event
for in-flight pages), so a WAL checkpoint whose ``fsync()`` "reported" durable may never have hit the
platter — corrupting ``state.db`` with a malformed image. This is the trigger in issue #30636 ("SIGTERM
during launchd shutdown under high load"), distinct from a plain in-session kill (which the page cache
survives and SQLite recovers from).
"""
_darwin_pragma(conn, "PRAGMA checkpoint_fullfsync=1")
def _enforce_macos_synchronous_full(conn: sqlite3.Connection) -> None:
"""Enforce ``PRAGMA synchronous=FULL`` on macOS: with NORMAL a WAL checkpoint
racing process termination leaves half-written btree pages (``btreeInitPage
error 11``). Called after every WAL activation so a prior NORMAL never sticks."""
_darwin_pragma(conn, "PRAGMA synchronous=FULL")
def _apply_wal_companions(conn: sqlite3.Connection) -> None:
"""The settings every WAL activation carries: size limit + macOS barriers."""
_apply_wal_size_limit(conn)
_apply_macos_checkpoint_barrier(conn)
_enforce_macos_synchronous_full(conn)
def is_sqlite_wal_reset_vulnerable(version_info: Optional[tuple] = None) -> bool:
"""True when the linked SQLite has the WAL-reset bug (3.7.0–3.51.2; fixed 3.51.3+, backports 3.50.7 /
3.44.6). Pre-WAL libraries are safe. https://sqlite.org/wal.html#walresetbug"""
return _is_sqlite_wal_reset_vulnerable(sqlite3.sqlite_version_info if version_info is None else version_info)
def sqlite_source_id() -> str:
"""Return ``sqlite_source_id()``, or an empty string when unavailable."""
try:
with contextlib.closing(sqlite3.connect(":memory:")) as conn:
row = conn.execute("SELECT sqlite_source_id()").fetchone()
except sqlite3.Error:
return ""
return str(row[0]) if row and row[0] is not None else ""
def _database_has_content(conn: sqlite3.Connection) -> bool:
"""Whether the file already holds pages (existing vs brand-new DB); lock-free header read. Fail-quiet False: the
only caller gates a warning on this and an unknown-answer warning would fire on every fresh database."""
try:
row = conn.execute("PRAGMA page_count").fetchone()
return bool(row) and row[0] is not None and int(row[0]) > 0
except (sqlite3.Error, TypeError, ValueError):
return False
def resolve_journal_mode() -> str:
"""The configured ``database.journal_mode`` (``wal`` default; ``delete`` for filesystems without WAL-safe
durability: macOS virtiofs, NFS, SMB). Invalid values fail safe to ``wal``."""
try:
from hermes_cli.config import load_config_readonly
database = (load_config_readonly() or {}).get("database", {})
raw = database.get("journal_mode", "wal") if isinstance(database, dict) else "wal"
except Exception:
return "wal"
mode = raw.strip().lower() if isinstance(raw, str) else ""
return mode if mode in ("wal", "delete") else "wal"
class WalUnsupportedError(sqlite3.OperationalError):
"""Raised by :func:`apply_wal_with_fallback` under ``require_wal=True`` when
the filesystem cannot provide WAL (SQLITE_PROTOCOL raised, or macOS-NFS silent
refusal) or the on-disk mode cannot be verified (probe blocked by a concurrent
opener). Subclasses ``OperationalError`` so DB-init handlers still catch it."""
# Docker Desktop / OrbStack / Podman expose host bind mounts to the guest as ``fuse.virtiofs`` or ``9p``. WAL's
# -shm file must be coherent shared memory for every opener; across the VM boundary it is not, and under write
# pressure the failure is SILENT (zero-filled pages), so the reactive ``_WAL_INCOMPAT_MARKERS`` fallback in
# ``_enable_wal`` never gets a signal — WAL must be refused before the pragma. Port of openclaw/openclaw#120597.
# Detection is Linux ``/proc/self/mountinfo`` only (statvfs carries no fstype); elsewhere, or when the table is
# unreadable, the answer is False and behaviour is unchanged. Nothing else (ext4/btrfs/xfs/zfs/tmpfs/overlay/nfs)
# is ever flagged here — those keep the existing reactive paths.
_CROSS_VM_FSTYPES = frozenset({"virtiofs", "fuse.virtiofs", "9p", "9p2000", "9p2000.l", "9p2000.u"})
_cross_vm_fs_cache: Dict[str, bool] = {} # per DB directory; kanban_db.connect() opens per operation
_cross_vm_fs_cache_lock = threading.Lock()
_cross_vm_warned_paths: set[str] = set()
_cross_vm_warned_lock = threading.Lock()
_cross_vm_existing_wal_warned_paths: set[str] = set()
_cross_vm_existing_wal_warned_lock = threading.Lock()
def _mountinfo_fstype(directory: str, mountinfo_path: str = "/proc/self/mountinfo") -> str:
"""fstype of the longest mount point that is a prefix of ``directory`` (``""`` if unreadable / no match)."""
try:
with open(mountinfo_path, "r", encoding="utf-8", errors="replace") as fh:
lines = fh.read().splitlines()
except OSError:
return ""
best_len, best_fstype = -1, ""
for line in lines:
# proc(5): <id> <parent> <maj:min> <root> <mount point> <opts> [optional...] - <fstype> <source> <super opts>
fields, _, tail = line.partition(" - ")
parts = fields.split()
if len(parts) < 5 or not tail:
continue
mount_point = parts[4]
if "\\" in mount_point: # octal escapes (\040 = space)
mount_point = mount_point.encode("latin-1", "ignore").decode("unicode_escape")
if directory == mount_point or directory.startswith(mount_point.rstrip("/") + "/"):
if len(mount_point) > best_len:
best_len, best_fstype = len(mount_point), tail.split()[0]
return best_fstype.lower()
def _detect_cross_vm_fs(directory: str, mountinfo_path: str = "/proc/self/mountinfo") -> bool:
"""True only when ``directory`` sits on a virtiofs/9p mount per ``mountinfo_path``."""
if sys.platform != "linux":
return False
return _mountinfo_fstype(directory, mountinfo_path) in _CROSS_VM_FSTYPES
def _path_on_cross_vm_fs(path: str) -> bool:
"""True when ``path`` resides on a virtiofs/9p (cross-VM) filesystem; cached per resolved directory."""
try:
directory = os.path.dirname(os.path.realpath(path)) or "/"
except (OSError, ValueError):
return False
with _cross_vm_fs_cache_lock:
cached = _cross_vm_fs_cache.get(directory)
if cached is None:
cached = _detect_cross_vm_fs(directory)
with _cross_vm_fs_cache_lock:
_cross_vm_fs_cache[directory] = cached
return cached
def _connection_db_file(conn: sqlite3.Connection) -> str:
"""Filesystem path of the ``main`` database, ``""`` for in-memory / unknown."""
try:
row = conn.execute("PRAGMA database_list").fetchone()
return str(row[2]) if row and row[2] else ""
except (sqlite3.OperationalError, IndexError, TypeError):
return ""
def _verify_configured_delete(actual: str) -> str:
"""Raise unless SQLite reported ``delete`` for an explicit operator request."""
if actual != "delete":
raise sqlite3.OperationalError(f"could not set configured journal_mode=delete (got {actual or 'no result'})")
return actual
def apply_wal_with_fallback(conn: sqlite3.Connection, *, db_label: str = "state.db", require_wal: bool = False) -> str:
"""Set ``journal_mode=WAL`` on ``conn``, falling back to DELETE on failure.
Returns the mode actually set — or, when the read-only mode probe is blocked by a concurrent opener, ``"wal"``
as the assumed mode with nothing touched (``require_wal=True`` raises instead). Shared by :class:`SessionDB`
and ``hermes_cli.kanban_db_connect.connect``. WAL-incompatible filesystems either raise ``OperationalError`` ("locking protocol" / "disk I/O error") or —
macOS NFS / SMB / AgentFS — silently refuse and stay in DELETE; either way log ERROR once per process per
``db_label`` and fall back. ``require_wal=True`` raises :class:`WalUnsupportedError` instead. WAL-reset-bug
builds (https://sqlite.org/wal.html#walresetbug) never enable WAL on non-WAL files; an already-WAL DB keeps WAL
with a warning. Gate deliberately RETAINED: re-measured on the bundled 3.50.4 there is no evidence WAL is safer.
Invariant on every path: never downgrade to DELETE if the on-disk header reports WAL or cannot be read — other
gateway/cron/worker connections may hold the DB open, and a live downgrade destroys their uncheckpointed
commits.
An earlier revision of the lock-cancellation fix (#71724) reverted it on the theory that DELETE was "the
mode that corrupts", but that comparison was confounded: the clean WAL result came from SQLite 3.53.1,
which carries BOTH the WAL-reset fix AND 3.51.0's defenses against close()-broken POSIX locks, so it
says nothing about 3.50.4. Re-measured on the actually-bundled 3.50.4 with the lock fix in place, WAL
and DELETE are both clean (0/3 each) — i.e. there is no evidence that WAL is safer here, and upstream
still documents the WAL-reset bug as real through 3.51.2 with serious consequences. Until a fixed
runtime is delivered, keep new databases out of WAL.
"""
configured = resolve_journal_mode()
# Vulnerable SQLite: never enable WAL on non-WAL files (configured mode resolved first so an explicit DELETE
# request is still verified).
if is_sqlite_wal_reset_vulnerable():
return _apply_delete_for_wal_reset_bug(conn, db_label=db_label, require_delete=configured == "delete")
# Read-only probe (no flock/checkpoint/WAL-SHM unlink): WAL-init must not unlink files other connections hold.
current_mode = _on_disk_journal_mode(conn)
if current_mode == "wal":
if configured == "delete":
# Never-live-downgrade keeps WAL; tell the operator their delete did not apply.
_log_configured_delete_overridden_once(db_label)
_warn_existing_wal_on_cross_vm_fs(conn)
_apply_wal_companions(conn)
return "wal"
if configured == "delete":
# #68545: honor the canonical database.journal_mode setting. Existing on-disk WAL databases were
# returned above and are never live-downgraded.
if current_mode is None:
# Probe failed (locked/busy): ownership not provably exclusive. Fail loudly.
raise sqlite3.OperationalError(_CANNOT_VERIFY_DELETE_MSG)
return _verify_configured_delete(_set_journal_mode_no_wait(conn, "DELETE"))
if current_mode is None:
# Probe failed (locked/busy): ownership not provably exclusive, same as the DELETE branch above. A fresh
# 0-page DB probes "delete" cleanly, so this is a real file some other connection holds — running WAL-init
# would unlink its -wal/-shm sidecars. Touch nothing: the connection inherits whatever mode the header has.
if require_wal:
raise WalUnsupportedError("could not verify the on-disk journal mode (database is locked — possible "
"concurrent openers); cannot guarantee WAL")
_log_once("wal_probe_unknown", db_label)
return "wal"
# Cross-VM bind mount (virtiofs/9p): WAL corrupts silently there, so refuse to ENABLE it. On-disk WAL
# databases were returned above (never live-downgrade); a 0-page / DELETE file just stays DELETE.
db_file = _connection_db_file(conn)
if db_file and _path_on_cross_vm_fs(db_file):
if require_wal:
raise WalUnsupportedError("journal_mode=WAL refused: database is on a cross-VM filesystem (virtiofs/9p) "
"where WAL shared-memory silently corrupts")
_log_once("cross_vm_fs", db_label)
return _set_journal_mode_no_wait(conn, "DELETE") or "delete"
return _enable_wal(conn, db_label, require_wal, current_mode)
def _enable_wal(conn: sqlite3.Connection, db_label: str, require_wal: bool, current_mode: Optional[str]) -> str:
"""Flip a non-WAL, non-vulnerable connection to WAL, or fall back to DELETE."""
# Decide BEFORE the flip whether it overwrites a mode somebody chose (probe and page_count are only readable
# while the file is untouched). A 0-page DB has no prior choice, and every caller reaches this before schema.
upgrading_existing_db = current_mode is not None and current_mode != "wal" and _database_has_content(conn)
def _wal_activated() -> str:
if upgrading_existing_db:
_log_journal_mode_upgrade_once(db_label, current_mode)
_apply_wal_companions(conn)
return "wal"
try:
# ``PRAGMA journal_mode=WAL`` RETURNS the resulting mode: macOS NFS, SMB/CIFS and the AgentFS overlay
# refuse WITHOUT raising. Trust the row, not the absence of an exception.
mode = _mode_from_row(conn.execute("PRAGMA journal_mode=WAL").fetchone())
if mode == "wal":
return _wal_activated()
silent_exc = WalUnsupportedError(f"journal_mode=WAL refused without raising (still {mode!r})")
if require_wal:
raise silent_exc
_log_wal_fallback_once(db_label, silent_exc)
return mode or "delete"
except WalUnsupportedError:
raise # the require_wal silent-refusal raise above — propagate unchanged
except sqlite3.OperationalError as exc:
msg = str(exc).lower()
if not any(marker in msg for marker in _WAL_INCOMPAT_MARKERS):
raise # unrelated OperationalError — don't silently swallow
# ``disk i/o error`` is ambiguous: on ZFS / APFS-CoW it is a deterministic WAL-incompatibility (SHM
# corruption under concurrent connection bursts — #55305, #71498), but it can also be a one-shot
# transient EIO (page-cache pressure, brief lock contention). Treating a transient EIO as a
# permanent downgrade signal produced the mixed-journal-mode corruption pattern fixed in 5c49cd0ed0
# (process A downgrades to DELETE while sibling processes set WAL). Disambiguate by retrying the
# pragma a couple of times: transient EIO clears and we return "wal"; the deterministic filesystem
# cases keep failing and fall through to the guarded DELETE fallback.
if "disk i/o error" in msg:
# Retry twice: EIO is either deterministic WAL-incompatibility (ZFS / APFS-CoW) or a one-shot transient,
# and treating a transient as a permanent downgrade produced mixed-mode corruption (A downgrades to
# DELETE while siblings set WAL). A non-EIO retry error propagates.
for _ in range(2):
time.sleep(0.05)
try:
row = conn.execute("PRAGMA journal_mode=WAL").fetchone()
except sqlite3.OperationalError as retry_exc:
if "disk i/o error" not in str(retry_exc).lower():
raise
exc = retry_exc
continue
if _mode_from_row(row) == "wal":
return _wal_activated()
break
# Never downgrade if WAL is on disk or the mode cannot be read (probe blocked by a concurrent opener) —
# ownership is not provably exclusive either way.
if _on_disk_journal_mode(conn) in ("wal", None):
raise
if require_wal:
raise WalUnsupportedError(str(exc)) from exc
_log_wal_fallback_once(db_label, exc)
try:
_set_journal_mode_no_wait(conn, "DELETE")
except sqlite3.OperationalError as delete_exc:
# Filesystems that reject BOTH pragmas (APFS external SSDs under heavy contention raise
# "disk I/O error" on DELETE too). The connection keeps its current mode — typically the
# SQLite default DELETE — and stays usable for reads/writes, so propagating would crash
# every DB-init caller (SessionDB, kanban_db, ResponseStore) for no benefit. Log once
# per db_label and return the mode actually in effect, read back rather than guessed.
_log_once("wal_delete_fallback_failed", db_label, exc, delete_exc)
try:
read_back = _mode_from_row(conn.execute("PRAGMA journal_mode").fetchone())
except sqlite3.OperationalError:
read_back = ""
return read_back or "delete"
return "delete"
def _set_journal_mode_no_wait(conn: sqlite3.Connection, mode: str) -> str:
"""Execute ``PRAGMA journal_mode=<mode>`` without waiting on other openers.
The ONLY place a non-WAL journal-mode switch may be issued. ``busy_timeout=0`` turns SQLite's exclusivity
requirement into a concurrent-opener detector: leaving WAL needs exclusive access, so if ANY other connection
holds the DB the pragma fails immediately with ``database is locked`` instead of sneaking the flip between a
writer's transactions (how uncheckpointed WAL commits die). Callers must treat a raised ``OperationalError`` as
"not exclusively owned: leave the mode alone", never as retryable. Returns the reported mode, ``""`` if no row."""
try:
row = conn.execute("PRAGMA busy_timeout").fetchone()
previous_timeout = int(row[0]) if row and row[0] is not None else 0
except (sqlite3.OperationalError, TypeError, ValueError):
previous_timeout = 0
conn.execute("PRAGMA busy_timeout=0")
try:
return _mode_from_row(conn.execute(f"PRAGMA journal_mode={mode}").fetchone())
finally:
with contextlib.suppress(sqlite3.OperationalError):
conn.execute(f"PRAGMA busy_timeout={previous_timeout}")
def _warn_existing_wal_on_cross_vm_fs(conn: sqlite3.Connection) -> None:
"""#110848: the fresh-DB cross-VM refusal never sees a DB that was already WAL before the check existed (or was
created on a native volume and then moved). Keeping WAL is right (never live-downgrade); staying silent is not.
Both the vulnerable-SQLite and the regular already-WAL paths return early, so both must call this."""
db_file = _connection_db_file(conn)
if db_file and _path_on_cross_vm_fs(db_file):
# Keyed (and labelled) by path, not db_label: a gateway serving several profiles must hear about each one.
_log_once("cross_vm_fs_existing_wal", db_file)
def _apply_delete_for_wal_reset_bug(conn: sqlite3.Connection, *, db_label: str, require_delete: bool = False) -> str:
"""Avoid enabling WAL when the linked SQLite has the WAL-reset bug.
Already-WAL on disk: keep WAL (no live downgrade) and warn. Mode unreadable (probe blocked by a concurrent
opener): not provably exclusive — leave it and warn; treating "could not read" as "not WAL" once flipped a live
WAL state.db to DELETE under a writer, destroying its uncheckpointed commits. Otherwise set DELETE without
waiting out openers and warn; an explicit operator request additionally verifies SQLite accepted DELETE."""
current = _on_disk_journal_mode(conn)
if current == "wal":
_log_wal_reset_bug_once(db_label, kept_wal=True)
if require_delete:
# Upgrading SQLite doesn't help here; emit the actionable message last.
_log_configured_delete_overridden_once(db_label)
_warn_existing_wal_on_cross_vm_fs(conn)
_apply_wal_companions(conn)
return "wal"
if current is None:
if require_delete:
raise sqlite3.OperationalError(_CANNOT_VERIFY_DELETE_MSG)
_log_wal_reset_bug_once(db_label, kept_wal=True, indeterminate=True)
return "wal"
actual = ""
try:
actual = _set_journal_mode_no_wait(conn, "DELETE")
except sqlite3.OperationalError as exc:
if require_delete:
raise
if is_sqlite_lock_error(exc):
# A concurrent opener appeared between probe and flip: leave the mode as is.
_log_wal_reset_bug_once(db_label, kept_wal=True, indeterminate=True)
return current or "delete"
# Best-effort otherwise: DELETE is already the default for new file-backed DBs.
if require_delete:
_verify_configured_delete(actual)
_log_wal_reset_bug_once(db_label, kept_wal=False)
return "delete"
def _wal_reset_repair_hint() -> str:
"""Repair hint matching what ``hermes update`` can actually do for this install type.
See #75153.
"""
try:
from hermes_cli.config import detect_install_method, get_project_root, recommended_update_command_for_method
method = detect_install_method(get_project_root())
cmd = recommended_update_command_for_method(method)
if method in {"git", "unknown"}:
return f"Hermes-managed installs can repair the embedded runtime with `{cmd}`"
return f"update the container image with `{cmd}`" if method == "docker" else cmd # else nix/nixos
except Exception:
return "install a Python build bundled with SQLite 3.51.3+ (or backports 3.50.7 / 3.44.6) and restart Hermes"
# Once-per-(process, db_label) log table. Levels are deliberate: falling back to DELETE and an ignored
# ``journal_mode: delete`` are real losses (ERROR); a non-WAL -> WAL flip is normally desirable (WARNING).
_WAL_RESET_BUG_ACTIONS = {
"indeterminate": ("journal mode could not be verified or exclusively switched (database is locked — possible "
"concurrent openers); leaving the journal mode untouched (no live downgrade under concurrent "
"openers)"),
"kept_wal": "is already in WAL mode — leaving WAL in place (no live downgrade under concurrent openers)",
"delete": "using journal_mode=DELETE instead of enabling WAL",
}
_ONCE_LOGS = {
"wal_reset_bug": (_wal_reset_bug_warned_lock, "_wal_reset_bug_warned_paths", logging.WARNING,
# Install-type-aware so the warning never promises a repair path that doesn't exist for git/pip installs.
"%s: linked SQLite %s (interpreter %s) is vulnerable to the WAL-reset corruption bug "
"(https://sqlite.org/wal.html#walresetbug) — %s. Upgrade to SQLite 3.51.3+ (or backports 3.50.7 / 3.44.6); "
"%s. See `hermes doctor`. This warning fires once per process per database."),
"journal_upgrade": (_journal_upgrade_warned_lock, "_journal_upgrade_warned_paths", logging.WARNING,
# journal_mode is a property of the FILE: switching an existing DB to WAL rewrites its header and outlives
# the process. Operators set DELETE on the file (the WAL-reset-bug mitigation) and nothing told them the
# next open would silently put WAL back.
"%s: on-disk journal_mode was %s and has been switched to WAL. This rewrites the database header and "
"persists after this process exits. If %s was a deliberate choice (for example the mitigation for the SQLite "
"WAL-reset bug, or a WAL-unsafe filesystem), setting it with PRAGMA on the file will not survive -- every "
"open re-applies the configured mode. Set `database.journal_mode: delete` in config.yaml to make it stick. "
"This message fires once per process per database."),
"wal_fallback": (_wal_fallback_warned_lock, "_wal_fallback_warned_paths", logging.ERROR,
# Under kanban dispatcher + workers a DELETE-mode write blocks readers as SQLITE_BUSY.
"%s: WAL journal_mode unsupported on this filesystem (%s) — falling back to journal_mode=DELETE (slower "
"rollback-journal mode; reduces concurrency but works on NFS/SMB/FUSE/ZFS). See "
"https://www.sqlite.org/wal.html for details. This message fires once per process per database."),
"wal_delete_fallback_failed": (_wal_delete_fallback_failed_lock, "_wal_delete_fallback_failed_paths", logging.WARNING,
# Both pragmas rejected (observed on APFS external SSDs under heavy contention): the connection keeps
# whatever mode it has (typically the SQLite default DELETE) and stays usable for reads/writes, so this
# is WARNING, not ERROR — propagating would crash every DB-init caller. Deduped per db_label because
# kanban_db.connect() runs on every kanban operation.
"%s: both WAL and DELETE journal_mode failed (WAL: %s; DELETE: %s) — continuing with the connection's "
"current journal mode (reads/writes still work). Typically seen on APFS external SSDs under heavy "
"contention. This message fires once per process per database."),
"delete_overridden": (_delete_overridden_warned_lock, "_delete_overridden_warned_paths", logging.ERROR,
# Never-live-downgrade keeps WAL; without this the operator never learns their delete had no effect.
"%s: database.journal_mode=delete is configured but the on-disk database is already WAL; keeping WAL (a live "
"downgrade under open connections can corrupt the DB). To apply journal_mode=DELETE, stop every Hermes "
"process using this database and run `hermes sessions set-journal-mode delete` (add `--db PATH` for a "
"store other than state.db). This message fires once per process per database."),
"wal_probe_unknown": (_wal_probe_unknown_lock, "_wal_probe_unknown_paths", logging.WARNING,
# WARNING, not ERROR: the connection inherits the header's mode, so an already-WAL file (the common case)
# keeps working; only a true-DELETE file stays DELETE for this connection.
"%s: could not verify the on-disk journal mode (database is locked / busy); not issuing a journal-mode "
"set-pragma while another connection may hold the file (it could unlink the -wal/-shm sidecars that "
"connection still uses). Leaving the file untouched; this connection inherits the on-disk mode. This message fires once per process per database."),
"cross_vm_fs": (_cross_vm_warned_lock, "_cross_vm_warned_paths", logging.WARNING,
# The DB keeps working in DELETE mode; the loss is concurrency, so WARNING with the actionable fix.
"%s: database directory is on a cross-VM filesystem (virtiofs/9p — typical for Docker Desktop / OrbStack / "
"Podman host bind mounts). SQLite WAL shared-memory is not coherent across the VM boundary and can silently "
"corrupt the database, so journal_mode=DELETE is used instead. To restore WAL concurrency, move the database "
"onto a native volume (e.g. a named Docker volume) instead of a host bind mount. This message fires once per "
"process per database."),
"cross_vm_fs_existing_wal": (_cross_vm_existing_wal_warned_lock, "_cross_vm_existing_wal_warned_paths", logging.ERROR,
# ERROR, unlike the fresh-DB refusal: this database IS running WAL on the corrupting filesystem and only the
# operator can fix it (a live downgrade under other openers would destroy their uncheckpointed commits).
"%s: existing WAL-mode database is on a cross-VM filesystem (virtiofs/9p — typical for Docker Desktop / "
"OrbStack / Podman host bind mounts). SQLite WAL shared-memory is not coherent across the VM boundary and "
"concurrent writers can silently corrupt the database. Hermes does not live-downgrade an on-disk WAL database. "
"Fix one of two ways: stop every Hermes process using this database and run `hermes sessions "
"set-journal-mode delete` (set `database.journal_mode: delete` in config.yaml to keep it), "
"or move the database onto a native volume (e.g. a named Docker volume). This message fires once per process "
"per database."),
}
def _log_once(kind: str, db_label: str, *args: Any) -> None:
"""Emit ``_ONCE_LOGS[kind]`` once per (process, db_label). Callable *args* are
resolved only after the dedupe check, so install-method probes run once."""
lock, set_name, level, message = _ONCE_LOGS[kind]
seen = globals()[set_name]
with lock:
if db_label in seen:
return
seen.add(db_label)
logger.log(level, message, db_label, *(a() if callable(a) else a for a in args))
def _log_wal_reset_bug_once(db_label: str, *, kept_wal: bool, indeterminate: bool = False) -> None:
"""Log once per (process, db_label) about the WAL-reset vulnerability path."""
action = _WAL_RESET_BUG_ACTIONS["indeterminate" if indeterminate else "kept_wal" if kept_wal else "delete"]
_log_once("wal_reset_bug", db_label, sqlite3.sqlite_version, sys.executable, action, _wal_reset_repair_hint)
def _log_journal_mode_upgrade_once(db_label: str, previous_mode: str) -> None:
"""Single WARNING per (process, db_label) about a non-WAL -> WAL flip."""
_log_once("journal_upgrade", db_label, previous_mode, previous_mode)
# Single ERROR per (process, db_label): WAL fallback / configured delete ignored (DB already WAL).
_log_wal_fallback_once = functools.partial(_log_once, "wal_fallback")
_log_configured_delete_overridden_once = functools.partial(_log_once, "delete_overridden")
# Operators write synchronous as a name; mapped so a typo becomes a warning, not a silently different level.
_SYNCHRONOUS_LEVELS: Dict[str, int] = {"OFF": 0, "NORMAL": 1, "FULL": 2, "EXTRA": 3}
_SYNCHRONOUS_NAMES: Dict[int, str] = {v: k for k, v in _SYNCHRONOUS_LEVELS.items()}
_SYNCHRONOUS_FULL = 2
def resolve_synchronous_level(raw_value: Any) -> Optional[int]:
"""Map ``database.synchronous`` (``OFF``/``NORMAL``/``FULL``/``EXTRA`` any
case, or ``0``-``3``) to its PRAGMA integer; None for anything else so the
caller warns and leaves the level untouched (guessing at durability is worse)."""
if isinstance(raw_value, bool):
# bool is an int subclass and YAML turns bare `on`/`off` into one; "off" is a real choice, True meaningless.
return 0 if raw_value is False else None
if isinstance(raw_value, int):
return raw_value if raw_value in _SYNCHRONOUS_NAMES else None
text = str(raw_value).strip()
if text.upper() in _SYNCHRONOUS_LEVELS:
return _SYNCHRONOUS_LEVELS[text.upper()]
with contextlib.suppress(TypeError, ValueError):
return int(text) if int(text) in _SYNCHRONOUS_NAMES else None
return None
def _apply_synchronous_pragma(conn: sqlite3.Connection, raw_value: Any, *, db_label: str) -> None:
"""Set ``PRAGMA synchronous`` from config, never below FULL on macOS.
Kept out of the integer loop in :func:`apply_database_pragmas`: this PRAGMA decides whether a commit is on
the platter, so an unrecognised value must not fall through to "SQLite default" the way a bad
``cache_size`` can. Darwin floor: this runs after :func:`_enforce_macos_synchronous_full`, so a configured
``NORMAL`` would silently undo the btree protection — raising is allowed, lowering is refused out loud."""
if (level := resolve_synchronous_level(raw_value)) is None:
logger.warning("%s: ignoring unrecognized database.synchronous=%r (expected OFF, NORMAL, FULL, EXTRA, or 0-3)",
db_label, raw_value)
return
if sys.platform == "darwin" and level < _SYNCHRONOUS_FULL:
logger.warning("%s: refusing database.synchronous=%s on macOS; keeping FULL. Darwin's fsync() does not "
"guarantee write ordering, so a lower level readmits the half-written btree pages FULL exists "
"to prevent.", db_label, _SYNCHRONOUS_NAMES[level])
return
with contextlib.suppress(sqlite3.OperationalError):
conn.execute(f"PRAGMA synchronous={level}")
def apply_database_pragmas(conn: sqlite3.Connection, *, db_label: str = "state.db") -> None:
"""Apply optional performance and WAL-sizing PRAGMAs from ``config.yaml``.
Journal mode is NOT handled here (owned by :func:`apply_wal_with_fallback`). ``database:`` keys: ``cache_size``
(negative = KiB, positive = pages), ``mmap_size`` (bytes, 0 = off), ``temp_store`` (0-3), ``wal_autocheckpoint``
(pages), ``journal_size_limit`` (bytes), ``synchronous`` (unset leaves the compile-time default, which differs
between bundled/distro/Homebrew builds). Best-effort: failures are ignored so DB init never breaks on a
malformed section. Applied to ALL connection types: writer, read_only, WAL readers."""
try:
from hermes_cli.config import cfg_get, load_config_readonly # local: avoids a circular import
cfg = load_config_readonly()
except Exception:
return
for pragma_name in ("cache_size", "mmap_size", "temp_store", "wal_autocheckpoint", "journal_size_limit"):
if (raw_value := cfg_get(cfg, "database", pragma_name, default=None)) 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
with contextlib.suppress(sqlite3.OperationalError):
conn.execute(f"PRAGMA {pragma_name}={value}")
# Last: sizing pragmas cannot change durability (see _apply_synchronous_pragma).
if (raw_synchronous := cfg_get(cfg, "database", "synchronous", default=None)) is not None:
_apply_synchronous_pragma(conn, raw_synchronous, db_label=db_label)