fix(state): per-profile store housekeeping under one multiplexed process
Three silent data-correctness bugs on a host where ONE process serves every profile: - The gateway constructor ran auto-archive and auto-prune/VACUUM once, on a handle pinned to the construction-time launch home, so a served secondary profile's state.db was never pruned or vacuumed by anybody. Both now run per SERVED profile, inside that profile's runtime scope, against its own SessionDB, its own `sessions:` config and its own transcript dir. - The dashboard/`hermes serve` auto-archive sweep archived an arbitrary profile's store but read the config through the PROCESS HERMES_HOME, so one profile's sessions.auto_archive_days governed every other profile's retention. It now loads the config of the home whose store it sweeps. - Auto-VACUUM admission read "no FOREIGN holder" as "the store is quiet", but the /proc scan skips our own pid by design. VACUUM plus its TRUNCATE checkpoint therefore retired a WAL generation another live SessionDB in THIS process still held. The path-keyed registry now answers the in-process half. Also: argv naming only the install root no longer dismisses a multiplexer as "another instance" when the store is a profile store beneath that root — under one-process-per-host that argv is exactly what the holder looks like.
This commit is contained in:
@@ -3704,25 +3704,16 @@ class GatewayRunner(
|
||||
# state.db corruption or NFS/SMB lock failures silently degrade the entire gateway — messages may
|
||||
# flow but nothing is persisted, and the user has no indication until they try /resume and find
|
||||
# nothing (#88235).
|
||||
if self._session_db is not None:
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_full_config
|
||||
_sess_cfg = (_load_full_config().get("sessions") or {})
|
||||
if _sess_cfg.get("auto_archive", False):
|
||||
self._session_db._db.maybe_auto_archive(
|
||||
idle_days=float(_sess_cfg.get("auto_archive_days", 3)),
|
||||
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)))
|
||||
if _sess_cfg.get("auto_prune", False):
|
||||
# Construction-time, before the loop serves traffic; sync DB is fine.
|
||||
self._session_db._db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=int(_sess_cfg.get("retention_days", 90)),
|
||||
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)),
|
||||
min_vacuum_interval_days=int(
|
||||
_sess_cfg.get("min_vacuum_interval_days", 30)),
|
||||
vacuum=bool(_sess_cfg.get("vacuum_after_prune", True)),
|
||||
sessions_dir=self.config.sessions_dir)
|
||||
except Exception as exc:
|
||||
logger.debug("state.db auto-maintenance skipped: %s", exc)
|
||||
# Once per SERVED profile, each under its own scope: both the store and the ``sessions:``
|
||||
# config that governs it must be the profile's own. Bound to ``self._session_db`` this ran
|
||||
# against the construction-time launch home only, so a multiplexed secondary profile's
|
||||
# state.db was never pruned or vacuumed by anybody, and the launch profile's
|
||||
# retention_days/auto_prune decided whether it happened at all.
|
||||
from gateway.run_profile_reconcile import _for_each_served_profile
|
||||
_housekeeping_chore(
|
||||
"state.db startup maintenance",
|
||||
lambda: _for_each_served_profile(
|
||||
self, lambda _label: _housekeeping_state_db_maintenance()))
|
||||
# Checkpoint store pruning is a housekeeping chore (``_housekeeping_checkpoint_prune``), not a
|
||||
# constructor step: its ``git gc`` repacks the whole store (tens of seconds on a GB store) and
|
||||
# here it ran before the control socket, adapters and the code_sha stamp — so the first
|
||||
@@ -4620,25 +4611,37 @@ def _housekeeping_org_skill_sync() -> None:
|
||||
maybe_pull_org_skills()
|
||||
|
||||
|
||||
def _housekeeping_auto_archive() -> None:
|
||||
"""Stale-session auto-archive on a live timer (the startup hook fires once); maybe_auto_archive()
|
||||
is gated by sessions.min_interval_hours. Opens its own SessionDB — SQLite connections are thread-bound.
|
||||
def _housekeeping_state_db_maintenance() -> None:
|
||||
"""Stale-session auto-archive plus auto-prune/VACUUM for ONE profile's state.db; both are gated
|
||||
by sessions.min_interval_hours (VACUUM additionally by its own throttles). Opens its own
|
||||
SessionDB — SQLite connections are thread-bound.
|
||||
|
||||
Profile-scoped by its caller: ``acquire()`` and ``load_config()`` both resolve through
|
||||
``get_hermes_home()``, so an unscoped tick swept only the LAUNCH profile's store and a
|
||||
multiplexed secondary was never archived by anyone — the dashboard/serve trigger defers to
|
||||
the gateway for every profile a gateway owns (``web_server_sessions``)."""
|
||||
Profile-scoped by its caller: ``acquire()``, ``get_hermes_home()`` and ``load_config()`` all
|
||||
resolve through the active scope, so an unscoped run swept only the LAUNCH profile's store with
|
||||
the LAUNCH profile's retention settings and a multiplexed secondary was never archived, pruned
|
||||
or vacuumed by anyone — the dashboard/serve trigger defers to the gateway for every profile a
|
||||
gateway owns (``web_server_sessions``)."""
|
||||
from hermes_cli.config import load_config as _load_full_config
|
||||
from hermes_state_registry import acquire, release_or_close
|
||||
_sess_cfg = (_load_full_config().get("sessions") or {})
|
||||
if _sess_cfg.get("auto_archive", False):
|
||||
_adb = acquire()
|
||||
try:
|
||||
if not (_sess_cfg.get("auto_archive", False) or _sess_cfg.get("auto_prune", False)):
|
||||
return
|
||||
_adb = acquire()
|
||||
try:
|
||||
if _sess_cfg.get("auto_archive", False):
|
||||
_adb.maybe_auto_archive(
|
||||
idle_days=float(_sess_cfg.get("auto_archive_days", 3)),
|
||||
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)))
|
||||
finally:
|
||||
release_or_close(_adb)
|
||||
if _sess_cfg.get("auto_prune", False):
|
||||
_adb.maybe_auto_prune_and_vacuum(
|
||||
retention_days=int(_sess_cfg.get("retention_days", 90)),
|
||||
min_interval_hours=int(_sess_cfg.get("min_interval_hours", 24)),
|
||||
min_vacuum_interval_days=int(_sess_cfg.get("min_vacuum_interval_days", 30)),
|
||||
vacuum=bool(_sess_cfg.get("vacuum_after_prune", True)),
|
||||
# This profile's own transcript dir, not the launch profile's ``config.sessions_dir``.
|
||||
sessions_dir=get_hermes_home() / "sessions")
|
||||
finally:
|
||||
release_or_close(_adb)
|
||||
|
||||
|
||||
def _housekeeping_deferred_fts_retry() -> None:
|
||||
@@ -4731,7 +4734,7 @@ def _start_gateway_housekeeping(
|
||||
(60, "Curator tick", profile_scoped_chore(runner, _housekeeping_curator)),
|
||||
(60, "Sync pull tick", profile_scoped_chore(runner, _housekeeping_skill_sync)),
|
||||
(60, "Org sync pull tick", profile_scoped_chore(runner, _housekeeping_org_skill_sync)),
|
||||
(60, "Auto-archive tick", profile_scoped_chore(runner, _housekeeping_auto_archive)),
|
||||
(60, "state.db maintenance tick", profile_scoped_chore(runner, _housekeeping_state_db_maintenance)),
|
||||
(1, "Deferred FTS retry tick", _housekeeping_deferred_fts_retry),
|
||||
(1, "gateway housekeeping memory trim", _housekeeping_memory_trim),
|
||||
(1, "MCP config reconcile", _mcp_config_reconciler(runner)),
|
||||
|
||||
@@ -228,20 +228,31 @@ def _maybe_auto_archive_for_profile(profile: Optional[str]) -> None:
|
||||
_last_auto_archive_check[key] = now
|
||||
|
||||
from hermes_cli.config import load_config as _load_full_config
|
||||
cfg = (_load_full_config().get("sessions") or {})
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
|
||||
# The config that governs a store is the one in that store's OWN home. A zero-arg
|
||||
# load_config() resolves through the PROCESS HERMES_HOME, so the dashboard swept every
|
||||
# profile's sessions with the launch profile's sessions.auto_archive/auto_archive_days —
|
||||
# one profile's retention silently decided another's.
|
||||
profile_home = _session_db_path_for_profile(profile).parent
|
||||
_home_token = set_hermes_home_override(str(profile_home))
|
||||
try:
|
||||
cfg = (_load_full_config().get("sessions") or {})
|
||||
finally:
|
||||
reset_hermes_home_override(_home_token)
|
||||
if not cfg.get("auto_archive", False):
|
||||
return
|
||||
from hermes_cli.profiles import _check_gateway_running
|
||||
|
||||
# A live gateway owns this profile's store and runs the same sweep on its own
|
||||
# housekeeping tick ("Auto-archive tick" in gateway/run.py, profile-scoped so a
|
||||
# housekeeping tick ("state.db maintenance tick" in gateway/run.py, profile-scoped so a
|
||||
# multiplexed secondary's store is swept too). Opening it WRITABLE from `hermes
|
||||
# serve` adds a second writer to a database another process is already archiving,
|
||||
# for zero extra coverage (#110405). `_check_gateway_running` is the canonical
|
||||
# per-profile predicate (`_maybe_run_skill_maintenance` below uses it): its
|
||||
# multiplexer rung catches a served secondary, which owns no gateway.pid or lock
|
||||
# of its own and a bare lock-file probe would report stopped.
|
||||
if _check_gateway_running(_session_db_path_for_profile(profile).parent):
|
||||
if _check_gateway_running(profile_home):
|
||||
return
|
||||
db = _open_session_db_for_profile(profile, read_only=False)
|
||||
try:
|
||||
|
||||
@@ -156,9 +156,23 @@ def _argv_scoped_to_other_home(argv: Sequence[str], db_path: Path) -> bool:
|
||||
this instance's stale-FTS rebuild forever despite lsof proving zero open
|
||||
handles). Ambiguous argv without absolute-path tokens returns False and
|
||||
keeps the fail-closed suspicion.
|
||||
|
||||
One process per host serves EVERY profile, so a token naming the install
|
||||
root is not evidence of another instance when ``db_path`` is a profile
|
||||
store under it: a default-launched multiplexer holds
|
||||
``<root>/profiles/<name>/state.db`` while its argv mentions only
|
||||
``<root>``. Only a token under a DIFFERENT install root still counts.
|
||||
"""
|
||||
db_path_str = os.path.abspath(os.fspath(db_path))
|
||||
this_home = os.path.dirname(db_path_str)
|
||||
# ``<root>/profiles/<name>/state.db`` -> ``<root>``; the multiplexer scoped to the
|
||||
# install root is a candidate holder of every profile store beneath it.
|
||||
profiles_dir = os.path.dirname(this_home)
|
||||
install_root = (
|
||||
os.path.dirname(profiles_dir)
|
||||
if os.path.basename(profiles_dir) == "profiles"
|
||||
else None
|
||||
)
|
||||
ours = {
|
||||
os.path.normcase(candidate)
|
||||
for candidate in (
|
||||
@@ -166,8 +180,13 @@ def _argv_scoped_to_other_home(argv: Sequence[str], db_path: Path) -> bool:
|
||||
db_path_str + "-wal",
|
||||
db_path_str + "-shm",
|
||||
this_home,
|
||||
*((install_root,) if install_root else ()),
|
||||
)
|
||||
}
|
||||
own_prefixes = tuple(
|
||||
os.path.normcase(root) + os.sep
|
||||
for root in (this_home, *((install_root,) if install_root else ()))
|
||||
)
|
||||
other_home_seen = False
|
||||
for token in argv:
|
||||
if not isinstance(token, str):
|
||||
@@ -183,7 +202,7 @@ def _argv_scoped_to_other_home(argv: Sequence[str], db_path: Path) -> bool:
|
||||
path_token = None
|
||||
if path_token is not None:
|
||||
normalized = os.path.normcase(os.path.normpath(path_token))
|
||||
if normalized in ours or normalized.startswith(this_home + os.sep):
|
||||
if normalized in ours or normalized.startswith(own_prefixes):
|
||||
return False
|
||||
if "/.hermes" in normalized or normalized.endswith("/.hermes"):
|
||||
other_home_seen = True
|
||||
@@ -335,6 +354,24 @@ def foreign_state_db_holders(db_path: Path) -> List[Tuple[int, str]]:
|
||||
return holders
|
||||
|
||||
|
||||
def in_process_state_db_holders(
|
||||
db_path: Path, *, exclude=None
|
||||
) -> List[Tuple[int, str]]:
|
||||
"""Return holders of ``db_path`` inside THIS process, other than *exclude*.
|
||||
|
||||
:func:`foreign_state_db_holders` skips ``os.getpid()`` by design, so it answers a
|
||||
cross-PROCESS question only. Consumers that read "no holders" as "the store is quiet"
|
||||
(auto-VACUUM admission) need this arm too: a VACUUM plus its TRUNCATE checkpoint retires
|
||||
the generation a sibling SessionDB in this very process still holds.
|
||||
"""
|
||||
from hermes_state_registry import other_generations_for_path
|
||||
|
||||
return [
|
||||
(os.getpid(), description)
|
||||
for description in other_generations_for_path(db_path, exclude=exclude)
|
||||
]
|
||||
|
||||
|
||||
def held_store_refusal(db_path: Path, *, command: str, force_hint: Optional[str] = "--force") -> Optional[str]:
|
||||
"""Operator-facing refusal for structural maintenance (VACUUM, index rebuild, bulk delete) while another
|
||||
process holds ``db_path`` or a WAL sidecar; ``None`` when the store is provably quiet.
|
||||
|
||||
@@ -436,9 +436,14 @@ class SessionMaintenanceMixin:
|
||||
# Same admission `hermes sessions optimize` runs: VACUUM plus the TRUNCATE checkpoint
|
||||
# retire the WAL generation a sibling writer (gateway, Desktop, dashboard, cron) still
|
||||
# holds, and that is exactly the state every agent then refuses turns in (#110054).
|
||||
# The foreign scan skips our own pid, so it is paired with the in-process arm: under
|
||||
# one multiplexed process another live SessionDB generation for this same path is
|
||||
# just as much a holder as another process would be.
|
||||
# Automatic maintenance only ever SKIPS — a turn is never refused over housekeeping.
|
||||
from hermes_state_holders import foreign_state_db_holders
|
||||
holders = foreign_state_db_holders(self.db_path)
|
||||
from hermes_state_holders import (
|
||||
foreign_state_db_holders, in_process_state_db_holders)
|
||||
holders = (foreign_state_db_holders(self.db_path)
|
||||
+ in_process_state_db_holders(self.db_path, exclude=self))
|
||||
if holders:
|
||||
result["vacuum_skipped_holders"] = len(holders)
|
||||
logger.debug(
|
||||
|
||||
@@ -398,6 +398,31 @@ def close_all_under(directory: str | Path) -> int:
|
||||
return _teardown_swept_generations(generations, teardown_barriers, active_teardowns)
|
||||
|
||||
|
||||
def other_generations_for_path(
|
||||
db_path: Path, *, exclude: Optional["SessionDB"] = None
|
||||
) -> List[str]:
|
||||
"""Describe every OTHER SessionDB generation THIS process holds for *db_path*.
|
||||
|
||||
The registry is path-keyed, so it can answer the in-process half of "is this store quiet?"
|
||||
that a ``/proc`` descriptor scan structurally cannot: that scan skips our own pid, so it only
|
||||
ever proves other PROCESSES are away. Retired generations count — they stay open for their
|
||||
holders, and a VACUUM's TRUNCATE checkpoint retires the WAL generation underneath them.
|
||||
"""
|
||||
try:
|
||||
path = Path(db_path).resolve()
|
||||
except OSError:
|
||||
path = Path(db_path)
|
||||
with _lock:
|
||||
candidates = [
|
||||
("live", generation) for generation in _generations.values()
|
||||
] + [("retired", generation) for generation in _retired.values()]
|
||||
return [
|
||||
f"in-process {kind} SessionDB generation (refcount {generation.refcount})"
|
||||
for kind, generation in candidates
|
||||
if generation.db is not exclude and generation.path == path
|
||||
]
|
||||
|
||||
|
||||
def live_shared_session_dbs() -> List["SessionDB"]:
|
||||
"""Snapshot of every live (non-retired) shared SessionDB (refcounts untouched), for
|
||||
in-process maintenance. A concurrent final release may close an instance, in which
|
||||
|
||||
@@ -53,6 +53,12 @@ def two_homes(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home))
|
||||
monkeypatch.setenv("HERMES_HOME", str(a))
|
||||
monkeypatch.delenv("NOUS_INFERENCE_BASE_URL", raising=False)
|
||||
# The hermetic conftest pins ``hermes_state.DEFAULT_DB_PATH`` at one sandbox store whenever
|
||||
# hermes_state is already imported, and that pin WINS over ``get_hermes_home()`` inside
|
||||
# ``_default_db_path()`` — exactly the per-profile resolution these tests exist to prove.
|
||||
# Restore the import-time sentinel so an argless ``acquire()`` resolves through the scope.
|
||||
import hermes_state
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", hermes_state._IMPORT_DEFAULT_DB_PATH)
|
||||
return a, b
|
||||
|
||||
|
||||
@@ -128,6 +134,46 @@ def test_multiplexed_auto_archive_tick_sweeps_every_served_profile_store(two_hom
|
||||
assert swept == [a / "state.db", b / "state.db"]
|
||||
|
||||
|
||||
def test_multiplexed_maintenance_tick_prunes_every_served_profile_store(two_homes, monkeypatch):
|
||||
"""Prune/VACUUM reaches each served profile's OWN state.db, under its OWN ``sessions:`` config.
|
||||
|
||||
Prune and VACUUM ran once in the gateway constructor against a handle pinned to the launch
|
||||
home, so a multiplexed secondary's store was never pruned or vacuumed by anybody — it grew
|
||||
without bound while the launch profile's ``retention_days`` decided whether it happened at all.
|
||||
Real stores, real config files: nothing here is patched.
|
||||
"""
|
||||
from agent.secret_scope import set_multiplex_active
|
||||
from hermes_state import SessionDB
|
||||
|
||||
homes = two_homes
|
||||
for home in homes:
|
||||
(home / "config.yaml").write_text(
|
||||
"model:\n provider: nous\n"
|
||||
"sessions:\n"
|
||||
" auto_prune: true\n"
|
||||
" retention_days: 0\n"
|
||||
" min_interval_hours: 0\n"
|
||||
" vacuum_after_prune: false\n",
|
||||
encoding="utf-8")
|
||||
db = SessionDB(db_path=home / "state.db")
|
||||
db.create_session("old", "cli")
|
||||
db.end_session("old", "done")
|
||||
db.close()
|
||||
|
||||
set_multiplex_active(True)
|
||||
try:
|
||||
_run_60_ticks(SimpleNamespace(config=SimpleNamespace(multiplex_profiles=True)))
|
||||
finally:
|
||||
set_multiplex_active(False)
|
||||
|
||||
for home in homes:
|
||||
db = SessionDB(db_path=home / "state.db")
|
||||
try:
|
||||
assert db.get_session("old") is None, f"{home.name}'s store was never pruned"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_single_profile_sync_ticks_run_once_against_the_process_home(two_homes, monkeypatch):
|
||||
"""Control: a single-profile gateway (multiplex off) still runs each chore exactly once against
|
||||
the process home — the named profile directory on disk is not visited."""
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Invariant: a profile's OWN ``sessions:`` config governs its store's auto-archive.
|
||||
|
||||
``_maybe_auto_archive_for_profile`` sweeps an arbitrary profile's store, but the config it
|
||||
read came from a zero-arg ``load_config()`` — i.e. the PROCESS HERMES_HOME. On a host serving
|
||||
several profiles, the launch profile's ``auto_archive`` / ``auto_archive_days`` silently
|
||||
decided every other profile's retention.
|
||||
|
||||
Real stores, real ``config.yaml`` files and the real ``_check_gateway_running`` predicate;
|
||||
only the profile -> home mapping is redirected at temp dirs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import web_server_sessions as wss
|
||||
|
||||
|
||||
def _write_config(home: Path, *, auto_archive_days: int) -> None:
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
(home / "config.yaml").write_text(
|
||||
"sessions:\n"
|
||||
" auto_archive: true\n"
|
||||
f" auto_archive_days: {auto_archive_days}\n"
|
||||
" min_interval_hours: 0\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_profile_homes(tmp_path, monkeypatch):
|
||||
"""Launch profile 'a' never archives; served profile 'b' archives immediately."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
home_a, home_b = tmp_path / "a", tmp_path / "b"
|
||||
_write_config(home_a, auto_archive_days=3650)
|
||||
_write_config(home_b, auto_archive_days=0)
|
||||
|
||||
db = SessionDB(db_path=home_b / "state.db")
|
||||
db.create_session("s1", "cli")
|
||||
db.append_message("s1", "user", "hello")
|
||||
db.close()
|
||||
|
||||
# Process home is the LAUNCH profile's.
|
||||
monkeypatch.setenv("HERMES_HOME", str(home_a))
|
||||
monkeypatch.setattr(
|
||||
wss, "_session_db_path_for_profile",
|
||||
lambda profile: (home_b if profile == "b" else home_a) / "state.db")
|
||||
monkeypatch.setattr(wss, "_last_auto_archive_check", {})
|
||||
return home_b
|
||||
|
||||
|
||||
def _archived(db_path: Path) -> bool:
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB(db_path=db_path)
|
||||
try:
|
||||
return bool((db.get_session("s1") or {}).get("archived"))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_auto_archive_uses_the_swept_profiles_own_retention_config(two_profile_homes):
|
||||
home_b = two_profile_homes
|
||||
|
||||
wss._maybe_auto_archive_for_profile("b")
|
||||
|
||||
assert _archived(home_b / "state.db"), (
|
||||
"profile b's store was swept with another profile's sessions.auto_archive_days")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Automatic VACUUM is refused while THIS process holds another generation of the same store.
|
||||
|
||||
``foreign_state_db_holders`` skips ``os.getpid()``, so it can only ever prove other PROCESSES
|
||||
are away. Under the one-process-per-host multiplexer the dangerous holder is usually a sibling
|
||||
SessionDB generation inside this very process: the VACUUM's TRUNCATE checkpoint retires the WAL
|
||||
generation that handle is still bound to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
|
||||
import hermes_state_registry as registry
|
||||
|
||||
|
||||
def _seed(path):
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB(db_path=path)
|
||||
db.create_session("old", "cli")
|
||||
db.append_message("old", "user", "hello")
|
||||
db.end_session("old", "done")
|
||||
db.close()
|
||||
|
||||
|
||||
def _auto_maintenance(db):
|
||||
# retention_days=0 makes the ended row prunable; the freelist floor is disabled so only a
|
||||
# holder can stop the VACUUM.
|
||||
return db.maybe_auto_prune_and_vacuum(
|
||||
retention_days=0, min_interval_hours=0, min_vacuum_interval_days=0,
|
||||
min_vacuum_freelist_ratio=-1.0)
|
||||
|
||||
|
||||
def test_auto_vacuum_skips_while_this_process_holds_another_generation(tmp_path):
|
||||
db_path = tmp_path / "state.db"
|
||||
_seed(db_path)
|
||||
|
||||
first = registry.acquire(db_path)
|
||||
try:
|
||||
# Snapshot restore / recovery swap shape: the file is replaced, so the next acquire
|
||||
# RETIRES `first` (still open, still held) and opens a fresh generation.
|
||||
replacement = tmp_path / "replacement.db"
|
||||
shutil.copy2(db_path, replacement)
|
||||
replacement.replace(db_path)
|
||||
second = registry.acquire(db_path)
|
||||
try:
|
||||
assert second is not first, "inode replacement did not mint a new generation"
|
||||
result = _auto_maintenance(second)
|
||||
assert result["pruned"] == 1
|
||||
assert result["vacuumed"] is False, "VACUUM ran under a live in-process holder"
|
||||
assert result.get("vacuum_skipped_holders")
|
||||
finally:
|
||||
registry.release(second)
|
||||
finally:
|
||||
registry.release(first)
|
||||
|
||||
# Control: the store is quiet once the other generation is released, so the same call VACUUMs.
|
||||
only = registry.acquire(db_path)
|
||||
try:
|
||||
only.set_meta("last_auto_prune", "0")
|
||||
only.create_session("old2", "cli")
|
||||
only.end_session("old2", "done")
|
||||
again = _auto_maintenance(only)
|
||||
assert again["vacuumed"] is True
|
||||
assert "vacuum_skipped_holders" not in again
|
||||
finally:
|
||||
registry.release(only)
|
||||
@@ -137,3 +137,21 @@ class TestUninspectableHolderInstanceScope:
|
||||
holders = hermes_state_holders.foreign_state_db_holders(db_path)
|
||||
assert [pid for pid, _ in holders] == [222], argv
|
||||
assert holders[0][1].startswith("uninspectable holder:"), argv
|
||||
|
||||
def test_install_root_argv_still_holds_a_profile_store_under_it(self, tmp_path, monkeypatch):
|
||||
"""One process per host serves EVERY profile, so argv naming only the install root does
|
||||
not prove the process is another instance: it holds ``<root>/profiles/<name>/state.db``
|
||||
too. A DIFFERENT install root is still proof."""
|
||||
root = tmp_path / ".hermes"
|
||||
db_path = root / "profiles" / "b" / "state.db"
|
||||
db_path.parent.mkdir(parents=True)
|
||||
multiplexer = ["hermes", "--home", str(root), "gateway", "run"]
|
||||
other_install = ["hermes", "--home", "/home/demo/.hermes", "gateway", "run"]
|
||||
|
||||
_install_fake_proc(monkeypatch, db_path.parent, unreadable_pids=(222,))
|
||||
_install_fake_argv(monkeypatch, {222: multiplexer})
|
||||
assert [pid for pid, _ in hermes_state_holders.foreign_state_db_holders(db_path)] == [222]
|
||||
|
||||
_install_fake_proc(monkeypatch, db_path.parent, unreadable_pids=(222,))
|
||||
_install_fake_argv(monkeypatch, {222: other_install})
|
||||
assert hermes_state_holders.foreign_state_db_holders(db_path) == []
|
||||
|
||||
Reference in New Issue
Block a user