OOB-Dashboard/app/routers/analysis.py

114 lines
4.3 KiB
Python

"""Upload, analyse, export -- the original dashboard API, now per user.
Every route here is `def`, not `async def`, on purpose. The pipeline is
synchronous and CPU-bound: one analysis holds a core for seconds to tens of
seconds. FastAPI runs `def` handlers in a worker thread, so a long run cannot
stall the event loop and everyone else's requests with it.
"""
from __future__ import annotations
import logging
import threading
from fastapi import (APIRouter, Depends, File, Form, HTTPException, Response,
UploadFile, status)
from app.config import Settings, get_settings
from app.deps import require_user
from app.models import User
from app.schemas import Ok, SettingsIn
from app.services import exporters
from app.services.analysis import run_analysis
from app.services.ratelimit import limiter
from app.services.store import UploadTooLarge, UserWorkspace, store_of
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["analysis"])
# Bounds peak memory. The default worker threadpool is 40 threads; without this
# a burst of uploads could start 40 concurrent pipelines and exhaust the box.
_slots = threading.BoundedSemaphore(get_settings().MAX_CONCURRENT_ANALYSES)
def workspace(user: User = Depends(require_user)) -> UserWorkspace:
return store_of().get(user.id)
@router.get("/state")
def state(user: User = Depends(require_user),
ws: UserWorkspace = Depends(workspace)) -> dict:
with ws.lock:
payload = ws.names()
payload["user"] = {"email": user.email, "name": user.name, "is_admin": user.is_admin}
return payload
@router.post("/upload")
def upload(file: UploadFile = File(...), kind: str = Form("history"),
ws: UserWorkspace = Depends(workspace),
s: Settings = Depends(get_settings)) -> dict:
if kind not in ("history", "perf"):
raise ValueError("Unknown upload kind.")
try:
with ws.lock:
saved = ws.add(file.filename or "upload.xlsx", file.file, kind,
s.MAX_UPLOAD_BYTES, s.MAX_WORKSPACE_BYTES)
except UploadTooLarge as exc:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, str(exc)) from exc
finally:
file.file.close()
return {"ok": True, "name": saved.name.split("_", 1)[-1]}
@router.post("/analyze")
def analyze(body: SettingsIn | None = None,
user: User = Depends(require_user),
ws: UserWorkspace = Depends(workspace)) -> dict:
wait = limiter.check("analyze:user", str(user.id))
if wait:
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS,
"That is a lot of runs in a short time. Give it a minute.",
headers={"Retry-After": str(int(wait))})
if not _slots.acquire(blocking=False):
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE,
"The server is analysing other reports right now. "
"Try again in a moment.",
headers={"Retry-After": "30"})
try:
settings_in = (body or SettingsIn()).model_dump()
with ws.lock:
# Released before the run so the previous result is not held
# alongside the new one at the peak of the build.
ws.last = None
result = run_analysis(ws.history, ws.perf, settings_in)
ws.last = result.artifacts
return result.payload
finally:
_slots.release()
@router.post("/clear", response_model=Ok)
def clear(ws: UserWorkspace = Depends(workspace)) -> Ok:
with ws.lock:
ws.clear()
return Ok()
@router.get("/export")
def export(format: str = "xlsx", ws: UserWorkspace = Depends(workspace)) -> Response:
with ws.lock:
last = ws.last
if not last:
raise ValueError("Analyse some files first.")
stamp = exporters.export_stamp(last)
if format == "csv":
body, mime, name = (exporters.csv_bytes(last), exporters.CSV_MIME,
f"ppc-budget_{stamp}.csv")
else:
body, mime, name = (exporters.xlsx_bytes(last), exporters.XLSX_MIME,
f"ppc-budget-report_{stamp}.xlsx")
return Response(body, media_type=mime,
headers={"Content-Disposition": f'attachment; filename="{name}"'})