Files
vllm-frontdoor/router/tests/test_wake_recovery.py
bing 80eef4ce6a Initial commit: router front-door vLLM stack
Three always-running vLLM services (text TP=2 GPU0+1, ocr + embed on GPU2,
sleep mode) behind a FastAPI router that auto-wakes models on request.
Tiered idle (sleep 15 min / offload 3 h), depth-aware 503s with
Retry-After, persisted wake-intent recovery, admin API on 127.0.0.1:8010.
Routine control via vllmctl is pure HTTP — no docker on the request path.

Verified: 91 router unit tests + 15-test E2E on real hardware
(measurements in CALIBRATION.md; design record in
.claude/memory/router-front-door-plan.md).

Old nginx stack files removed before git init; design survives in
.claude/memory/sleep-mode-implementation-plan.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 10:17:42 +00:00

297 lines
12 KiB
Python

"""Router-restart-mid-wake recovery (plan 6.4, E2E case 12).
`is_sleeping=false` does NOT mean "reload done": if the previous router died
between POST /wake_up and collective_rpc reload_weights, the backend happily
reports awake+healthy while serving garbage. The persisted wake-intent file
is what lets the new router tell the two apart.
"""
from __future__ import annotations
import asyncio
import json
import os
import httpx
from app import build_apps
from config import DEPTH_OFFLOADED, clone, load_config
from conftest import raw_asgi
from services import ServiceManager
def write_state(path, **services) -> None:
payload = {"version": 1, "updated": 0.0, "services": {
key: {"depth": value.get("depth"),
"wake_in_progress": value.get("wake_in_progress", False),
"level": value.get("level", 0),
"pending_reload": value.get("pending_reload", False)}
for key, value in services.items()
}}
with open(path, "w", encoding="utf-8") as fh:
json.dump(payload, fh)
def read_state(path) -> dict:
with open(path, encoding="utf-8") as fh:
return json.load(fh)["services"]
async def _make(cfg, backend, state_file):
public_app, admin_app, manager = build_apps(cfg, transport=backend)
try:
yield public_app, admin_app, manager
finally:
await manager.proxy_client.aclose()
await manager.ctrl_client.aclose()
async def test_startup_completes_interrupted_wake(tmp_path, backend):
"""The E2E failure: old router died after wake_up, before reload_weights.
The new router must not proxy until the sequence has been completed."""
state_file = str(tmp_path / "state.json")
write_state(state_file, text={"depth": "offloaded", "wake_in_progress": True,
"level": 2})
# backend *looks* perfectly healthy and awake -- that is the lie
backend.services["vllm-text"]["sleeping"] = False
cfg = clone(load_config())
cfg.state_file = state_file
cfg.idle_enabled = False
cfg.state_cache_ttl = 0.0
async for _public, _admin, manager in _make(cfg, backend, state_file):
manager.start()
await asyncio.sleep(0.3) # let the recovery task run
text = manager.services["text"]
assert text.pending_reload is False
assert text.depth == "awake"
svc = backend.services["vllm-text"]
# the interrupted sequence was completed, not skipped
assert svc["reload_calls"] == 1
assert svc["reset_calls"] == 1
# and the file no longer claims a wake in flight
assert read_state(state_file)["text"]["wake_in_progress"] is False
async def test_interrupted_wake_never_proxies_before_recovery(tmp_path, backend, pub):
"""A request arriving in the recovery window must not be proxied on the
strength of is_sleeping=false alone."""
state_file = str(tmp_path / "state.json")
write_state(state_file, ocr={"depth": "offloaded", "wake_in_progress": True, "level": 2})
backend.services["vllm-ocr"]["sleeping"] = False # awake-looking
backend.services["vllm-ocr"]["api_delay"] = 0.0
backend.calls.clear()
stack_cfg = pub # unused; keeps the fixture ordering simple
del stack_cfg
cfg = clone(load_config())
cfg.state_file = state_file
cfg.idle_enabled = False
async for public_app, _admin, manager in _make(cfg, backend, state_file):
manager.start()
# fire the request immediately, before recovery has finished probing
task = asyncio.create_task(_post(public_app, "/v1/chat/completions",
{"model": "ocr"}))
await asyncio.sleep(0.05)
ocr = backend.services["vllm-ocr"]
assert ocr["reload_calls"] >= 0
response = await task
assert response.status_code == 200
# proxied only after a completed reload sequence
assert backend.last("POST vllm-ocr/v1/chat/completions") > \
backend.last("POST vllm-ocr/reset_prefix_cache")
assert manager.services["ocr"].pending_reload is False
async def _post(app, path, payload) -> httpx.Response:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app),
base_url="http://router.test") as client:
return await client.post(path, json=payload)
async def test_startup_clears_state_when_unreachable(tmp_path, backend):
"""wake_in_progress + unreachable backend = fresh boot (weights are fresh);
nothing to complete."""
state_file = str(tmp_path / "state.json")
write_state(state_file, text={"depth": "offloaded", "wake_in_progress": True, "level": 2})
backend.services["vllm-text"]["reachable"] = False
cfg = clone(load_config())
cfg.state_file = state_file
cfg.idle_enabled = False
async for _public, _admin, manager in _make(cfg, backend, state_file):
manager.start()
await asyncio.sleep(0.2)
text = manager.services["text"]
assert text.pending_reload is False
assert backend.services["vllm-text"]["reload_calls"] == 0
assert read_state(state_file)["text"]["wake_in_progress"] is False
async def test_startup_clears_state_when_still_sleeping(tmp_path, backend):
"""wake_in_progress but the backend never woke: the normal request path
will run a full wake when traffic arrives."""
state_file = str(tmp_path / "state.json")
write_state(state_file, embed={"depth": "sleeping", "wake_in_progress": True, "level": 1})
backend.set_sleeping("embed", True, level=1)
cfg = clone(load_config())
cfg.state_file = state_file
cfg.idle_enabled = False
async for _public, _admin, manager in _make(cfg, backend, state_file):
manager.start()
await asyncio.sleep(0.2)
embed = manager.services["embed"]
assert embed.pending_reload is False
assert embed.depth == "sleeping" # restored from the file
assert backend.services["vllm-embed"]["reload_calls"] == 0
async def test_no_reload_when_no_wake_in_progress(tmp_path, backend, pub):
"""Plain restart with a settled backend: no reload, no state churn."""
state_file = str(tmp_path / "state.json")
write_state(state_file,
text={"depth": "awake", "wake_in_progress": False},
ocr={"depth": "offloaded", "wake_in_progress": False, "level": 2})
cfg = clone(load_config())
cfg.state_file = state_file
cfg.idle_enabled = False
async for _public, _admin, manager in _make(cfg, backend, state_file):
manager.start()
await asyncio.sleep(0.1)
assert manager.services["ocr"].depth == "offloaded"
assert backend.count("POST vllm-ocr/wake_up") == 0
assert backend.count("POST vllm-text/collective_rpc") == 0
# the awake service serves immediately, straight through the proxy
status, body = await raw_asgi(_public_app(manager), "POST",
"/v1/chat/completions",
headers=[("content-type", "application/json")],
body=b'{"model": "text"}')
assert status == 200, body
svc = backend.services["vllm-text"]
assert svc["wake_up_calls"] == 0 # no pointless wake
assert svc["reload_calls"] == 0 # no pointless reload
def _public_app(manager):
from routing import build_public_app
return build_public_app(manager)
async def test_interrupted_wake_end_to_end(tmp_path, backend):
"""In-process replay of E2E case 12: the wake is killed after wake_up has
flipped is_sleeping but before reload_weights; a new manager on the same
state file must complete the sequence before proxying."""
state_file = str(tmp_path / "state.json")
cfg = clone(load_config())
cfg.state_file = state_file
cfg.idle_enabled = False
cfg.wake_health_poll_s = 0.005
backend.set_sleeping("text", True, level=2)
backend.services["vllm-text"]["wake_delay"] = 0.4
backend.services["vllm-text"]["wake_flip_midway"] = True
first = ServiceManager(cfg, transport=backend)
try:
task = asyncio.create_task(first.ensure_awake("text"))
await asyncio.sleep(0.3) # is_sleeping flipped at t+0.2s
task.cancel() # ... and the router "dies"
await asyncio.gather(task, return_exceptions=True)
finally:
await first.proxy_client.aclose()
await first.ctrl_client.aclose()
assert backend.services["vllm-text"]["sleeping"] is False # looks awake
assert backend.services["vllm-text"]["reload_calls"] == 0 # ... but isn't
assert read_state(state_file)["text"]["wake_in_progress"] is True
backend.calls.clear()
async for public_app, _admin, manager in _make(cfg, backend, state_file):
manager.start()
response = await _post(public_app, "/v1/chat/completions", {"model": "text"})
assert response.status_code == 200
svc = backend.services["vllm-text"]
assert svc["reload_calls"] == 1 # sequence was completed ...
assert backend.last("POST vllm-text/v1/chat/completions") > \
backend.last("POST vllm-text/reset_prefix_cache") # ... first
assert manager.services["text"].pending_reload is False
async def test_wake_intent_is_written_and_cleared(tmp_path, backend):
"""The file says wake_in_progress=true while a wake is running and false
once it completes -- which is exactly what a restart in the middle leaves
behind."""
state_file = str(tmp_path / "state.json")
cfg = clone(load_config())
cfg.state_file = state_file
cfg.idle_enabled = False
cfg.wake_health_poll_s = 0.005
backend.set_sleeping("text", True, level=2)
backend.services["vllm-text"]["wake_delay"] = 0.2
manager = ServiceManager(cfg, transport=backend)
try:
task = asyncio.create_task(manager.ensure_awake("text"))
await asyncio.sleep(0.05) # mid-wake
assert read_state(state_file)["text"]["wake_in_progress"] is True
outcome = await task
assert outcome.ok
entry = read_state(state_file)["text"]
assert entry["wake_in_progress"] is False
assert entry["depth"] == "awake"
finally:
await manager.proxy_client.aclose()
await manager.ctrl_client.aclose()
async def test_failed_wake_clears_the_intent(tmp_path, backend):
state_file = str(tmp_path / "state.json")
cfg = clone(load_config())
cfg.state_file = state_file
cfg.idle_enabled = False
cfg.wake_health_poll_s = 0.005
backend.set_sleeping("text", True)
backend.services["vllm-text"]["wake_fails"] = 9
manager = ServiceManager(cfg, transport=backend)
try:
outcome = await manager.ensure_awake("text")
assert not outcome.ok
entry = read_state(state_file)["text"]
assert entry["wake_in_progress"] is False
finally:
await manager.proxy_client.aclose()
await manager.ctrl_client.aclose()
async def test_state_file_failure_is_not_fatal(tmp_path, backend):
"""An unwritable state path disables persistence, never serving."""
cfg = clone(load_config())
cfg.state_file = str(tmp_path / "no-such-dir" / "state.json")
cfg.idle_enabled = False
async for public_app, _admin, manager in _make(cfg, backend, cfg.state_file):
manager.start()
response = await _post(public_app, "/v1/chat/completions", {"model": "text"})
assert response.status_code == 200
assert not os.path.exists(cfg.state_file)
async def test_admin_reports_recovery(tmp_path, backend):
state_file = str(tmp_path / "state.json")
write_state(state_file, ocr={"depth": "offloaded", "wake_in_progress": True, "level": 2})
backend.services["vllm-ocr"]["sleeping"] = False
cfg = clone(load_config())
cfg.state_file = state_file
cfg.idle_enabled = False
async for _public, admin_app, manager in _make(cfg, backend, state_file):
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=admin_app),
base_url="http://router.test") as client:
r = await client.get("/admin/status")
assert r.status_code == 200
assert r.json()["services"]["ocr"]["wake_recovery_pending"] is True