From bc55901d4df16368f38f4ac977e0e8f4f5497189 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 25 Sep 2026 16:37:57 +0300 Subject: [PATCH] fix(core): serialize planning reports outside the control event loop --- src/k1link/web/planning_live_api.py | 25 ++++++++-- tests/test_planning_active_response.py | 69 ++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 tests/test_planning_active_response.py diff --git a/src/k1link/web/planning_live_api.py b/src/k1link/web/planning_live_api.py index 0682444..510fc85 100644 --- a/src/k1link/web/planning_live_api.py +++ b/src/k1link/web/planning_live_api.py @@ -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(): diff --git a/tests/test_planning_active_response.py b/tests/test_planning_active_response.py new file mode 100644 index 0000000..d2d7833 --- /dev/null +++ b/tests/test_planning_active_response.py @@ -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"}