Files
hermes-agent/tests/hermes_cli/test_config_lock_free_cache_hit.py

98 lines
3.3 KiB
Python

"""A cached config read must not block behind a config WRITER.
``_load_config_impl`` served cache hits from inside ``_CONFIG_LOCK``, which
``save_config()`` holds across an atomic YAML write. A cache hit itself costs
~0.024ms-scale work, but measured on a clean tree the same cached read took
**10010ms** while another thread held the lock.
On a gateway that lands on the event loop: the per-message hook path
(``invoke_hook`` -> ``_resolve_hook_callback_timeout``) reads config, so one
background config write stalls every inbound message for its whole duration.
These tests drive the real functions against a temp HERMES_HOME -- no mocks of
the thing under test, no source reading.
"""
from __future__ import annotations
import threading
import time
import pytest
# Generous: the point is 10s-vs-instant, not a tight timing assertion.
MAX_BLOCKED_READ_SECS = 2.0
@pytest.fixture()
def config_home(tmp_path, monkeypatch):
home = tmp_path / "hermes-home"
home.mkdir()
lines = ["plugins:", " hook_callback_timeout: 30", "agent:", " max_turns: 500"]
for i in range(100):
lines += [f"section_{i}:", f" key_a: value_{i}"]
(home / "config.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(home))
from hermes_cli import config as cfgmod
cfgmod._LOAD_CONFIG_CACHE.clear()
cfgmod._RAW_CONFIG_CACHE.clear()
yield home
cfgmod._LOAD_CONFIG_CACHE.clear()
cfgmod._RAW_CONFIG_CACHE.clear()
def _time_cached_read_under_held_lock(reader):
"""Prime ``reader`` (lock-free), then time one more call while another thread holds
``_CONFIG_LOCK``. Returns ``(result, elapsed_seconds)``."""
from hermes_cli import config as cfgmod
reader() # prime the cache
holding = threading.Event()
release = threading.Event()
def _hold():
with cfgmod._CONFIG_LOCK:
holding.set()
release.wait(timeout=30)
thread = threading.Thread(target=_hold, daemon=True)
thread.start()
try:
assert holding.wait(timeout=10), "lock holder never started"
started = time.perf_counter()
result = reader()
elapsed = time.perf_counter() - started
finally:
release.set()
thread.join(timeout=10)
return result, elapsed
def test_cached_read_completes_while_another_thread_holds_the_config_lock(config_home):
from hermes_cli import config as cfgmod
cfg, elapsed = _time_cached_read_under_held_lock(cfgmod.load_config_readonly)
assert isinstance(cfg, dict) and cfg
assert elapsed < MAX_BLOCKED_READ_SECS, (
f"a cached load_config_readonly() blocked {elapsed:.1f}s behind a held "
"_CONFIG_LOCK; cache hits must not serialize against config writers"
)
def test_cached_raw_read_completes_while_another_thread_holds_the_config_lock(config_home):
"""Twin of the load_config test for the sibling ``read_raw_config`` fast path."""
from hermes_cli import config as cfgmod
raw, elapsed = _time_cached_read_under_held_lock(cfgmod.read_raw_config_readonly)
assert isinstance(raw, dict) and raw.get("plugins") == {"hook_callback_timeout": 30}
assert elapsed < MAX_BLOCKED_READ_SECS, (
f"a cached read_raw_config_readonly() blocked {elapsed:.1f}s behind a held "
"_CONFIG_LOCK; cache hits must not serialize against config writers"
)