"""Process-wide shared SessionDB registry. Each bare ``SessionDB()`` mints its own writer connection, lock, close-time WAL checkpoint and token writer thread, and one connection's close-time checkpoint can race another's growth (lost/reordered-page-write corruption). This module owns that boundary: one shared ``SessionDB`` per resolved path per process, refcounted, with generation-aware retirement when the file is replaced (snapshot restore, recovery swap). Lifecycle rules: - ``acquire(path)`` returns the current generation for *path* and bumps its refcount. - ``close()`` on a shared instance RELEASES one refcount instead of tearing the connection down: the registry owns the physical lifecycle and only closes on the final release, so legacy call sites return their reference instead of leaking it. - ``release(db)`` decrements the generation *db was acquired from* (object-keyed, so an inode replacement cannot strand a still-owned generation); the final release of a retired generation tears it down. - On inode change the old generation is RETIRED (never lent again) but stays alive until its holders release. If the replacement open fails the registry keeps NO path entry. - All teardown happens OUTSIDE the registry lock: a final release's WAL checkpoint must never stall acquisition for every state.db. - A final close/checkpoint is serialized with the next open for the same path; no new generation is published while the previous generation is still tearing down. - A path can have SEVERAL closes admitted at once (the current generation's final release plus a retired generation's drain). The path barrier COUNTS them and is lifted only by the last one to settle, so neither ``acquire`` nor ``close_all`` / ``close_all_under`` can escape while any handle for that path is still inside checkpoint/WAL-unlink. - Maintenance callers borrow handles with a temporary registry reference instead of iterating an unpinned snapshot. """ from __future__ import annotations import contextlib import logging import os import threading from pathlib import Path from typing import TYPE_CHECKING, Dict, Iterator, List, Optional, Tuple from hermes_state_common import stat_db_file_identity as _stat_db_file_identity if TYPE_CHECKING: # pragma: no cover - import cycle guard, typed only from hermes_state import SessionDB logger = logging.getLogger(__name__) class _TeardownBarrier: """Accounting for every admitted-but-unfinished physical close of one path. A path can own more than one close at a time: the current generation's final release and a retired generation's drain are admitted independently under ``_lock`` and only meet at the lifecycle mutex. One event per path is honest only if the LAST admitted teardown settles it. Signalling on the first lets ``close_all()`` return and ``acquire()`` publish a replacement while an older handle is still inside ``PRAGMA wal_checkpoint``/sidecar unlink -- the exact overlap (#102827) this registry exists to forbid. """ __slots__ = ("event", "pending") def __init__(self) -> None: self.event = threading.Event() self.pending = 0 class _Generation: """One shared SessionDB generation: instance, refcount, file identity.""" __slots__ = ("path", "db", "refcount", "identity", "retired") def __init__(self, path: Path, db: "SessionDB", identity: Optional[Tuple[int, int]]) -> None: self.path = path self.db = db self.refcount = 1 self.identity = identity self.retired = False _lock = threading.Lock() # path → live generation; retired generations move to _retired (keyed by id(db)) until # their last holder releases. _generations: Dict[Path, _Generation] = {} _retired: Dict[int, _Generation] = {} # Paths whose next generation is being constructed. Construction runs outside _lock # (schema reconciliation can take seconds), but peers for the SAME file must wait or # every cold caller opens its own writer before a winner is chosen. _opening: Dict[Path, threading.Event] = {} # A final close/checkpoint must finish before a replacement writer is opened # for the same path. The barrier is admitted while holding _lock and lifted # only after the LAST admitted physical teardown, so acquire cannot slip # through the generation-removal/open gap and close_all cannot report a # finished sweep over a close that is still running. _tearing_down: Dict[Path, _TeardownBarrier] = {} # Open and close are both performed outside _lock. This per-path mutex closes # the race between checking _tearing_down and entering sqlite3.connect(), # including retired-generation drains after an inode replacement. _path_lifecycle_locks: Dict[Path, threading.Lock] = {} def _open_session_db(path: Path) -> "SessionDB": """Construct the SessionDB for *path* (call-time import avoids cycles; tests patch this).""" from hermes_state import SessionDB return SessionDB(db_path=path) def _teardown(db: "SessionDB") -> None: """Close a shared instance, clearing its registry-owned flag first.""" with contextlib.suppress(Exception): db._shared_registry_owned = False _close_quietly(db, "Error closing shared SessionDB") def _close_quietly(db: "SessionDB", debug_message: str) -> None: """close() that never propagates. A lost WAL generation whose capture failed is data at risk, not teardown noise: the handle stays open and the operator has to act, so that one surfaces.""" try: db.close() except Exception as exc: from hermes_state_dbfile import RetiredGenerationCaptureError if isinstance(exc, RetiredGenerationCaptureError): logger.error("SessionDB for %s did not settle at close: %s", _db_path_of(db), exc) else: logger.debug(debug_message, exc_info=True) def _path_lifecycle_lock_locked(path: Path) -> threading.Lock: """Return the lifecycle mutex for *path* (caller holds ``_lock``).""" lock = _path_lifecycle_locks.get(path) if lock is None: lock = threading.Lock() _path_lifecycle_locks[path] = lock return lock def _admit_teardown_locked(path: Path) -> _TeardownBarrier: """Register one pending physical close for *path* (caller holds ``_lock``). Admission shares the lock section that removes the generation, so a peer release, ``acquire`` or ``close_all`` taking the lock next always sees this teardown accounted for. """ barrier = _tearing_down.get(path) if barrier is None: barrier = _tearing_down[path] = _TeardownBarrier() barrier.pending += 1 return barrier def _finish_teardown(path: Path, barrier: _TeardownBarrier) -> None: """Settle one admitted teardown; only the last one lifts the path barrier.""" with _lock: barrier.pending -= 1 if barrier.pending > 0: return if _tearing_down.get(path) is barrier: _tearing_down.pop(path, None) barrier.event.set() def _teardown_generation( path: Path, db: "SessionDB", *, barrier: Optional[_TeardownBarrier] = None, ) -> None: """Close *db* under its path lifecycle mutex, then settle its barrier slot.""" with _lock: lifecycle_lock = _path_lifecycle_lock_locked(path) try: with lifecycle_lock: _teardown(db) finally: if barrier is not None: _finish_teardown(path, barrier) def _db_path_of(db: "SessionDB") -> Optional[Path]: """``Path(db.db_path)`` or None when absent/unconvertible.""" path = getattr(db, "db_path", None) try: return None if path is None else Path(path) except (TypeError, ValueError): return None def _finish_opening(path: Path, opening: threading.Event) -> None: """Drop the per-path construction marker and wake waiters (caller holds _lock).""" if _opening.get(path) is opening: _opening.pop(path, None) opening.set() def acquire(db_path: Optional[Path] = None) -> "SessionDB": """Return the shared SessionDB for *db_path*, incrementing its refcount. If the file was replaced (different inode) since the generation opened, that generation is RETIRED but stays alive for its holders, and a fresh one is opened in its place. Raises whatever ``SessionDB.__init__`` raises; on a replacement-open failure the registry holds NO entry for the path.""" from hermes_state import _default_db_path raw_path = Path(db_path) if db_path is not None else Path(_default_db_path()) try: path = raw_path.resolve() except OSError: path = raw_path while True: wait_for: Optional[threading.Event] = None with _lock: generation = _generations.get(path) if generation is not None: current = _stat_db_file_identity(path) if current is not None and generation.identity is not None and current != generation.identity: # File replaced: retire this generation so it is never lent again, then elect one # caller to open the replacement. It stays alive for its holders, tracked in # ``_retired`` by ``id(db)`` so their releases find it after the path remaps. generation.retired = True del _generations[path] _retired[id(generation.db)] = generation else: generation.refcount += 1 return generation.db teardown = _tearing_down.get(path) if teardown is not None: wait_for = teardown.event else: opening = _opening.get(path) if opening is None: opening = _opening[path] = threading.Event() lifecycle_lock = _path_lifecycle_lock_locked(path) else: wait_for = opening if wait_for is not None: # Another caller is constructing or closing this path; wait without holding the # global lock. A failed opener signals too, so a waiter can retry. wait_for.wait() continue # Open OUTSIDE the registry lock; the per-path marker prevents redundant writers without # serialising other files, the lifecycle mutex keeps the open off a same-path close. try: with lifecycle_lock: db = _open_session_db(path) db._shared_registry_owned = True identity = _stat_db_file_identity(path) except BaseException: with _lock: _finish_opening(path, opening) raise with _lock: teardown = _tearing_down.get(path) if teardown is None: existing = _generations.get(path) if existing is not None: # Defensive: installed by explicit registry manipulation mid-open. existing.refcount += 1 winner = existing.db else: _generations[path] = _Generation(path, db, identity) winner = db _finish_opening(path, opening) if teardown is not None: # A shutdown or retired-generation final release was admitted while this opener was # constructing: never publish into that window — discard the speculative handle and # go round again once the barrier lifts. _teardown_generation(path, db) teardown.event.wait() continue if winner is not db: _teardown_generation(path, db) return winner def release(db: "SessionDB") -> bool: """Decrement the refcount of a shared SessionDB. ``True`` if *db* was shared; ``False`` if it is not registry-managed (caller owns close()). The final release tears the generation down OUTSIDE the registry lock. Lookup is object-keyed, so holders of an old generation release into its retired record, not into whatever the path names.""" if db is None: return False key = id(db) teardown_barrier: Optional[_TeardownBarrier] = None with _lock: generation = _retired.get(key) if generation is None: path = _db_path_of(db) if path is None: return False generation = _generations.get(path) if generation is None or generation.db is not db: # Not shared (bare SessionDB()); the caller owns close(). return False generation.refcount -= 1 needs_teardown = generation.refcount <= 0 if needs_teardown: if generation.retired: _retired.pop(key, None) elif _generations.get(generation.path) is generation: _generations.pop(generation.path, None) # A retired generation's drain is admitted too: it checkpoints and unlinks the same # sidecars as the current one, so a replacement writer must not open on top of it. teardown_barrier = _admit_teardown_locked(generation.path) # Teardown OUTSIDE the lock: stopping the token writer, WAL checkpoint and read-pool # drain must not block acquisition for every other state.db. if needs_teardown: _teardown_generation(generation.path, db, barrier=teardown_barrier) return True def _path_is_under(path: Path, root: Path) -> bool: """True when *path* is *root* or a file inside it (normcase, resolved).""" try: path_key = os.path.normcase(str(path.resolve())) root_key = os.path.normcase(str(root.resolve())) except OSError: path_key = os.path.normcase(str(path)) root_key = os.path.normcase(str(root)) return path_key == root_key or path_key.startswith(root_key + os.sep) def _teardown_swept_generations( generations: List[_Generation], teardown_barriers: Dict[Path, _TeardownBarrier], active_teardowns: List[_TeardownBarrier], ) -> int: """Close *generations* outside the registry lock; wait for already-admitted teardowns.""" by_path: Dict[Path, List[_Generation]] = {} for generation in generations: by_path.setdefault(generation.path, []).append(generation) for path, path_generations in by_path.items(): with _lock: lifecycle_lock = _path_lifecycle_lock_locked(path) try: with lifecycle_lock: for generation in path_generations: _teardown(generation.db) finally: _finish_teardown(path, teardown_barriers[path]) for barrier in active_teardowns: barrier.event.wait() return len(generations) def close_all() -> int: """Close every shared SessionDB regardless of refcount; returns the count. For gateway shutdown, after all agents and cron jobs finished. Idempotent.""" teardown_barriers: Dict[Path, _TeardownBarrier] = {} with _lock: active_teardowns = list(_tearing_down.values()) generations = list(_generations.values()) + list(_retired.values()) for path in {generation.path for generation in generations}: teardown_barriers[path] = _admit_teardown_locked(path) _generations.clear() _retired.clear() for generation in generations: generation.retired = True return _teardown_swept_generations(generations, teardown_barriers, active_teardowns) def close_all_under(directory: str | Path) -> int: """Force-close every shared SessionDB whose file lives under *directory*; returns the count. Profile delete rmtree (and a same-name recreate) fails while this process still holds ``state.db``. Same contract as ``MemoryStore.release_all_under``: a live holder is expected to fail afterward; a process that holds none is a no-op returning 0. A final ``release()`` can drop the generation and admit teardown before the physical close finishes. Wait for those directory-matching barriers even when no generation remains, otherwise rmtree still sees the open handle. """ try: root = Path(directory).expanduser().resolve() except OSError: root = Path(directory).expanduser() teardown_barriers: Dict[Path, _TeardownBarrier] = {} with _lock: generations = [ generation for generation in list(_generations.values()) + list(_retired.values()) if _path_is_under(generation.path, root) ] # Collect by directory, not by remaining generations: a last release already # popped the generation and left only ``_tearing_down``. active_teardowns = [ barrier for path, barrier in _tearing_down.items() if _path_is_under(path, root) ] selected_paths = {generation.path for generation in generations} for path in selected_paths: teardown_barriers[path] = _admit_teardown_locked(path) for generation in generations: generation.retired = True if _generations.get(generation.path) is generation: _generations.pop(generation.path, None) _retired.pop(id(generation.db), None) 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 LIVE 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 are deliberately not holders here. A generation is retired only after its file was replaced, which is exactly when ``SessionDB`` fences it: every write raises ``StateDbReplacedError`` and the close-time checkpoint is disabled, so it is not the live writer this gate protects. It also leaves ``_retired`` only when its last holder releases, and a gateway handle does not release before shutdown — counting it made ONE inode replacement (recovery swap, backup restore, snapshot) skip auto-VACUUM for that path for the rest of the process lifetime, turning the unbounded growth this maintenance exists to bound into a permanent condition. """ try: path = Path(db_path).resolve() except OSError: path = Path(db_path) with _lock: return [ f"in-process live SessionDB generation (refcount {generation.refcount})" for generation in _generations.values() if generation.db is not exclude and generation.path == path and not generation.retired ] 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 case the callee sees ``_conn is None``.""" with _lock: return [g.db for g in _generations.values() if not g.retired] @contextlib.contextmanager def borrow_live_shared_session_dbs() -> Iterator[List["SessionDB"]]: """Borrow live handles with registry references pinned for the whole block. Maintenance must not operate on the unowned snapshot returned by :func:`live_shared_session_dbs`: the last real owner could otherwise release and physically close the connection between the snapshot and the maintenance call. Each borrowed generation gets one temporary reference; the ``finally`` block releases it even when maintenance raises. """ with _lock: borrowed_generations = [ generation for generation in _generations.values() if not generation.retired ] borrowed = [generation.db for generation in borrowed_generations] for generation in borrowed_generations: generation.refcount += 1 try: yield borrowed finally: for db in reversed(borrowed): release(db) def stats() -> Dict[str, int]: """Registry census for tests and diagnostics (no locks held long).""" with _lock: return { "live_generations": len(_generations), "retired_generations": len(_retired), "total_refcounts": sum(g.refcount for g in _generations.values()), } def release_or_close(db: "SessionDB") -> None: """Release a shared instance, or close it when it is not registry-managed. Drop-in for a plain ``db.close()``: read-only opens, CLI one-shots and test fakes fall back.""" if not release(db): _close_quietly(db, "release_or_close fallback close failed") # ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ---- # Names external plugins imported from this module before the Sep 2026 decomposition. # Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does). # The whole block is removed by reverting the commit that added it. def close_shared_session_dbs() -> int: return close_all() def get_shared_session_db(db_path: Optional[Path] = None) -> "SessionDB": return acquire(db_path) def release_shared_session_db(db: "SessionDB") -> bool: return release(db) # ---- END PLUGIN-COMPAT ----