fix: keep backend startup independent from paused elo

Avoid eager Elo/MMR imports during backend startup while the feature is paused. Add validation coverage for backend startup and health route resolution.
This commit is contained in:
devRaGonSa
2026-05-19 09:09:20 +02:00
committed by GitHub
parent feb516f411
commit 2271608836
3 changed files with 266 additions and 4 deletions

View File

@@ -0,0 +1,175 @@
---
id: TASK-109
title: Keep backend startup independent from paused Elo/MMR
status: pending
type: backend
team: Backend Senior
supporting_teams: ["Arquitecto Python"]
roadmap_item: foundation
priority: high
---
# TASK-109 - Keep backend startup independent from paused Elo/MMR
## Goal
Fix backend startup so the default operational Compose profile can start `backend` and `frontend` while Elo/MMR and complex historical materialization remain paused.
The backend must expose `/health` and non-Elo routes even if Elo/MMR engine internals are unavailable or temporarily broken.
## Context
HLL Vietnam has simplified its default operational mode. The normal Compose profile should start only `backend` and `frontend`; historical workers, Elo/MMR, complex historical materialization, and server #03 are paused operationally.
Current validation shows:
- `docker compose config --services` returns only `backend` and `frontend`.
- `docker compose up -d --build` starts the frontend successfully.
- The frontend responds with HTTP 200.
- The backend restarts continuously.
- `/health` fails because backend startup imports Elo/MMR code.
- Backend logs include: `ImportError: cannot import name 'ELO_K_FACTOR' from 'app.elo_mmr_models'`.
- The startup import path is: `app.main -> app.routes -> app.payloads -> app.elo_mmr_engine -> app.elo_mmr_models`.
- `payloads.py` imports Elo/MMR payload functions at module import time, so a paused or broken Elo implementation can break the whole backend, including `/health`.
This task is startup-focused. Preserve the existing HLL Vietnam product identity and repository discipline, and keep the fix narrow.
## Steps
1. Inspect the listed files before changing anything.
2. Confirm the backend startup import path and where Elo/MMR is imported at module load time.
3. Refactor only the startup boundary needed so `/health` and non-Elo routes do not depend on successful import of Elo/MMR engine internals.
4. Prefer lazy importing Elo/MMR functions only inside Elo-specific payload builders or routes.
5. If Elo/MMR is paused or unavailable, make Elo-specific endpoints return a controlled unavailable or fallback payload instead of crashing backend import.
6. Preserve the public API shape as much as possible.
7. Add or update tests if a backend test framework exists.
8. Update lightweight validation if needed so future startup import regressions are caught.
9. Document the paused Elo/MMR startup boundary if documentation needs adjustment.
10. Run the required validation before committing.
11. Move this task file to `ai/tasks/done/` only after validation is complete and the outcome is documented.
12. Stage only intended files, commit, and push the branch to origin.
## Files to Read First
- `backend/app/main.py`
- `backend/app/routes.py`
- `backend/app/payloads.py`
- `backend/app/elo_mmr_engine.py`
- `backend/app/elo_mmr_models.py`
- `backend/app/config.py`
- `backend/tests/` if present
- `docker-compose.yml`
- `scripts/run-integration-tests.ps1`
- `README.md`
- `docs/decisions.md`
## Expected Files to Modify
- likely `backend/app/payloads.py`
- possibly `backend/app/routes.py`
- possibly `backend/app/elo_mmr_engine.py`
- possibly `backend/app/elo_mmr_models.py` only if justified
- possibly tests under `backend/tests/`
- possibly `scripts/run-integration-tests.ps1`
- this task file moved from `ai/tasks/pending/` to `ai/tasks/done/`
If additional files become necessary, explain why in the task outcome and commit message.
## Expected Files Not to Modify
- `frontend/**`
- `docker-compose.yml`, unless validation proves a Compose-only issue also exists
- database migrations
- persisted data
- unrelated backend modules
- server #03 configuration
## Constraints
- Do not delete Elo/MMR code.
- Do not delete historical ingestion code.
- Do not remove database migrations or persisted data.
- Do not change frontend behavior.
- Do not reintroduce server #03.
- Keep the fix narrow and startup-focused.
- Do not implement a new Elo/MMR algorithm.
- Do not reintroduce `ELO_K_FACTOR` as a blind compatibility patch unless you prove it is the smallest safe fix and still prevents startup coupling.
- Keep `/health` available even if Elo/MMR import fails.
- Preserve existing public API shape as much as possible.
- Do not modify unrelated files.
- Do not leave completed work only in local; commit and push after validation.
## Validation
Before completing the task, run and document:
- `git status`
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
- `docker compose down`
- `docker compose up -d --build`
- `docker compose ps`
- `docker compose logs --tail=100 backend`
- `Invoke-WebRequest http://localhost:8000/health | Select-Object -ExpandProperty Content`
- `Invoke-WebRequest http://localhost:8080 | Select-Object -ExpandProperty StatusCode`
Also confirm and document:
- backend is not restarting
- `/health` returns a valid payload
- frontend still returns 200
- no frontend files changed
- no database migrations or persisted data changed
- `backend/runtime/` is not created or committed
- `git diff --name-only` matches the expected scope
If integration tests are relevant and `scripts/run-integration-tests.ps1` exists, use it. If no backend test framework exists for this scope, document that explicitly in the outcome.
## Commit And Push Requirements
- Run all validation before committing.
- Run `git status`.
- Stage only intended files.
- Commit with a clear message, for example: `fix: keep backend startup independent from paused elo`.
- Push the branch to origin.
- Do not leave completed work only in local.
## Outcome
- Validation performed:
- `git status --short`
- `python -c "import sys; sys.path.insert(0, 'backend'); import app.main; from app.routes import resolve_get_payload; print(resolve_get_payload('/health'))"`
- `python -c "import sys; sys.path.insert(0, 'backend'); from app.routes import resolve_get_payload; print(resolve_get_payload('/api/historical/elo-mmr/leaderboard'))"`
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
- `docker compose down`
- `docker compose up -d --build`
- `docker compose ps`
- `docker compose logs --tail=100 backend`
- `Invoke-WebRequest http://localhost:8000/health | Select-Object -ExpandProperty Content`
- `Invoke-WebRequest http://localhost:8080 | Select-Object -ExpandProperty StatusCode`
- `Invoke-WebRequest 'http://localhost:8000/api/historical/elo-mmr/leaderboard' | Select-Object -ExpandProperty Content`
- `git diff --name-only`
- Startup coupling removed:
- `backend/app/payloads.py` no longer imports `app.elo_mmr_engine` at module load time.
- Elo/MMR engine imports now happen only inside Elo/MMR payload builders.
- `app.main -> app.routes -> app.payloads` can load and serve `/health` without importing paused Elo/MMR internals.
- Elo/MMR unavailable behavior:
- If the Elo/MMR engine cannot be imported, Elo/MMR endpoints return the existing `status` + `data` envelope with `source: "elo-mmr-paused"`, `available: false`, `unavailable_reason: "elo-mmr-engine-import-unavailable"`, normal source policy metadata, and empty/null Elo data.
- Tests and validation:
- No backend test framework is configured for this scope.
- `scripts/run-integration-tests.ps1` now includes a lightweight backend startup import check and `/health` route resolution check.
- Scope confirmation:
- Backend was up and not restarting in `docker compose ps`.
- `/health` returned a valid JSON payload with `status: "ok"`.
- Frontend returned HTTP `200`.
- No frontend files changed.
- No database migrations, persisted data, or server #03 configuration changed.
- `backend/runtime/` was not created.
- `git diff --name-only` matched the expected scope: `backend/app/payloads.py` and `scripts/run-integration-tests.ps1`.
- Commit and push:
- Commit hash and pushed branch are recorded in the final task execution response.
## Change Budget
- Prefer fewer than 5 modified files.
- Prefer changes under 200 lines when feasible.
- Split follow-up work into a new task if the scope expands beyond startup independence.

View File

@@ -20,10 +20,6 @@ from .data_sources import (
get_live_data_source,
get_rcon_historical_read_model,
)
from .elo_mmr_engine import (
get_elo_mmr_player_payload,
list_elo_mmr_leaderboard_payload,
)
from .historical_snapshot_storage import get_historical_snapshot
from .historical_snapshots import (
DEFAULT_MONTHLY_SNAPSHOT_WINDOW,
@@ -1097,6 +1093,22 @@ def build_elo_mmr_leaderboard_payload(
server_id: str | None = None,
) -> dict[str, object]:
"""Return the current Elo/MMR monthly leaderboard."""
engine = _load_elo_mmr_engine()
if engine is None:
return _build_elo_mmr_unavailable_payload(
context="historical-elo-mmr-leaderboard",
title=(
"Leaderboard mensual Elo/MMR global"
if server_id == ALL_SERVERS_SLUG
else "Leaderboard mensual Elo/MMR por servidor"
),
server_id=server_id,
limit=limit,
extra={"items": []},
operation="elo-mmr-leaderboard",
)
list_elo_mmr_leaderboard_payload = engine[1]
payload = list_elo_mmr_leaderboard_payload(server_id=server_id, limit=limit)
is_all_servers = server_id == ALL_SERVERS_SLUG
accuracy_contract = _build_elo_accuracy_contract(payload.get("capabilities_summary"))
@@ -1137,6 +1149,21 @@ def build_elo_mmr_player_payload(
server_id: str | None = None,
) -> dict[str, object]:
"""Return one Elo/MMR player profile."""
engine = _load_elo_mmr_engine()
if engine is None:
return _build_elo_mmr_unavailable_payload(
context="historical-elo-mmr-player",
title="Perfil Elo/MMR de jugador",
server_id=server_id,
extra={
"player_id": player_id,
"found": False,
"profile": None,
},
operation="elo-mmr-player",
)
get_elo_mmr_player_payload, list_elo_mmr_leaderboard_payload = engine
profile = get_elo_mmr_player_payload(player_id=player_id, server_id=server_id)
source_policy = list_elo_mmr_leaderboard_payload(server_id=server_id, limit=1).get("source_policy")
accuracy_contract = _build_elo_player_accuracy_contract(profile)
@@ -1160,6 +1187,52 @@ def build_elo_mmr_player_payload(
}
def _load_elo_mmr_engine():
try:
from .elo_mmr_engine import ( # noqa: PLC0415 - lazy boundary for paused Elo/MMR
get_elo_mmr_player_payload,
list_elo_mmr_leaderboard_payload,
)
except ImportError:
return None
return get_elo_mmr_player_payload, list_elo_mmr_leaderboard_payload
def _build_elo_mmr_unavailable_payload(
*,
context: str,
title: str,
server_id: str | None,
operation: str,
limit: int | None = None,
extra: dict[str, object] | None = None,
) -> dict[str, object]:
accuracy_contract = _build_elo_accuracy_contract(None)
data = {
"title": title,
"context": context,
"source": "elo-mmr-paused",
"server_slug": server_id,
"available": False,
"unavailable_reason": "elo-mmr-engine-import-unavailable",
**_resolve_historical_fallback_policy(
operation=operation,
fallback_reason="elo-mmr-operationally-paused",
),
"capabilities_summary": None,
"accuracy_contract": accuracy_contract,
"model_contract": _build_elo_model_contract(accuracy_contract),
}
if limit is not None:
data["limit"] = limit
if extra:
data.update(extra)
return {
"status": "ok",
"data": data,
}
def _build_elo_player_accuracy_contract(profile: dict[str, object] | None) -> dict[str, object]:
if not isinstance(profile, dict):
return _build_elo_accuracy_contract(None)

View File

@@ -42,6 +42,20 @@ if (-not (Test-Path "ai/reports/.gitkeep")) {
throw "Missing ai/reports/.gitkeep"
}
$backendImportCheck = @'
import sys
sys.path.insert(0, "backend")
import app.main
from app.routes import resolve_get_payload
status, payload = resolve_get_payload("/health")
if status is None or payload.get("status") != "ok":
raise SystemExit("Backend health route did not resolve to an ok payload.")
'@
$backendImportCheck | python -
Write-Host "No product integration tests are configured for this platform-only scope."
Write-Host "Backend startup import check passed."
Write-Host "Platform validation passed."
exit 0