refactor(state): extract read-connection budgeting to hermes_state_readpool
This commit is contained in:
213
hermes_state.py
213
hermes_state.py
@@ -7,7 +7,6 @@ splits sessions via parent_session_id chains; sessions are source-tagged
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import atexit
|
import atexit
|
||||||
import errno
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -20,7 +19,6 @@ import sys
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import weakref
|
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -60,6 +58,10 @@ from hermes_state_guard import ( # noqa: F401 (re-exported; tests patch hermes
|
|||||||
_has_pytest_ancestor, _in_test_context, _is_production_state_db, _process_looks_like_pytest,
|
_has_pytest_ancestor, _in_test_context, _is_production_state_db, _process_looks_like_pytest,
|
||||||
_real_platform_state_root, _running_under_pytest, _set_last_init_error, get_last_init_error,
|
_real_platform_state_root, _running_under_pytest, _set_last_init_error, get_last_init_error,
|
||||||
)
|
)
|
||||||
|
from hermes_state_readpool import ( # noqa: F401 (re-exported; tests import from hermes_state)
|
||||||
|
_HANDLES_PER_PATH_WARN, _READ_POOL_MAX, _READ_POOL_PROCESS_MAX, _PathReadBudget,
|
||||||
|
_proc_fd_targets, _process_read_permits, _read_budget_for,
|
||||||
|
)
|
||||||
from hermes_state_portability import SessionPortabilityMixin
|
from hermes_state_portability import SessionPortabilityMixin
|
||||||
from hermes_state_telegram import SessionTelegramTopicsMixin
|
from hermes_state_telegram import SessionTelegramTopicsMixin
|
||||||
from hermes_state_schema import SessionSchemaMixin
|
from hermes_state_schema import SessionSchemaMixin
|
||||||
@@ -382,213 +384,6 @@ def _is_transient_read_only_ioerr(exc: sqlite3.OperationalError, *, attempt: int
|
|||||||
return attempt < _READ_ONLY_IOERR_RETRY_ATTEMPTS and _DISK_IO_ERROR_MARKER in str(exc).lower()
|
return attempt < _READ_ONLY_IOERR_RETRY_ATTEMPTS and _DISK_IO_ERROR_MARKER in str(exc).lower()
|
||||||
|
|
||||||
|
|
||||||
# Ceiling on read-only connections ALIVE at once against one database FILE
|
|
||||||
# (idle pooled + checked out, summed over every SessionDB on that file). One
|
|
||||||
# constant for both the pool maxsize and the permit count: a LifoQueue only caps
|
|
||||||
# how many are *returned*; with open-on-miss, N readers hitting an empty pool
|
|
||||||
# all open and peak at N, and EMFILE is a peak-instant condition. So a
|
|
||||||
# connection holds a permit for its whole lifetime (_get_read_conn ->
|
|
||||||
# _close_read_conn); once permits are gone reads degrade to the locked writer
|
|
||||||
# connection — slower, but not a process-wide wedge the supervisor can't see.
|
|
||||||
_READ_POOL_MAX = 8
|
|
||||||
|
|
||||||
# Ceiling on read-only connections ALIVE in this PROCESS across every state.db
|
|
||||||
# (a multiplexed gateway opens one per profile, so a per-file cap still scales
|
|
||||||
# with profile count). Three profiles' worth; past it readers degrade to the
|
|
||||||
# writer connection for the same reason as _READ_POOL_MAX.
|
|
||||||
_READ_POOL_PROCESS_MAX = 24
|
|
||||||
|
|
||||||
# Warn past this many SessionDB handles on one file in one process. Diagnostic
|
|
||||||
# only: writer connections cannot be rationed the way read connections can.
|
|
||||||
_HANDLES_PER_PATH_WARN = 4
|
|
||||||
|
|
||||||
# Descriptors kept in reserve for everything that is NOT this module (httpx
|
|
||||||
# sockets, terminal pipes, log files): SQLite's share is only part of the fd
|
|
||||||
# table, and the EMFILE it pushes over surfaces elsewhere (terminal_tool).
|
|
||||||
_FD_HEADROOM_RESERVE = 64
|
|
||||||
|
|
||||||
# The fd count is a directory listing; cache it briefly so a read burst isn't a
|
|
||||||
# syscall per query. Staleness lets through at most the ceiling's worth of opens.
|
|
||||||
_FD_USAGE_CACHE_SECONDS = 0.25
|
|
||||||
|
|
||||||
_process_read_permits = threading.BoundedSemaphore(_READ_POOL_PROCESS_MAX)
|
|
||||||
|
|
||||||
# Read opens refused for low descriptor headroom — the only visible signal the
|
|
||||||
# guard fires. Guarded by _read_budgets_lock.
|
|
||||||
_read_open_denied_fd_headroom = 0
|
|
||||||
|
|
||||||
_fd_usage_lock = threading.Lock()
|
|
||||||
_fd_usage_cache: "tuple[float, Optional[int]]" = (0.0, None)
|
|
||||||
|
|
||||||
|
|
||||||
def _proc_fd_targets(pid: int) -> Iterator[str]:
|
|
||||||
"""readlink() of every entry in /proc/<pid>/fd (unreadable links skipped).
|
|
||||||
Raises OSError when the fd directory itself cannot be listed."""
|
|
||||||
fd_dir = f"/proc/{pid}/fd"
|
|
||||||
for fd in os.listdir(fd_dir):
|
|
||||||
try:
|
|
||||||
yield os.readlink(f"{fd_dir}/{fd}")
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
|
|
||||||
def _open_fd_count() -> Optional[int]:
|
|
||||||
"""Open descriptors in THIS process; None when unmeasurable (Windows: no fd
|
|
||||||
dir and no RLIMIT_NOFILE, correctly inert — its limit is thousands); -1 when
|
|
||||||
the probe itself hit EMFILE/ENFILE (that IS the answer: no headroom)."""
|
|
||||||
for fd_dir in ("/proc/self/fd", "/dev/fd"):
|
|
||||||
try:
|
|
||||||
return len(os.listdir(fd_dir))
|
|
||||||
except OSError as exc:
|
|
||||||
if exc.errno in (errno.EMFILE, errno.ENFILE):
|
|
||||||
return -1
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _fd_soft_limit() -> Optional[int]:
|
|
||||||
"""The process's soft RLIMIT_NOFILE, or None when there is no usable one."""
|
|
||||||
try:
|
|
||||||
import resource
|
|
||||||
except ImportError:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
soft, _hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
return None
|
|
||||||
if soft in (resource.RLIM_INFINITY, -1):
|
|
||||||
return None
|
|
||||||
return int(soft)
|
|
||||||
|
|
||||||
|
|
||||||
def _fd_headroom_ok() -> bool:
|
|
||||||
"""Can the process spare a descriptor for a new read connection?
|
|
||||||
Fails OPEN when unmeasurable (refusing every read there would be a
|
|
||||||
self-inflicted convoy); fails CLOSED only on evidence (measured shortfall,
|
|
||||||
or a probe that couldn't get a descriptor itself)."""
|
|
||||||
soft = _fd_soft_limit()
|
|
||||||
if soft is None:
|
|
||||||
return True
|
|
||||||
global _fd_usage_cache
|
|
||||||
now = time.monotonic()
|
|
||||||
with _fd_usage_lock:
|
|
||||||
stamp, cached = _fd_usage_cache
|
|
||||||
fresh = cached is not None and (now - stamp) < _FD_USAGE_CACHE_SECONDS
|
|
||||||
if not fresh:
|
|
||||||
cached = _open_fd_count()
|
|
||||||
with _fd_usage_lock:
|
|
||||||
_fd_usage_cache = (now, cached)
|
|
||||||
if cached is None:
|
|
||||||
return True
|
|
||||||
return cached >= 0 and (soft - cached) > _FD_HEADROOM_RESERVE
|
|
||||||
|
|
||||||
|
|
||||||
def _reclaim_idle_read_conn_anywhere() -> bool:
|
|
||||||
"""Close one idle read connection on ANY path: the process ceiling is shared
|
|
||||||
across files, so a quiet profile must not hold descriptors a busy one needs."""
|
|
||||||
with _read_budgets_lock:
|
|
||||||
budgets = list(_read_budgets.values())
|
|
||||||
return any(budget.reclaim_idle() for budget in budgets)
|
|
||||||
|
|
||||||
|
|
||||||
class _PathReadBudget:
|
|
||||||
"""Read-connection permits for ONE database file, shared process-wide:
|
|
||||||
per-instance semaphores let N SessionDBs on one file peak at N x (1 + MAX)
|
|
||||||
and walk into EMFILE. An idle pooled connection keeps its permit, so a
|
|
||||||
permit miss first reclaims an IDLE connection from a peer on the same path
|
|
||||||
(idle descriptors are transferable, in-use ones are not)."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.permits = threading.BoundedSemaphore(_READ_POOL_MAX)
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
# Weak: a SessionDB dropped without close() must not pin peers' budget.
|
|
||||||
self._members: "weakref.WeakSet[SessionDB]" = weakref.WeakSet()
|
|
||||||
self._duplicate_handles_warned = False
|
|
||||||
|
|
||||||
def register(self, db: "SessionDB") -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._members.add(db)
|
|
||||||
handles = len(self._members)
|
|
||||||
warn = (handles > _HANDLES_PER_PATH_WARN and not self._duplicate_handles_warned)
|
|
||||||
if warn:
|
|
||||||
self._duplicate_handles_warned = True
|
|
||||||
if warn:
|
|
||||||
# Writer connections cannot be capped (a SessionDB without one cannot
|
|
||||||
# write); the only bound is not opening redundant handles. Make the
|
|
||||||
# next duplicate visible before it becomes an incident.
|
|
||||||
logger.warning(
|
|
||||||
"%d live SessionDB handles on %s in this process; each holds "
|
|
||||||
"its own writer connection (read connections are capped at %d "
|
|
||||||
"for the file). A long-lived process should share one handle per path.",
|
|
||||||
handles,
|
|
||||||
db.db_path,
|
|
||||||
_READ_POOL_MAX,
|
|
||||||
)
|
|
||||||
|
|
||||||
def acquire(self, requester: "SessionDB") -> bool:
|
|
||||||
"""Take a permit for a new read connection, or refuse (caller then reads
|
|
||||||
via the locked writer connection — slower, never an error). Gates,
|
|
||||||
broadest first: fd headroom, process-wide ceiling, this file's ceiling."""
|
|
||||||
if not _fd_headroom_ok():
|
|
||||||
global _read_open_denied_fd_headroom
|
|
||||||
with _read_budgets_lock:
|
|
||||||
_read_open_denied_fd_headroom += 1
|
|
||||||
return False
|
|
||||||
if not self._acquire_process_permit():
|
|
||||||
return False
|
|
||||||
if self._acquire_path_permit(requester):
|
|
||||||
return True
|
|
||||||
_process_read_permits.release()
|
|
||||||
return False
|
|
||||||
|
|
||||||
def release(self) -> None:
|
|
||||||
"""Return one connection's permits. Pairs with a successful acquire()."""
|
|
||||||
self.permits.release()
|
|
||||||
_process_read_permits.release()
|
|
||||||
|
|
||||||
def _acquire_process_permit(self) -> bool:
|
|
||||||
# Another thread may take a freed permit first; that is a legitimate
|
|
||||||
# loss, and the caller degrades to the writer lock rather than looping.
|
|
||||||
return _process_read_permits.acquire(blocking=False) or (
|
|
||||||
_reclaim_idle_read_conn_anywhere() and _process_read_permits.acquire(blocking=False)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _acquire_path_permit(self, requester: "SessionDB") -> bool:
|
|
||||||
return self.permits.acquire(blocking=False) or (
|
|
||||||
self.reclaim_idle(exclude=requester) and self.permits.acquire(blocking=False)
|
|
||||||
)
|
|
||||||
|
|
||||||
def reclaim_idle(self, exclude: "Optional[SessionDB]" = None) -> bool:
|
|
||||||
"""Close one idle pooled connection held by a member; True if one went.
|
|
||||||
Its release() returns both permits, so both ceilings reclaim through here."""
|
|
||||||
with self._lock:
|
|
||||||
members = [db for db in self._members if db is not exclude]
|
|
||||||
return any(member._evict_one_idle_read_conn() for member in members)
|
|
||||||
|
|
||||||
|
|
||||||
# canonical db path -> permits for that file. Weak values: the budget lives as
|
|
||||||
# long as some SessionDB on the path holds it, so tmp_path churn can't grow this.
|
|
||||||
_read_budgets: "weakref.WeakValueDictionary[str, _PathReadBudget]" = (weakref.WeakValueDictionary())
|
|
||||||
_read_budgets_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _read_budget_key(db_path) -> str:
|
|
||||||
"""Canonicalise a db path so two spellings share one budget."""
|
|
||||||
try:
|
|
||||||
return str(Path(db_path).resolve())
|
|
||||||
except OSError:
|
|
||||||
return str(db_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_budget_for(db_path) -> _PathReadBudget:
|
|
||||||
key = _read_budget_key(db_path)
|
|
||||||
with _read_budgets_lock:
|
|
||||||
budget = _read_budgets.get(key)
|
|
||||||
if budget is None:
|
|
||||||
budget = _PathReadBudget()
|
|
||||||
_read_budgets[key] = budget
|
|
||||||
return budget
|
|
||||||
|
|
||||||
|
|
||||||
# Import-time snapshot so _default_db_path() can detect a deliberately
|
# Import-time snapshot so _default_db_path() can detect a deliberately
|
||||||
# re-pointed DEFAULT_DB_PATH (tests monkeypatch the constant directly).
|
# re-pointed DEFAULT_DB_PATH (tests monkeypatch the constant directly).
|
||||||
_IMPORT_DEFAULT_DB_PATH = DEFAULT_DB_PATH
|
_IMPORT_DEFAULT_DB_PATH = DEFAULT_DB_PATH
|
||||||
|
|||||||
225
hermes_state_readpool.py
Normal file
225
hermes_state_readpool.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""Read-connection budgeting for SessionDB's WAL read path: per-file and
|
||||||
|
process-wide permit ceilings plus a descriptor-headroom gate, so N handles on
|
||||||
|
one state.db cannot walk the process into EMFILE. Idle pooled connections keep
|
||||||
|
their permit and are reclaimable across peers on the same path."""
|
||||||
|
|
||||||
|
import errno
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import weakref
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Iterator, Optional
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
|
||||||
|
# caplog tests pin the "hermes_state" logger name.
|
||||||
|
logger = logging.getLogger("hermes_state")
|
||||||
|
|
||||||
|
# Ceiling on read-only connections ALIVE at once against one database FILE
|
||||||
|
# (idle pooled + checked out, summed over every SessionDB on that file). One
|
||||||
|
# constant for both the pool maxsize and the permit count: a LifoQueue only caps
|
||||||
|
# how many are *returned*; with open-on-miss, N readers hitting an empty pool
|
||||||
|
# all open and peak at N, and EMFILE is a peak-instant condition. So a
|
||||||
|
# connection holds a permit for its whole lifetime (_get_read_conn ->
|
||||||
|
# _close_read_conn); once permits are gone reads degrade to the locked writer
|
||||||
|
# connection — slower, but not a process-wide wedge the supervisor can't see.
|
||||||
|
_READ_POOL_MAX = 8
|
||||||
|
|
||||||
|
# Ceiling on read-only connections ALIVE in this PROCESS across every state.db
|
||||||
|
# (a multiplexed gateway opens one per profile, so a per-file cap still scales
|
||||||
|
# with profile count). Three profiles' worth; past it readers degrade to the
|
||||||
|
# writer connection for the same reason as _READ_POOL_MAX.
|
||||||
|
_READ_POOL_PROCESS_MAX = 24
|
||||||
|
|
||||||
|
# Warn past this many SessionDB handles on one file in one process. Diagnostic
|
||||||
|
# only: writer connections cannot be rationed the way read connections can.
|
||||||
|
_HANDLES_PER_PATH_WARN = 4
|
||||||
|
|
||||||
|
# Descriptors kept in reserve for everything that is NOT this module (httpx
|
||||||
|
# sockets, terminal pipes, log files): SQLite's share is only part of the fd
|
||||||
|
# table, and the EMFILE it pushes over surfaces elsewhere (terminal_tool).
|
||||||
|
_FD_HEADROOM_RESERVE = 64
|
||||||
|
|
||||||
|
# The fd count is a directory listing; cache it briefly so a read burst isn't a
|
||||||
|
# syscall per query. Staleness lets through at most the ceiling's worth of opens.
|
||||||
|
_FD_USAGE_CACHE_SECONDS = 0.25
|
||||||
|
|
||||||
|
_process_read_permits = threading.BoundedSemaphore(_READ_POOL_PROCESS_MAX)
|
||||||
|
|
||||||
|
# Read opens refused for low descriptor headroom — the only visible signal the
|
||||||
|
# guard fires. Guarded by _read_budgets_lock.
|
||||||
|
_read_open_denied_fd_headroom = 0
|
||||||
|
|
||||||
|
_fd_usage_lock = threading.Lock()
|
||||||
|
_fd_usage_cache: "tuple[float, Optional[int]]" = (0.0, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _proc_fd_targets(pid: int) -> Iterator[str]:
|
||||||
|
"""readlink() of every entry in /proc/<pid>/fd (unreadable links skipped).
|
||||||
|
Raises OSError when the fd directory itself cannot be listed."""
|
||||||
|
fd_dir = f"/proc/{pid}/fd"
|
||||||
|
for fd in os.listdir(fd_dir):
|
||||||
|
try:
|
||||||
|
yield os.readlink(f"{fd_dir}/{fd}")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
def _open_fd_count() -> Optional[int]:
|
||||||
|
"""Open descriptors in THIS process; None when unmeasurable (Windows: no fd
|
||||||
|
dir and no RLIMIT_NOFILE, correctly inert — its limit is thousands); -1 when
|
||||||
|
the probe itself hit EMFILE/ENFILE (that IS the answer: no headroom)."""
|
||||||
|
for fd_dir in ("/proc/self/fd", "/dev/fd"):
|
||||||
|
try:
|
||||||
|
return len(os.listdir(fd_dir))
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno in (errno.EMFILE, errno.ENFILE):
|
||||||
|
return -1
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _fd_soft_limit() -> Optional[int]:
|
||||||
|
"""The process's soft RLIMIT_NOFILE, or None when there is no usable one."""
|
||||||
|
try:
|
||||||
|
import resource
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
soft, _hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
if soft in (resource.RLIM_INFINITY, -1):
|
||||||
|
return None
|
||||||
|
return int(soft)
|
||||||
|
|
||||||
|
|
||||||
|
def _fd_headroom_ok() -> bool:
|
||||||
|
"""Can the process spare a descriptor for a new read connection?
|
||||||
|
Fails OPEN when unmeasurable (refusing every read there would be a
|
||||||
|
self-inflicted convoy); fails CLOSED only on evidence (measured shortfall,
|
||||||
|
or a probe that couldn't get a descriptor itself)."""
|
||||||
|
soft = _fd_soft_limit()
|
||||||
|
if soft is None:
|
||||||
|
return True
|
||||||
|
global _fd_usage_cache
|
||||||
|
now = time.monotonic()
|
||||||
|
with _fd_usage_lock:
|
||||||
|
stamp, cached = _fd_usage_cache
|
||||||
|
fresh = cached is not None and (now - stamp) < _FD_USAGE_CACHE_SECONDS
|
||||||
|
if not fresh:
|
||||||
|
cached = _open_fd_count()
|
||||||
|
with _fd_usage_lock:
|
||||||
|
_fd_usage_cache = (now, cached)
|
||||||
|
if cached is None:
|
||||||
|
return True
|
||||||
|
return cached >= 0 and (soft - cached) > _FD_HEADROOM_RESERVE
|
||||||
|
|
||||||
|
|
||||||
|
def _reclaim_idle_read_conn_anywhere() -> bool:
|
||||||
|
"""Close one idle read connection on ANY path: the process ceiling is shared
|
||||||
|
across files, so a quiet profile must not hold descriptors a busy one needs."""
|
||||||
|
with _read_budgets_lock:
|
||||||
|
budgets = list(_read_budgets.values())
|
||||||
|
return any(budget.reclaim_idle() for budget in budgets)
|
||||||
|
|
||||||
|
|
||||||
|
class _PathReadBudget:
|
||||||
|
"""Read-connection permits for ONE database file, shared process-wide:
|
||||||
|
per-instance semaphores let N SessionDBs on one file peak at N x (1 + MAX)
|
||||||
|
and walk into EMFILE. An idle pooled connection keeps its permit, so a
|
||||||
|
permit miss first reclaims an IDLE connection from a peer on the same path
|
||||||
|
(idle descriptors are transferable, in-use ones are not)."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.permits = threading.BoundedSemaphore(_READ_POOL_MAX)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
# Weak: a SessionDB dropped without close() must not pin peers' budget.
|
||||||
|
self._members: "weakref.WeakSet[SessionDB]" = weakref.WeakSet()
|
||||||
|
self._duplicate_handles_warned = False
|
||||||
|
|
||||||
|
def register(self, db: "SessionDB") -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._members.add(db)
|
||||||
|
handles = len(self._members)
|
||||||
|
warn = (handles > _HANDLES_PER_PATH_WARN and not self._duplicate_handles_warned)
|
||||||
|
if warn:
|
||||||
|
self._duplicate_handles_warned = True
|
||||||
|
if warn:
|
||||||
|
# Writer connections cannot be capped (a SessionDB without one cannot
|
||||||
|
# write); the only bound is not opening redundant handles. Make the
|
||||||
|
# next duplicate visible before it becomes an incident.
|
||||||
|
logger.warning(
|
||||||
|
"%d live SessionDB handles on %s in this process; each holds "
|
||||||
|
"its own writer connection (read connections are capped at %d "
|
||||||
|
"for the file). A long-lived process should share one handle per path.",
|
||||||
|
handles,
|
||||||
|
db.db_path,
|
||||||
|
_READ_POOL_MAX,
|
||||||
|
)
|
||||||
|
|
||||||
|
def acquire(self, requester: "SessionDB") -> bool:
|
||||||
|
"""Take a permit for a new read connection, or refuse (caller then reads
|
||||||
|
via the locked writer connection — slower, never an error). Gates,
|
||||||
|
broadest first: fd headroom, process-wide ceiling, this file's ceiling."""
|
||||||
|
if not _fd_headroom_ok():
|
||||||
|
global _read_open_denied_fd_headroom
|
||||||
|
with _read_budgets_lock:
|
||||||
|
_read_open_denied_fd_headroom += 1
|
||||||
|
return False
|
||||||
|
if not self._acquire_process_permit():
|
||||||
|
return False
|
||||||
|
if self._acquire_path_permit(requester):
|
||||||
|
return True
|
||||||
|
_process_read_permits.release()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
"""Return one connection's permits. Pairs with a successful acquire()."""
|
||||||
|
self.permits.release()
|
||||||
|
_process_read_permits.release()
|
||||||
|
|
||||||
|
def _acquire_process_permit(self) -> bool:
|
||||||
|
# Another thread may take a freed permit first; that is a legitimate
|
||||||
|
# loss, and the caller degrades to the writer lock rather than looping.
|
||||||
|
return _process_read_permits.acquire(blocking=False) or (
|
||||||
|
_reclaim_idle_read_conn_anywhere() and _process_read_permits.acquire(blocking=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _acquire_path_permit(self, requester: "SessionDB") -> bool:
|
||||||
|
return self.permits.acquire(blocking=False) or (
|
||||||
|
self.reclaim_idle(exclude=requester) and self.permits.acquire(blocking=False)
|
||||||
|
)
|
||||||
|
|
||||||
|
def reclaim_idle(self, exclude: "Optional[SessionDB]" = None) -> bool:
|
||||||
|
"""Close one idle pooled connection held by a member; True if one went.
|
||||||
|
Its release() returns both permits, so both ceilings reclaim through here."""
|
||||||
|
with self._lock:
|
||||||
|
members = [db for db in self._members if db is not exclude]
|
||||||
|
return any(member._evict_one_idle_read_conn() for member in members)
|
||||||
|
|
||||||
|
|
||||||
|
# canonical db path -> permits for that file. Weak values: the budget lives as
|
||||||
|
# long as some SessionDB on the path holds it, so tmp_path churn can't grow this.
|
||||||
|
_read_budgets: "weakref.WeakValueDictionary[str, _PathReadBudget]" = (weakref.WeakValueDictionary())
|
||||||
|
_read_budgets_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_budget_key(db_path) -> str:
|
||||||
|
"""Canonicalise a db path so two spellings share one budget."""
|
||||||
|
try:
|
||||||
|
return str(Path(db_path).resolve())
|
||||||
|
except OSError:
|
||||||
|
return str(db_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_budget_for(db_path) -> _PathReadBudget:
|
||||||
|
key = _read_budget_key(db_path)
|
||||||
|
with _read_budgets_lock:
|
||||||
|
budget = _read_budgets.get(key)
|
||||||
|
if budget is None:
|
||||||
|
budget = _PathReadBudget()
|
||||||
|
_read_budgets[key] = budget
|
||||||
|
return budget
|
||||||
@@ -582,7 +582,7 @@ def test_no_read_connection_is_opened_without_descriptor_headroom(db, monkeypatc
|
|||||||
subprocess pipes, and EMFILE lands on whoever asks next -- which in the
|
subprocess pipes, and EMFILE lands on whoever asks next -- which in the
|
||||||
report was terminal_tool, not SQLite.
|
report was terminal_tool, not SQLite.
|
||||||
"""
|
"""
|
||||||
import hermes_state
|
import hermes_state_readpool as readpool
|
||||||
|
|
||||||
# Drain the pool so the next read must OPEN rather than reuse.
|
# Drain the pool so the next read must OPEN rather than reuse.
|
||||||
while True:
|
while True:
|
||||||
@@ -591,19 +591,19 @@ def test_no_read_connection_is_opened_without_descriptor_headroom(db, monkeypatc
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
|
|
||||||
monkeypatch.setattr(hermes_state, "_fd_soft_limit", lambda: 256)
|
monkeypatch.setattr(readpool, "_fd_soft_limit", lambda: 256)
|
||||||
monkeypatch.setattr(hermes_state, "_open_fd_count", lambda: 250)
|
monkeypatch.setattr(readpool, "_open_fd_count", lambda: 250)
|
||||||
monkeypatch.setattr(hermes_state, "_fd_usage_cache", (0.0, None))
|
monkeypatch.setattr(readpool, "_fd_usage_cache", (0.0, None))
|
||||||
|
|
||||||
assert db._get_read_conn() is None, "a read connection was opened with 6 fds left"
|
assert db._get_read_conn() is None, "a read connection was opened with 6 fds left"
|
||||||
# The read still has to work -- degradation, not failure.
|
# The read still has to work -- degradation, not failure.
|
||||||
assert db.get_session("s1") is not None
|
assert db.get_session("s1") is not None
|
||||||
assert hermes_state._read_open_denied_fd_headroom > 0, (
|
assert readpool._read_open_denied_fd_headroom > 0, (
|
||||||
"the guard fired without leaving a trace to diagnose it from"
|
"the guard fired without leaving a trace to diagnose it from"
|
||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(hermes_state, "_open_fd_count", lambda: 10)
|
monkeypatch.setattr(readpool, "_open_fd_count", lambda: 10)
|
||||||
monkeypatch.setattr(hermes_state, "_fd_usage_cache", (0.0, None))
|
monkeypatch.setattr(readpool, "_fd_usage_cache", (0.0, None))
|
||||||
conn = db._get_read_conn()
|
conn = db._get_read_conn()
|
||||||
assert conn is not None, "headroom returned but the read path stayed degraded"
|
assert conn is not None, "headroom returned but the read path stayed degraded"
|
||||||
db._close_read_conn(conn)
|
db._close_read_conn(conn)
|
||||||
@@ -611,17 +611,17 @@ def test_no_read_connection_is_opened_without_descriptor_headroom(db, monkeypatc
|
|||||||
|
|
||||||
def test_fd_headroom_guard_fails_open_where_it_cannot_measure(monkeypatch):
|
def test_fd_headroom_guard_fails_open_where_it_cannot_measure(monkeypatch):
|
||||||
"""No RLIMIT_NOFILE (Windows) means unmeasurable, not tight."""
|
"""No RLIMIT_NOFILE (Windows) means unmeasurable, not tight."""
|
||||||
import hermes_state
|
import hermes_state_readpool as readpool
|
||||||
|
|
||||||
monkeypatch.setattr(hermes_state, "_fd_soft_limit", lambda: None)
|
monkeypatch.setattr(readpool, "_fd_soft_limit", lambda: None)
|
||||||
assert hermes_state._fd_headroom_ok() is True
|
assert readpool._fd_headroom_ok() is True
|
||||||
|
|
||||||
# A probe that could not get a descriptor of its own is evidence, not
|
# A probe that could not get a descriptor of its own is evidence, not
|
||||||
# absence of evidence.
|
# absence of evidence.
|
||||||
monkeypatch.setattr(hermes_state, "_fd_soft_limit", lambda: 256)
|
monkeypatch.setattr(readpool, "_fd_soft_limit", lambda: 256)
|
||||||
monkeypatch.setattr(hermes_state, "_open_fd_count", lambda: -1)
|
monkeypatch.setattr(readpool, "_open_fd_count", lambda: -1)
|
||||||
monkeypatch.setattr(hermes_state, "_fd_usage_cache", (0.0, None))
|
monkeypatch.setattr(readpool, "_fd_usage_cache", (0.0, None))
|
||||||
assert hermes_state._fd_headroom_ok() is False
|
assert readpool._fd_headroom_ok() is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.requires_wal
|
@pytest.mark.requires_wal
|
||||||
|
|||||||
Reference in New Issue
Block a user