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."""
|
||||
|
||||
import gzip
|
||||
from typing import Literal
|
||||
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 starlette.concurrency import run_in_threadpool
|
||||
|
||||
@@ -47,8 +49,25 @@ def build_planning_live_router(service):
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
|
||||
@router.get("/active")
|
||||
async def active():
|
||||
return await call(service.get)
|
||||
async def active(request: Request):
|
||||
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("")
|
||||
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