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:
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user