fix(core): serialize planning reports outside the control event loop
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
"""Planning-profile preparation and preview; never an acquisition endpoint."""
|
"""Planning-profile preparation and preview; never an acquisition endpoint."""
|
||||||
|
|
||||||
|
import gzip
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Response
|
from fastapi import APIRouter, HTTPException, Query, Request, Response
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from starlette.concurrency import run_in_threadpool
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
@@ -47,8 +49,25 @@ def build_planning_live_router(service):
|
|||||||
raise HTTPException(409, str(exc)) from exc
|
raise HTTPException(409, str(exc)) from exc
|
||||||
|
|
||||||
@router.get("/active")
|
@router.get("/active")
|
||||||
async def active():
|
async def active(request: Request):
|
||||||
return await call(service.get)
|
accepts_gzip = "gzip" in request.headers.get("accept-encoding", "")
|
||||||
|
|
||||||
|
def encoded():
|
||||||
|
# A completed report can contain megabytes of numerical evidence.
|
||||||
|
# Returning its dict sends it through FastAPI's recursive encoder
|
||||||
|
# and gzip on the event loop, delaying unrelated control requests.
|
||||||
|
# Keep the exact JSON contract, but finish both operations here.
|
||||||
|
response = JSONResponse(service.get(), headers={"Cache-Control": "no-store"})
|
||||||
|
if accepts_gzip and len(response.body) >= 1024:
|
||||||
|
return Response(
|
||||||
|
gzip.compress(response.body, compresslevel=5, mtime=0),
|
||||||
|
media_type="application/json",
|
||||||
|
headers={"Content-Encoding": "gzip", "Vary": "Accept-Encoding",
|
||||||
|
"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
return await call(encoded)
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def history():
|
async def history():
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Periodic planning reports must not serialize/compress on the ASGI loop."""
|
||||||
|
import threading
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from k1link.web import planning_live_api
|
||||||
|
from k1link.web.response_compression import ResponseCompressionMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("encoding", ["gzip", "identity"])
|
||||||
|
def test_active_report_keeps_content_and_encodes_off_loop(monkeypatch, encoding):
|
||||||
|
document = {"state": "completed", "evidence": [{"label": "Проверка", "x": 1.25}] * 50}
|
||||||
|
calls = []
|
||||||
|
original_json = planning_live_api.JSONResponse
|
||||||
|
original_compress = planning_live_api.gzip.compress
|
||||||
|
|
||||||
|
class RecordedJSON(original_json):
|
||||||
|
def render(self, content):
|
||||||
|
calls.append(("json", threading.get_ident()))
|
||||||
|
return super().render(content)
|
||||||
|
|
||||||
|
def compress(*args, **kwargs):
|
||||||
|
calls.append(("gzip", threading.get_ident()))
|
||||||
|
return original_compress(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(planning_live_api, "JSONResponse", RecordedJSON)
|
||||||
|
monkeypatch.setattr(planning_live_api.gzip, "compress", compress)
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(planning_live_api.build_planning_live_router(SimpleNamespace(get=lambda: document)))
|
||||||
|
app.add_middleware(ResponseCompressionMiddleware)
|
||||||
|
|
||||||
|
@app.get("/loop-thread")
|
||||||
|
async def loop_thread():
|
||||||
|
return threading.get_ident()
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
loop_id = client.get("/loop-thread").json()
|
||||||
|
result = client.get("/api/v1/mission-planner/live-tests/active", headers={"Accept-Encoding": encoding})
|
||||||
|
assert result.status_code == 200
|
||||||
|
assert result.json() == document # Includes HTTP decompression; no double gzip.
|
||||||
|
assert result.headers["cache-control"] == "no-store"
|
||||||
|
assert result.headers["content-type"] == "application/json"
|
||||||
|
assert [name for name, _ in calls] == (["json", "gzip"] if encoding == "gzip" else ["json"])
|
||||||
|
assert all(thread != loop_id for _, thread in calls)
|
||||||
|
if encoding == "gzip":
|
||||||
|
assert result.headers["content-encoding"] == "gzip"
|
||||||
|
assert result.headers["vary"] == "Accept-Encoding"
|
||||||
|
else:
|
||||||
|
assert "content-encoding" not in result.headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_report_retains_empty_and_failure_contract():
|
||||||
|
service = SimpleNamespace(get=lambda: None)
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(planning_live_api.build_planning_live_router(service))
|
||||||
|
with TestClient(app) as client:
|
||||||
|
result = client.get("/api/v1/mission-planner/live-tests/active")
|
||||||
|
assert result.status_code == 200 and result.json() is None
|
||||||
|
|
||||||
|
def fail():
|
||||||
|
raise RuntimeError("synthetic unavailable report")
|
||||||
|
|
||||||
|
service.get = fail
|
||||||
|
result = client.get("/api/v1/mission-planner/live-tests/active")
|
||||||
|
assert result.status_code == 409
|
||||||
|
assert result.json() == {"detail": "synthetic unavailable report"}
|
||||||
Reference in New Issue
Block a user