Don't trip the AI circuit breaker on load shedding
With many simultaneous games, AI turns start in bursts and exceed the in-flight cap. That was counted as an AI failure, so after 5 bursts the breaker paused the AI for 30 s and every AI seat played random moves. - Only real failures (timeouts, lost connection, malformed/illegal answers, unexpected errors) count towards the breaker; the in-flight cap and "overloaded"/"expired" answers from the AI service do not - AI_MAX_INFLIGHT default 32 -> 64 (>= MAX_ACTIVE_GAMES, matches the AI service's AI_MAX_QUEUE) - Test that load shedding keeps the breaker closed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,9 +15,15 @@ requests are distributed round robin.
|
|||||||
A random legal action is played instead (so a game never gets stuck) when no
|
A random legal action is played instead (so a game never gets stuck) when no
|
||||||
instance is connected, it does not answer in time, it answers with an error or
|
instance is connected, it does not answer in time, it answers with an error or
|
||||||
an illegal action, too many requests are already waiting for it
|
an illegal action, too many requests are already waiting for it
|
||||||
(``max_inflight``), or the circuit breaker is open: after ``breaker_failures``
|
(``max_inflight``), or the circuit breaker is open.
|
||||||
failures in a row the AI is not asked for ``breaker_cooldown`` seconds, so a
|
|
||||||
struggling AI service is not buried under even more requests.
|
The circuit breaker protects against a *broken or hanging* AI service: after
|
||||||
|
``breaker_failures`` real failures in a row (timeouts, lost connection,
|
||||||
|
malformed or illegal answers, unexpected errors) the AI is not asked for
|
||||||
|
``breaker_cooldown`` seconds, so games don't wait ``timeout`` seconds for every
|
||||||
|
AI move. Deliberate load shedding is *not* a failure and does not trip it: the
|
||||||
|
in-flight cap, and the AI answering "overloaded" or "expired" (it is healthy
|
||||||
|
and answered immediately).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -36,6 +42,9 @@ from wizard_game import Action, Observation, action_from_dict
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Errors with which a healthy AI service sheds load (see wizard_ai/service/client.py).
|
||||||
|
LOAD_SHEDDING_ERRORS = ("overloaded", "expired")
|
||||||
|
|
||||||
|
|
||||||
class AIConnection:
|
class AIConnection:
|
||||||
def __init__(self, websocket: WebSocket, name: str) -> None:
|
def __init__(self, websocket: WebSocket, name: str) -> None:
|
||||||
@@ -94,7 +103,7 @@ class AIBridge:
|
|||||||
if loop.time() < self._breaker_open_until:
|
if loop.time() < self._breaker_open_until:
|
||||||
return self._fallback(observation, "AI paused after repeated failures", logging.DEBUG)
|
return self._fallback(observation, "AI paused after repeated failures", logging.DEBUG)
|
||||||
if len(connection.pending) >= self._max_inflight:
|
if len(connection.pending) >= self._max_inflight:
|
||||||
return self._failure(observation, f"{len(connection.pending)} AI requests already waiting")
|
return self._fallback(observation, f"{len(connection.pending)} AI requests already waiting")
|
||||||
|
|
||||||
request_id = uuid.uuid4().hex
|
request_id = uuid.uuid4().hex
|
||||||
future: asyncio.Future[dict[str, Any]] = loop.create_future()
|
future: asyncio.Future[dict[str, Any]] = loop.create_future()
|
||||||
@@ -113,7 +122,11 @@ class AIBridge:
|
|||||||
connection.pending.pop(request_id, None)
|
connection.pending.pop(request_id, None)
|
||||||
|
|
||||||
if reply.get("type") != "action":
|
if reply.get("type") != "action":
|
||||||
return self._failure(observation, f"AI error: {reply.get('message')}")
|
message = reply.get("message")
|
||||||
|
if message in LOAD_SHEDDING_ERRORS:
|
||||||
|
self._consecutive_failures = 0 # the service is alive and protecting itself
|
||||||
|
return self._fallback(observation, f"AI service {message}")
|
||||||
|
return self._failure(observation, f"AI error: {message}")
|
||||||
try:
|
try:
|
||||||
action = action_from_dict(reply.get("action", {}))
|
action = action_from_dict(reply.get("action", {}))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ class Settings:
|
|||||||
|
|
||||||
# --- AI service protection ----------------------------------------------
|
# --- AI service protection ----------------------------------------------
|
||||||
# Unanswered decide requests per AI connection before falling back.
|
# Unanswered decide requests per AI connection before falling back.
|
||||||
ai_max_inflight: int = _int("AI_MAX_INFLIGHT", 32)
|
# Each running game has at most one AI deciding at a time, so keep this at
|
||||||
|
# least MAX_ACTIVE_GAMES; 64 matches the AI service's AI_MAX_QUEUE.
|
||||||
|
ai_max_inflight: int = _int("AI_MAX_INFLIGHT", 64)
|
||||||
# Consecutive AI failures that pause AI requests for ai_breaker_cooldown seconds.
|
# Consecutive AI failures that pause AI requests for ai_breaker_cooldown seconds.
|
||||||
ai_breaker_failures: int = _int("AI_BREAKER_FAILURES", 5)
|
ai_breaker_failures: int = _int("AI_BREAKER_FAILURES", 5)
|
||||||
ai_breaker_cooldown: float = _float("AI_BREAKER_COOLDOWN", 30.0)
|
ai_breaker_cooldown: float = _float("AI_BREAKER_COOLDOWN", 30.0)
|
||||||
|
|||||||
@@ -200,6 +200,40 @@ def test_ai_circuit_breaker():
|
|||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
class OverloadedAIWebSocket:
|
||||||
|
"""Answers every request immediately with the given error message."""
|
||||||
|
|
||||||
|
def __init__(self, bridge_connection_ref, message):
|
||||||
|
self.ref, self.message, self.sent = bridge_connection_ref, message, []
|
||||||
|
|
||||||
|
async def send_json(self, request):
|
||||||
|
self.sent.append(request)
|
||||||
|
future = self.ref[0].pending[request["request_id"]]
|
||||||
|
future.set_result({"type": "error", "request_id": request["request_id"], "message": self.message})
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_shedding_does_not_trip_the_breaker():
|
||||||
|
async def scenario():
|
||||||
|
bridge = AIBridge(timeout=1, rng=random.Random(0), breaker_failures=2)
|
||||||
|
ref = []
|
||||||
|
ws = OverloadedAIWebSocket(ref, "overloaded")
|
||||||
|
connection = AIConnection(ws, "fake")
|
||||||
|
ref.append(connection)
|
||||||
|
bridge._connections.append(connection)
|
||||||
|
obs = observation()
|
||||||
|
for _ in range(5):
|
||||||
|
assert await bridge.decide(obs) in obs.legal_actions
|
||||||
|
assert len(ws.sent) == 5 # still asked every time
|
||||||
|
assert bridge.stats()["paused"] is False
|
||||||
|
|
||||||
|
ws.message = "model crashed" # a real failure counts
|
||||||
|
for _ in range(3):
|
||||||
|
await bridge.decide(obs)
|
||||||
|
assert len(ws.sent) == 7 and bridge.stats()["paused"] is True
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
def test_ai_inflight_limit():
|
def test_ai_inflight_limit():
|
||||||
async def scenario():
|
async def scenario():
|
||||||
bridge = AIBridge(timeout=1, rng=random.Random(0), max_inflight=1)
|
bridge = AIBridge(timeout=1, rng=random.Random(0), max_inflight=1)
|
||||||
@@ -208,7 +242,9 @@ def test_ai_inflight_limit():
|
|||||||
connection.pending["busy"] = asyncio.get_running_loop().create_future()
|
connection.pending["busy"] = asyncio.get_running_loop().create_future()
|
||||||
bridge._connections.append(connection)
|
bridge._connections.append(connection)
|
||||||
obs = observation()
|
obs = observation()
|
||||||
|
for _ in range(10):
|
||||||
assert await bridge.decide(obs) in obs.legal_actions
|
assert await bridge.decide(obs) in obs.legal_actions
|
||||||
assert ws.sent == [] # not even sent: too many requests already waiting
|
assert ws.sent == [] # not even sent: too many requests already waiting
|
||||||
|
assert bridge.stats()["paused"] is False # load shedding is not a failure
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|||||||
Reference in New Issue
Block a user