Review follow-ups on the new `hermes sessions set-journal-mode` verb: - The header probe used os.pread, which does not exist on Windows, while the subparser is registered unconditionally — the command died there with an uncaught AttributeError. It now reads the 20 header bytes through a plain binary open(), and the tests no longer skip on win32. - foreign_state_db_holders() returns [] unconditionally on Windows (no scan), which made the admission gate vacuous: an operator got a silent all-clear and could flip the mode under a running gateway. Windows now refuses outright, naming the reason, overridable only by --force. - Enabling WAL ignored the cross-VM filesystem refusal the runtime enforces (apply_wal_with_fallback). target=wal now refuses on virtiofs/9p, where WAL shared memory silently corrupts. - A --db pointing at a garbage file surfaced a raw sqlite3.DatabaseError traceback even though the header probe had already read not-a-database, and a directory raised IsADirectoryError. Both now bail in the command's own error style; every open/read is guarded. The admission checks that need no I/O live in a pure _refusal() that takes the platform as data, so the Windows and cross-VM invariants are tested without faking sys.platform.
115 lines
5.8 KiB
Python
115 lines
5.8 KiB
Python
"""`hermes sessions set-journal-mode delete|wal` — the offline self-service path for #100896.
|
|
|
|
``database.journal_mode: delete`` can never self-apply on a store that is already WAL:
|
|
``apply_wal_with_fallback`` deliberately never live-downgrades (#68545 — other gateway/cron/worker connections
|
|
may hold uncheckpointed WAL commits). Before this command the only escape hatch was an undocumented hand-run
|
|
``PRAGMA journal_mode=DELETE``. This runs it under the same admission the maintenance paths use: refuse while
|
|
ANY foreign process holds the file or a sidecar (``foreign_state_db_holders``), flip without waiting out openers
|
|
(``_set_journal_mode_no_wait``), then verify the header bytes 18/19 SQLite writes for the mode.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
# SQLite file header: bytes 18 (write version) / 19 (read version) are 1 for rollback-journal, 2 for WAL.
|
|
_HEADER_VERSION_BYTES = {"delete": (1, 1), "wal": (2, 2)}
|
|
_MODE_BY_HEADER_VERSION = {v: k for k, v in _HEADER_VERSION_BYTES.items()}
|
|
|
|
|
|
def _header_mode(db_path: Path) -> str:
|
|
"""Journal mode as the on-disk header reports it (no SQLite connection is open in this process here)."""
|
|
with open(db_path, "rb") as fh:
|
|
head = fh.read(20)
|
|
if len(head) < 20 or head[:16] != b"SQLite format 3\x00":
|
|
return "not-a-database"
|
|
return _MODE_BY_HEADER_VERSION.get((head[18], head[19]), f"unknown({head[18]}/{head[19]})")
|
|
|
|
|
|
def _refusal(target: str, current: str, platform: str, *, on_cross_vm_fs: bool, force: bool) -> Optional[str]:
|
|
"""Admission checks that need no I/O, with the platform passed as data so tests need not fake the host."""
|
|
if current == "not-a-database" or current.startswith("unknown("):
|
|
return (f"its file header reads {current}, so it is not a Hermes SQLite store this command can convert "
|
|
"(pass --db PATH to point at one).")
|
|
if target == "wal" and on_cross_vm_fs:
|
|
return ("WAL shared memory silently corrupts on cross-VM filesystems (virtiofs/9p — Docker Desktop, "
|
|
"OrbStack, Lima bind mounts). Keep this store on journal_mode=delete or move it to a native "
|
|
"filesystem.")
|
|
if platform == "win32" and not force:
|
|
# foreign_state_db_holders() has no Windows scan and returns [] there: an empty holder list is
|
|
# "unknown", never "nobody holds it", so the gate must refuse rather than issue a silent all-clear.
|
|
return ("cannot prove the database is quiet on Windows — no holder scan. Stop the gateway, dashboard, "
|
|
"cron and every CLI, then re-run with --force.")
|
|
return None
|
|
|
|
|
|
def cmd_set_journal_mode(args) -> int:
|
|
from hermes_state import _default_db_path
|
|
from hermes_state_holders import describe_holder_pid, foreign_state_db_holders
|
|
from hermes_state_wal import (_path_on_cross_vm_fs, _set_journal_mode_no_wait, is_sqlite_wal_reset_vulnerable,
|
|
resolve_journal_mode)
|
|
|
|
target = args.mode
|
|
db_path = Path(getattr(args, "db", None) or _default_db_path())
|
|
if not db_path.exists():
|
|
print(f"No database at {db_path} (nothing to convert).")
|
|
return 1
|
|
try:
|
|
current = _header_mode(db_path)
|
|
except OSError as exc:
|
|
print(f"✗ Cannot read {db_path}: {exc}")
|
|
return 1
|
|
if current == target:
|
|
print(f"✓ {db_path} is already journal_mode={target}.")
|
|
return 0
|
|
refusal = _refusal(
|
|
target, current, sys.platform,
|
|
on_cross_vm_fs=target == "wal" and _path_on_cross_vm_fs(str(db_path)),
|
|
force=bool(getattr(args, "force", False)),
|
|
)
|
|
if refusal:
|
|
print(f"✗ Refusing to change the journal mode of {db_path}: {refusal}")
|
|
return 1
|
|
if target == "wal" and is_sqlite_wal_reset_vulnerable():
|
|
print(f"✗ Refusing to enable WAL: the linked SQLite {sqlite3.sqlite_version} has the WAL-reset bug "
|
|
"(https://sqlite.org/wal.html#walresetbug). Upgrade to 3.51.3+ first.")
|
|
return 1
|
|
holders = foreign_state_db_holders(db_path)
|
|
if holders:
|
|
print(f"✗ Refusing to change the journal mode of {db_path}: other processes hold it open "
|
|
"(a live switch would destroy their uncheckpointed commits). Stop them and re-run:")
|
|
for pid, target_path in holders:
|
|
print(f" pid {pid} — {describe_holder_pid(pid) if pid > 0 else 'scan'}: {target_path}")
|
|
return 1
|
|
# timeout=0: any opener that appeared between the scan and the flip makes the pragma fail with
|
|
# 'database is locked' instead of sneaking the switch between a writer's transactions.
|
|
try:
|
|
conn = sqlite3.connect(str(db_path), timeout=0.0, isolation_level=None)
|
|
except (OSError, sqlite3.Error) as exc:
|
|
print(f"✗ Cannot open {db_path}: {exc}")
|
|
return 1
|
|
try:
|
|
try:
|
|
reported = _set_journal_mode_no_wait(conn, target.upper())
|
|
except sqlite3.OperationalError as exc:
|
|
print(f"✗ journal_mode={target.upper()} refused: {exc} (another opener appeared; nothing was changed)")
|
|
return 1
|
|
finally:
|
|
conn.close()
|
|
try:
|
|
after = _header_mode(db_path)
|
|
except OSError as exc:
|
|
print(f"✗ Cannot re-read {db_path} after the switch: {exc}")
|
|
return 1
|
|
if after != target:
|
|
print(f"✗ SQLite reported journal_mode={reported or '?'} but the file header reads {after}; not converted.")
|
|
return 1
|
|
print(f"✓ {db_path}: journal_mode {current} → {after} (header verified).")
|
|
configured = resolve_journal_mode()
|
|
if configured != target:
|
|
print(f" Note: config.yaml has database.journal_mode: {configured} — the next open would switch the file "
|
|
f"back. Set `database.journal_mode: {target}` in config.yaml to keep it.")
|
|
return 0
|