fix(core): serialize planning reports outside the control event loop

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 16:37:57 +03:00
parent 6d772b29fe
commit bc55901d4d
2 changed files with 91 additions and 3 deletions
+22 -3
View File
@@ -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():
+69
View File
@@ -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"}