Files
hermes-agent/tests/gateway/test_sticker_cache_off_loop.py
kshitijk4poor 9056bfb5a4 docs(gateway): drop references to the deleted AST sweep and gate-proof tests
ec9e7096a9 removed the lexical AST sweeps and the starve-the-loop gate
proofs; the mark() docstring and both test-module docstrings still pointed
at them. Also collapse the sticker_cache lock comment that repeated the
test docstring verbatim.

PROOF: grep -n 'AST sweep\|gate-proof' over the four files -> no hits;
ruff clean; tests unchanged (4 files, 7 passed).
2026-09-22 15:54:29 +05:30

103 lines
3.7 KiB
Python

"""The sticker-description cache write must not block the event loop.
``gateway.sticker_cache._save_cache`` ends in ``os.replace``, whose duration is
unbounded under filesystem pressure. Its only production caller is Telegram's
``_handle_sticker`` -- an inbound-message coroutine -- so every sticker that
missed the cache paid that rename inline on the loop, stalling every other
adapter and every in-flight turn in the process.
These tests pin the two invariants of the off-loop form: the write runs on a
worker thread, and the lock keeps two concurrent load/merge/save triples from
losing an entry in the DURABLE file.
"""
import asyncio
import json
import threading
import pytest
from gateway import sticker_cache
@pytest.fixture()
def cache_file(tmp_path, monkeypatch):
path = tmp_path / "sticker_cache.json"
monkeypatch.setattr(sticker_cache, "CACHE_PATH", path, raising=True)
return path
def test_the_write_runs_off_the_loop_thread(cache_file, monkeypatch):
"""The rename must execute on a worker thread, not the loop thread."""
seen: dict[str, int] = {}
real_save = sticker_cache._save_cache
def _record(cache):
seen["thread"] = threading.get_ident()
return real_save(cache)
monkeypatch.setattr(sticker_cache, "_save_cache", _record, raising=True)
async def scenario():
seen["loop"] = threading.get_ident()
await sticker_cache.cache_sticker_description_async("uid_thread", "A dog")
asyncio.run(scenario())
assert seen["thread"] != seen["loop"], (
"the cache write ran on the event-loop thread; the off-loop dispatch "
"is not in effect"
)
def test_concurrent_writes_do_not_lose_an_entry(cache_file, monkeypatch):
"""Off-loop dispatch removed the loop's accidental serialization.
``cache_sticker_description`` is a read-modify-write: ``_load_cache`` ->
merge -> ``_save_cache``. ``atomic_json_write`` makes each WRITE atomic;
it does not make the TRIPLE atomic. While the call was inline on the loop,
the loop serialized every caller and the race could not be observed.
Moving it to a worker thread introduces real concurrency, so the lock has
to arrive in the SAME change -- otherwise two stickers described at once
silently drop one of the two descriptions.
Asserts on the DURABLE FILE, not on the API.
"""
# With the lock in place the second caller cannot reach the barrier, so
# the first one MUST time out here. That timeout is the green path's
# cost, so keep it small.
entered = threading.Barrier(2, timeout=0.5)
real_load = sticker_cache._load_cache
done = threading.Event()
def _interleaving_load():
data = real_load()
# Force both callers to observe the SAME pre-state, which is what a
# lost update requires. Only the first two callers rendezvous.
if not done.is_set():
try:
entered.wait()
except threading.BrokenBarrierError:
pass
return data
monkeypatch.setattr(
sticker_cache, "_load_cache", _interleaving_load, raising=True
)
async def scenario():
try:
await asyncio.gather(
sticker_cache.cache_sticker_description_async("uid_a", "A"),
sticker_cache.cache_sticker_description_async("uid_b", "B"),
)
finally:
done.set()
asyncio.run(scenario())
durable = json.loads(cache_file.read_text())
assert sorted(durable) == ["uid_a", "uid_b"], (
"a concurrent read-modify-write lost an entry from the durable cache "
f"file: keys = {sorted(durable)}. atomic_json_write makes each write "
"atomic, not the load/merge/save triple."
)