Retiring the win32 gate left `--force` with no reader, so on a Windows host where the Restart Manager cannot start a session (restricted/service context) the fail-closed `(-1, scan failed)` sentinel made `set-journal-mode` permanently unrunnable, while optimize/optimize-storage/prune kept a working override. `--force` now drops only pid <= 0 sentinel entries — a process the scan actually found is still refused — and the help text says exactly that. The holder test parametrised over `force` now asserts something real: force plus a live holder is refused, force plus a failed scan proceeds (and without force the failed scan is refused). Rewrite the user-guide paragraph that still described a POSIX-only scan and a no-scan Windows path. Drop the dead `import time`. The doctor holder test compares the child-reported pid instead of `Popen.pid`, which is the venv launcher on Windows, so un-skipping it there does not assert a pid equality that cannot hold.
111 lines
5.5 KiB
Python
111 lines
5.5 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
|
|
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, *, on_cross_vm_fs: bool) -> Optional[str]:
|
|
"""Admission checks independent of holder discovery, which is enforced for every platform."""
|
|
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.")
|
|
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, on_cross_vm_fs=target == "wal" and _path_on_cross_vm_fs(str(db_path))
|
|
)
|
|
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 getattr(args, "force", False):
|
|
# Same override the optimize/prune dispatcher honours, narrowed: --force waives only an incomplete
|
|
# scan (the pid <= 0 sentinel), never a process the scan actually found.
|
|
holders = [holder for holder in holders if holder[0] > 0]
|
|
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
|