fix(change-watcher): probe profile liveness once per profiles/ mtime + TTL, not on every 2 s pairing tick
#114041 §2: hermes-change-watcher re-stats the profile trees independently of list_profiles(). The cost was _pairing_sig: every 2 s it iterated profiles/ and ran named_profile_is_live on each entry (is_dir + up to 6 identity markers x is_file/is_symlink + tombstone exists ~= 14 stats per profile), then listed each root. That also covers the §3 "profile liveness checks reached from the watcher" contributor. The set of live profiles only moves when a profile dir is added/removed (which bumps the profiles/ dir mtime) or a marker/tombstone lands inside an existing dir. _pairing_roots caches the roots on (home, profiles/ mtime_ns) with a 30 s TTL: per tick the watcher now does one stat of profiles/ plus the ledger iterdir on the known roots, so pairing latency is unchanged for every existing profile; a brand-new profile is picked up on the next tick via the mtime bump, and a marker-only change within 30 s. No new event, interval or config surface. Test: two ticks -> one liveness probe while the ledger write is still broadcast; a new profile dir (mtime bump) re-probes and its ledger fires. Red on the previous change_watcher.py (liveness ran on both ticks).
This commit is contained in:
@@ -26,6 +26,7 @@ def watcher_home(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(server, "_change_checked_at", {})
|
||||
monkeypatch.setattr(server, "_change_broadcast_at", {})
|
||||
monkeypatch.setattr(server, "_bot_relay_outbox_seen", 0)
|
||||
monkeypatch.setattr(server, "_pairing_roots_cache", None, raising=False)
|
||||
|
||||
events = []
|
||||
monkeypatch.setattr(
|
||||
@@ -125,6 +126,38 @@ def test_pairing_signal_follows_a_profile_store(watcher_home):
|
||||
assert ("pairing.changed", {}) in events
|
||||
|
||||
|
||||
def test_pairing_probe_reuses_live_profile_roots_until_the_profile_set_moves(watcher_home, monkeypatch):
|
||||
"""The per-profile liveness probe (~14 stats each) runs once per profiles/ mtime + TTL, not
|
||||
on every 2 s tick (#114041 §2); ledger writes under known roots are still seen each tick,
|
||||
and a newly created profile is picked up because creating it bumps the parent's mtime."""
|
||||
import hermes_constants
|
||||
|
||||
home, events = watcher_home
|
||||
live_calls = []
|
||||
real_live = hermes_constants.named_profile_is_live
|
||||
monkeypatch.setattr(hermes_constants, "named_profile_is_live",
|
||||
lambda p: live_calls.append(p.name) or real_live(p))
|
||||
|
||||
def _profile(name):
|
||||
(home / "profiles" / name / "platforms" / "pairing").mkdir(parents=True)
|
||||
(home / "profiles" / name / "config.yaml").write_text("{}\n", encoding="utf-8")
|
||||
|
||||
_profile("work")
|
||||
server._broadcast_watched_changes(now=0.0)
|
||||
(home / "profiles" / "work" / "platforms" / "pairing" / "telegram-pending.json").write_text("{}", encoding="utf-8")
|
||||
server._broadcast_watched_changes(now=10.0)
|
||||
assert events == [("pairing.changed", {})]
|
||||
assert live_calls == ["work"] # second tick reused the cached roots, still saw the ledger
|
||||
|
||||
_profile("play")
|
||||
os.utime(home / "profiles", ns=(0, 10**18)) # deterministic parent-mtime bump
|
||||
server._broadcast_watched_changes(now=20.0)
|
||||
(home / "profiles" / "play" / "platforms" / "pairing" / "discord-approved.json").write_text("{}", encoding="utf-8")
|
||||
server._broadcast_watched_changes(now=30.0)
|
||||
assert events == [("pairing.changed", {})] * 2
|
||||
assert sorted(live_calls) == ["play", "work", "work"]
|
||||
|
||||
|
||||
def test_rate_limit_churn_does_not_broadcast_pairing_changed(watcher_home):
|
||||
"""_rate_limits.json moves on every unauthorized DM, including ones that
|
||||
produce no new row — signalling on it would refetch for nothing."""
|
||||
|
||||
@@ -133,16 +133,8 @@ def _pairing_sig():
|
||||
"""Newest mtime across every profile's pairing ledgers (legacy ``pairing/`` and
|
||||
``platforms/pairing/``): the gateway process writes pending codes, so the files are the only
|
||||
shared signal (a pairing request moves nothing in gateway_state.json)."""
|
||||
home = _watcher_home()
|
||||
roots = [home / "pairing", home / "platforms" / "pairing"]
|
||||
from hermes_constants import named_profile_is_live
|
||||
|
||||
with contextlib.suppress(OSError):
|
||||
for profile_dir in (home / "profiles").iterdir():
|
||||
if named_profile_is_live(profile_dir):
|
||||
roots += [profile_dir / "pairing", profile_dir / "platforms" / "pairing"]
|
||||
entries = []
|
||||
for root in roots:
|
||||
for root in _pairing_roots(_watcher_home()):
|
||||
with contextlib.suppress(OSError):
|
||||
# Only the ledgers: _rate_limits.json moves on every unauthorized DM.
|
||||
entries += [
|
||||
@@ -150,6 +142,31 @@ def _pairing_sig():
|
||||
return _newest_mtime_ns(entries)
|
||||
|
||||
|
||||
# Live-profile pairing roots, cached on (home, ``profiles/`` dir mtime) with a TTL. The liveness
|
||||
# probe costs ~14 stats per profile; on the 2 s tick that was the watcher's share of the idle
|
||||
# profile-tree burn (#114041 §2/§3). The profile SET only moves when a dir is added or removed —
|
||||
# which bumps the parent's mtime — while a marker/tombstone landing inside one is caught by the TTL.
|
||||
_PAIRING_ROOTS_TTL_S = 30.0
|
||||
_pairing_roots_cache: tuple[Path, int | None, float, list] | None = None
|
||||
|
||||
|
||||
def _pairing_roots(home: Path) -> list:
|
||||
global _pairing_roots_cache
|
||||
profiles_dir = home / "profiles"
|
||||
dir_mtime, now = _watcher_mtime_ns(profiles_dir), time.monotonic()
|
||||
cached = _pairing_roots_cache
|
||||
if cached is not None and cached[0] == home and cached[1] == dir_mtime and now - cached[2] < _PAIRING_ROOTS_TTL_S:
|
||||
return cached[3]
|
||||
from hermes_constants import named_profile_is_live
|
||||
roots = [home / "pairing", home / "platforms" / "pairing"]
|
||||
with contextlib.suppress(OSError):
|
||||
for profile_dir in profiles_dir.iterdir():
|
||||
if named_profile_is_live(profile_dir):
|
||||
roots += [profile_dir / "pairing", profile_dir / "platforms" / "pairing"]
|
||||
_pairing_roots_cache = (home, dir_mtime, now, roots)
|
||||
return roots
|
||||
|
||||
|
||||
# Newest outbox-envelope mtime EVER seen (monotone): a drain empties the outbox,
|
||||
# and falling back to None would fire a spurious pending event after every drain.
|
||||
_bot_relay_outbox_seen = 0
|
||||
|
||||
Reference in New Issue
Block a user