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

View File

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