fix(api): hydrate planner from DB exactly once on first GET, not on every read

get_config was calling planner.apply_payload on every GET request, racing
concurrent reads on module-level globals. Added a _hydrated flag + lock
so DB hydration runs at most once per process lifetime; put_config marks
it done too. Test fixture resets the flag between tests.
This commit is contained in:
2026-04-30 21:17:03 -04:00
parent c7fcd86be4
commit ebe15310ab
2 changed files with 25 additions and 14 deletions

View File

@@ -17,6 +17,7 @@ waiting for the orchestrator's next refresh tick.
from __future__ import annotations
import json
import threading
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
@@ -30,6 +31,8 @@ router = APIRouter()
log = get_logger("api.realism.config")
_CONFIG_KEY = "weights"
_hydrated = False
_hydrate_lock = threading.Lock()
@router.get(
@@ -51,20 +54,22 @@ async def get_config(
restart the ``realism_config`` row is loaded into this process the
first time GET is called; subsequent reads are local.
"""
# Lazy hydration — first call after restart pulls from DB so the
# admin sees what the orchestrator is actually using, not the
# baked-in defaults.
row = await repo.get_realism_config(_CONFIG_KEY)
if row is not None:
try:
stored = json.loads(row.get("value") or "{}")
if isinstance(stored, dict):
planner.apply_payload(stored)
except (json.JSONDecodeError, ValueError) as exc:
log.warning(
"api.realism.get_config: stored payload invalid, "
"serving defaults: %s", exc,
)
global _hydrated
if not _hydrated:
with _hydrate_lock:
if not _hydrated:
row = await repo.get_realism_config(_CONFIG_KEY)
if row is not None:
try:
stored = json.loads(row.get("value") or "{}")
if isinstance(stored, dict):
planner.apply_payload(stored)
except (json.JSONDecodeError, ValueError) as exc:
log.warning(
"api.realism.get_config: stored payload invalid, "
"serving defaults: %s", exc,
)
_hydrated = True
return planner.current_payload()
@@ -94,6 +99,7 @@ async def put_config(
Validation: any structural failure raises 400 *before* the rebind,
so the live config never goes torn.
"""
global _hydrated
if not isinstance(body, dict):
raise HTTPException(status_code=400, detail="body must be an object")
@@ -102,6 +108,8 @@ async def put_config(
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_hydrated = True
# Persist what the planner now reflects (keeps DB in sync with the
# in-memory state — partial bodies merge into prior config).
snapshot = planner.current_payload()

View File

@@ -12,8 +12,11 @@ from decnet.realism import planner
@pytest.fixture(autouse=True)
def _reset_planner():
import decnet.web.router.realism.api_config as _api_config
_api_config._hydrated = False
yield
planner.reset_to_defaults()
_api_config._hydrated = False
@pytest.mark.asyncio