fix(credential-pool): throttle "no available entries" log to stop Windows log-lock storm (contributes to #62698) (#66338)

* fix(credential-pool): throttle "no available entries" log to stop Windows log-lock storm

Credential selection runs on a hot path (every model call plus auxiliary
tasks), so an empty/exhausted pool logged "no available entries" at INFO on
*every* selection. On Windows, where multiple Hermes processes share one
rotating log guarded by concurrent-log-handler's cross-process lock, that
per-selection volume storms the lock (RuntimeError: Cannot acquire lock after
20 attempts), pegs a core, and stalls the asyncio event loop long enough that
the Desktop backend readiness probe times out ("Timed out connecting to Hermes
backend after 15000ms") even though the backend already announced
HERMES_BACKEND_READY.

Log the condition at most once per 60s window, re-arming on a successful
selection so recovery->re-exhaustion still surfaces promptly. Same fix class as
the warn-once dedup in #58265.

* test(credential-pool): cover no-available-entries log throttle

Assert the empty-pool INFO line logs at most once per throttle window, logs
again after the window elapses, and re-arms on a successful selection so a
recover->re-exhaust transition surfaces promptly. Uses a deterministic fake
monotonic clock (no sleeps, no network).
This commit is contained in:
HexLab 2026-07-18 00:08:46 +07:00 committed by GitHub
parent ef9e0c98f5
commit 594308d4bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 143 additions and 1 deletions

View file

@ -114,6 +114,20 @@ EXHAUSTED_TTL_401_SECONDS = 5 * 60 # 5 minutes
EXHAUSTED_TTL_429_SECONDS = 60 * 60 # 1 hour
EXHAUSTED_TTL_DEFAULT_SECONDS = 60 * 60 # 1 hour
# Throttle window for the "no available entries" INFO line. Credential
# selection runs on a hot path (every model call, plus auxiliary tasks like
# compression/moa/titles), so when a pool is empty or fully exhausted the
# un-throttled log fires on *every* selection. On Windows several Hermes
# processes share one rotating log guarded by concurrent-log-handler's
# cross-process lock; that per-selection volume storms the lock
# (``RuntimeError: Cannot acquire lock after 20 attempts``), pegs a core, and
# stalls the asyncio event loop long enough to fail the Desktop backend
# readiness handshake ("Timed out connecting to Hermes backend after
# 15000ms"). Logging the condition at most once per window preserves the
# signal while removing the storm — same class of fix as the warn-once
# dedup in #58265.
NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS = 60.0
# Pool key prefix for custom OpenAI-compatible endpoints.
# Custom endpoints all share provider='custom' but are keyed by their
# custom_providers name: 'custom:<normalized_name>'.
@ -566,6 +580,12 @@ class CredentialPool:
self._lock = threading.Lock()
self._active_leases: Dict[str, int] = {}
self._max_concurrent = DEFAULT_MAX_CONCURRENT_PER_CREDENTIAL
# Monotonic timestamp of the last "no available entries" log, used to
# throttle that message so an empty/exhausted pool cannot storm the
# shared rotating log (see NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS).
# Re-armed to None on every successful selection so a recover→re-exhaust
# transition logs promptly instead of being swallowed by a stale window.
self._last_no_entries_log_at: Optional[float] = None
def has_credentials(self) -> bool:
return bool(self._entries)
@ -1604,13 +1624,32 @@ class CredentialPool:
self._persist(removed_ids=entries_to_prune)
return available
def _log_no_available_entries(self) -> None:
"""Emit the empty-pool INFO line at most once per throttle window.
Called on every selection while the pool is empty/exhausted. Without
throttling this storms the Windows cross-process log lock and stalls the
event loop (see NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS).
"""
now = time.monotonic()
last = self._last_no_entries_log_at
if last is not None and (now - last) < NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS:
return
self._last_no_entries_log_at = now
logger.info("credential pool: no available entries (all exhausted or empty)")
def _select_unlocked(self, *, refresh: bool = True) -> Optional[PooledCredential]:
available = self._available_entries(clear_expired=True, refresh=refresh)
if not available:
self._current_id = None
logger.info("credential pool: no available entries (all exhausted or empty)")
self._log_no_available_entries()
return None
# A successful selection means the pool recovered; re-arm the throttle
# so a later re-exhaustion logs immediately rather than being silenced
# by a window opened during the previous empty stretch.
self._last_no_entries_log_at = None
if self._strategy == STRATEGY_RANDOM:
entry = random.choice(available)
self._current_id = entry.id

View file

@ -0,0 +1,103 @@
"""Regression: the credential-pool "no available entries" INFO line must be
throttled so an empty/exhausted pool cannot storm the shared rotating log.
Selection runs on a hot path (every model call plus auxiliary tasks). Before
the throttle, an empty/exhausted pool logged this line on *every* select(),
which on Windows storms concurrent-log-handler's cross-process lock
("Cannot acquire lock after 20 attempts"), stalls the asyncio event loop, and
fails the Desktop backend readiness handshake ("Timed out connecting to Hermes
backend after 15000ms"). See #58265 for the same fix class on another message.
"""
from __future__ import annotations
import logging
from agent.credential_pool import (
NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS,
CredentialPool,
PooledCredential,
)
_NO_ENTRIES_MSG = "credential pool: no available entries (all exhausted or empty)"
class _FakeClock:
"""Deterministic monotonic clock driven by the test."""
def __init__(self) -> None:
self.now = 1000.0
def __call__(self) -> float:
return self.now
def _no_entries_records(caplog) -> list[logging.LogRecord]:
return [r for r in caplog.records if r.getMessage() == _NO_ENTRIES_MSG]
def _make_entry(entry_id: str) -> PooledCredential:
return PooledCredential(
provider="test",
id=entry_id,
label=entry_id,
auth_type="api_key",
source="manual",
access_token=f"tok-{entry_id}",
priority=0,
)
def test_empty_pool_logs_once_within_throttle_window(monkeypatch, caplog):
clock = _FakeClock()
monkeypatch.setattr("agent.credential_pool.time.monotonic", clock)
pool = CredentialPool("test", [])
with caplog.at_level(logging.INFO, logger="agent.credential_pool"):
for _ in range(50):
clock.now += 0.1 # tighter than the throttle window
assert pool.select() is None
# 50 selections, well inside one window -> exactly one log line.
assert len(_no_entries_records(caplog)) == 1
def test_logs_again_after_throttle_window_elapses(monkeypatch, caplog):
clock = _FakeClock()
monkeypatch.setattr("agent.credential_pool.time.monotonic", clock)
pool = CredentialPool("test", [])
with caplog.at_level(logging.INFO, logger="agent.credential_pool"):
assert pool.select() is None # log #1
clock.now += NO_AVAILABLE_ENTRIES_LOG_THROTTLE_SECONDS + 1
assert pool.select() is None # window elapsed -> log #2
assert len(_no_entries_records(caplog)) == 2
def test_successful_selection_rearms_throttle(monkeypatch, caplog):
"""A recover -> re-exhaust transition must log immediately, even inside the
window opened by the previous empty stretch (observability of the flip)."""
clock = _FakeClock()
monkeypatch.setattr("agent.credential_pool.time.monotonic", clock)
pool = CredentialPool("test", [])
with caplog.at_level(logging.INFO, logger="agent.credential_pool"):
assert pool.select() is None # log #1, throttle armed
clock.now += 1
assert pool.select() is None # within window -> no log
# Pool recovers: a successful selection re-arms the throttle.
pool._entries = [_make_entry("a")]
clock.now += 1
assert pool.select() is not None
# Pool empties again shortly after (< throttle window since log #1).
pool._entries = []
clock.now += 1
assert pool.select() is None # re-armed -> log #2 immediately
assert len(_no_entries_records(caplog)) == 2