fix(async): chiamate bloccanti in asyncio.to_thread (#36 punto 1)

kaleido (pio.to_image), WeasyPrint (write_pdf), pdfplumber (extract_text_from_pdf)
e la scrittura del CSV di produzione giravano sincrone dentro handler async:
un PDF pesante o un run grande fermava il worker per tutte le altre richieste.
Ora avvolte in await asyncio.to_thread(...) in report_service.py,
ai_service.py e production_export_service.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:38:28 +02:00
parent 78970c69b0
commit 3f70f99a40
3 changed files with 27 additions and 9 deletions
+4 -1
View File
@@ -1,4 +1,5 @@
"""AI service for parsing technical sheets via OpenRouter."""
import asyncio
import json
import logging
from io import BytesIO
@@ -36,7 +37,9 @@ def extract_text_from_pdf(pdf_bytes: bytes) -> str:
async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
text = extract_text_from_pdf(pdf_bytes)
# pdfplumber parses synchronously and can take a while on a big PDF: off
# the event loop, or one upload stalls every other request being served.
text = await asyncio.to_thread(extract_text_from_pdf, pdf_bytes)
if not text.strip():
return []
@@ -9,6 +9,7 @@ The CSV uses the delimiters configured in system_settings, the same ones the man
export honours, so a shop floor set to the Italian convention (';' and ',') gets
files that open correctly in their Excel.
"""
import asyncio
import csv
import io
import logging
@@ -192,7 +193,11 @@ async def export_run_statistics(db: AsyncSession, run: ProductionRun) -> str | N
stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S")
filename = f"production_{run.id}_{stamp}.csv"
(directory / filename).write_text(content, encoding="utf-8-sig")
# A large run's file can take a moment to write: off the event loop, so
# closing one run does not stall every other request being served.
await asyncio.to_thread(
(directory / filename).write_text, content, encoding="utf-8-sig"
)
# utf-8-sig: the BOM is what makes Excel read accented characters correctly,
# and these files are opened in Excel on the shop floor.
+17 -7
View File
@@ -1,4 +1,5 @@
"""Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
import asyncio
import base64
from datetime import datetime
from pathlib import Path
@@ -204,6 +205,11 @@ def _generate_control_chart_svg(chart_data, subtask_info: dict | None) -> str:
return svg_bytes.decode("utf-8")
def _render_pdf(html_content: str) -> bytes:
"""Blocking WeasyPrint render — always call via asyncio.to_thread."""
return HTML(string=html_content).write_pdf()
def _generate_histogram_svg(hist_data, subtask_info: dict | None) -> str:
"""Generate histogram SVG using Plotly + Kaleido."""
if not hist_data.bins:
@@ -300,9 +306,13 @@ async def generate_spc_report(
)
histogram = compute_histogram(values)
# 3. Generate SVG charts
control_chart_svg = _generate_control_chart_svg(control_chart, subtask_info)
histogram_svg = _generate_histogram_svg(histogram, subtask_info)
# 3. Generate SVG charts (kaleido shells out and blocks: off the event loop)
control_chart_svg = await asyncio.to_thread(
_generate_control_chart_svg, control_chart, subtask_info
)
histogram_svg = await asyncio.to_thread(
_generate_histogram_svg, histogram, subtask_info
)
# 4. Get company info and recipe info
company = await _get_company_info(db)
@@ -336,8 +346,8 @@ async def generate_spc_report(
n_measurements=len(measurements),
)
# 7. Convert HTML to PDF
pdf_bytes = HTML(string=html_content).write_pdf()
# 7. Convert HTML to PDF (WeasyPrint blocks: off the event loop)
pdf_bytes = await asyncio.to_thread(_render_pdf, html_content)
return pdf_bytes
@@ -424,6 +434,6 @@ async def generate_measurement_report(
n_measurements=len(measurements),
)
# 8. Convert to PDF
pdf_bytes = HTML(string=html_content).write_pdf()
# 8. Convert to PDF (WeasyPrint blocks: off the event loop)
pdf_bytes = await asyncio.to_thread(_render_pdf, html_content)
return pdf_bytes