mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description When Kompress preload is deferred until first request, startup still logs "not installed" even if ML deps are present. After the model later loads into the module cache, /readyz and /health can keep reporting kompress as unhealthy because reconcile only inspected attached compressor instances. This PR reports deferred startup accurately and promotes health from the live module cache once the model is ready, without starting loads from health checks. Closes #2560 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Treat eager-status `deferred` as installed-but-deferred at proxy startup and log that state instead of "not installed". - Promote `/readyz` and `/health` Kompress readiness from the module-level model cache when attached compressors are missing or not ready. - Keep health inspection free of lazy getters and download side effects. - Add regressions for deferred startup logging and cache-based health promotion. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ PYTHONPATH=/tmp/headroom-2561 python -m pytest tests/test_proxy_health.py tests/test_proxy_eager_preload_bind.py -q -o addopts= 21 passed, 1 warning in 2.73s $ ruff format --check headroom/proxy/server.py tests/test_proxy_health.py tests/test_proxy_eager_preload_bind.py 3 files already formatted $ ruff check headroom/proxy/server.py tests/test_proxy_health.py tests/test_proxy_eager_preload_bind.py All checks passed! ``` ## Real Behavior Proof - Environment: Linux VPS, Python 3.11 venv with headroom-ai 0.32.1 wheel for `_core`, checked out main + this branch overlayed for source under test - Exact command / steps: `PYTHONPATH=/tmp/headroom-2561 python -m pytest tests/test_proxy_health.py tests/test_proxy_eager_preload_bind.py -q -o addopts=`; `ruff format --check` and `ruff check` on the three changed files - Observed result: 21 focused tests passed, including deferred startup log regression and module-cache health promotion; ruff format/check clean - Not tested: live multi-request proxy with real ONNX model download on this host; install-status follow-up mentioned in the issue comment ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this) ## Screenshots (if applicable) N/A ## Additional Notes - Scoped to Kompress status reporting only. The separate `headroom install status` ownership probe in the issue comment is left for a follow-up. Co-authored-by: axelray-dev <axelray-dev@users.noreply.github.com>
143 lines
4.8 KiB
Python
143 lines
4.8 KiB
Python
"""Startup must bind its port even when eager preload hangs (#790).
|
|
|
|
``HeadroomProxy.startup()`` runs inside the ASGI lifespan, which completes
|
|
*before* uvicorn binds the socket. The eager compressor/parser preload used to
|
|
run synchronously there, so a hang or an uncatchable native stall during a model
|
|
load (observed on Windows) left the proxy "never opening its port". The preload
|
|
now runs off the event loop under ``asyncio.wait_for`` with
|
|
``EAGER_PRELOAD_TIMEOUT_SECONDS``; on timeout startup logs and continues so the
|
|
bind still happens and transforms fall back to lazy loading.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("fastapi")
|
|
|
|
import headroom.proxy.server as server_mod
|
|
from headroom.proxy.server import ProxyConfig, create_app
|
|
|
|
|
|
def _make_proxy(*, optimize: bool):
|
|
config = ProxyConfig(
|
|
optimize=optimize,
|
|
cache_enabled=False,
|
|
rate_limit_enabled=False,
|
|
cost_tracking_enabled=False,
|
|
log_requests=False,
|
|
ccr_inject_tool=False,
|
|
ccr_handle_responses=False,
|
|
ccr_context_tracking=False,
|
|
image_optimize=False,
|
|
subscription_tracking_enabled=False,
|
|
)
|
|
return create_app(config).state.proxy
|
|
|
|
|
|
class _FastTransform:
|
|
def __init__(self, status):
|
|
self._status = status
|
|
|
|
def eager_load_compressors(self):
|
|
return self._status
|
|
|
|
|
|
class _RaisingTransform:
|
|
def eager_load_compressors(self):
|
|
raise RuntimeError("boom")
|
|
|
|
|
|
class _NonDictTransform:
|
|
def eager_load_compressors(self):
|
|
return "not-a-dict"
|
|
|
|
|
|
class _HangingTransform:
|
|
"""Simulates a model load that hangs forever (released via the event)."""
|
|
|
|
def __init__(self, release: threading.Event):
|
|
self._release = release
|
|
|
|
def eager_load_compressors(self):
|
|
# Safety cap so a misbehaving test can never wedge the suite.
|
|
self._release.wait(timeout=30)
|
|
return {"hang": "done"}
|
|
|
|
|
|
class _FakePipeline:
|
|
def __init__(self, transforms):
|
|
self.transforms = transforms
|
|
|
|
|
|
def test_eager_preload_dedupes_and_swallows_failures():
|
|
proxy = _make_proxy(optimize=False)
|
|
shared = _FastTransform({"shared": "enabled"})
|
|
proxy.anthropic_pipeline = _FakePipeline([shared, _FastTransform({"kompress": "enabled"})])
|
|
# ``shared`` appears in both pipelines and must load exactly once; the
|
|
# raising and non-dict transforms must be skipped without aborting.
|
|
proxy.openai_pipeline = _FakePipeline([shared, _RaisingTransform(), _NonDictTransform()])
|
|
|
|
eager_status, statuses = proxy._eager_preload_transforms()
|
|
|
|
assert eager_status == {"shared": "enabled", "kompress": "enabled"}
|
|
assert statuses == [{"shared": "enabled"}, {"kompress": "enabled"}]
|
|
|
|
|
|
async def test_startup_binds_despite_hung_preload(monkeypatch):
|
|
monkeypatch.setattr(server_mod, "EAGER_PRELOAD_TIMEOUT_SECONDS", 0.3)
|
|
proxy = _make_proxy(optimize=True)
|
|
release = threading.Event()
|
|
proxy.anthropic_pipeline = _FakePipeline([_HangingTransform(release)])
|
|
proxy.openai_pipeline = _FakePipeline([])
|
|
|
|
try:
|
|
start = time.monotonic()
|
|
await proxy.startup() # must NOT wait on the hung load
|
|
elapsed = time.monotonic() - start
|
|
# Returns shortly after the 0.3s preload timeout, far below the 30s hang.
|
|
assert elapsed < 10
|
|
finally:
|
|
release.set()
|
|
await proxy.shutdown()
|
|
|
|
|
|
async def test_startup_merges_warmup_for_normal_transforms(monkeypatch):
|
|
proxy = _make_proxy(optimize=True)
|
|
captured: list[dict] = []
|
|
monkeypatch.setattr(proxy.warmup, "merge_transform_status", captured.append)
|
|
proxy.anthropic_pipeline = _FakePipeline([_FastTransform({"kompress": "enabled"})])
|
|
proxy.openai_pipeline = _FakePipeline([])
|
|
|
|
try:
|
|
await proxy.startup()
|
|
assert {"kompress": "enabled"} in captured
|
|
assert proxy._kompress_status == "enabled"
|
|
finally:
|
|
await proxy.shutdown()
|
|
|
|
|
|
async def test_startup_reports_deferred_kompress(caplog):
|
|
proxy = _make_proxy(optimize=True)
|
|
proxy.anthropic_pipeline = _FakePipeline([_FastTransform({"kompress": "deferred"})])
|
|
proxy.openai_pipeline = _FakePipeline([])
|
|
|
|
try:
|
|
# Proxy setup disables propagation on the ``headroom`` logger, so
|
|
# attach caplog's handler directly to the logger that emits this line.
|
|
server_mod.logger.addHandler(caplog.handler)
|
|
try:
|
|
with caplog.at_level(logging.INFO, logger=server_mod.logger.name):
|
|
await proxy.startup()
|
|
finally:
|
|
server_mod.logger.removeHandler(caplog.handler)
|
|
|
|
assert proxy._kompress_status == "deferred"
|
|
assert "Kompress: DEFERRED (model loads on first request)" in caplog.messages
|
|
assert not any("Kompress: not installed" in message for message in caplog.messages)
|
|
finally:
|
|
await proxy.shutdown()
|