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.""" """AI service for parsing technical sheets via OpenRouter."""
import asyncio
import json import json
import logging import logging
from io import BytesIO 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]: 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(): if not text.strip():
return [] 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 export honours, so a shop floor set to the Italian convention (';' and ',') gets
files that open correctly in their Excel. files that open correctly in their Excel.
""" """
import asyncio
import csv import csv
import io import io
import logging 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") stamp = (run.closed_at or datetime.now()).strftime("%Y%m%d_%H%M%S")
filename = f"production_{run.id}_{stamp}.csv" 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, # utf-8-sig: the BOM is what makes Excel read accented characters correctly,
# and these files are opened in Excel on the shop floor. # 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.""" """Report generation service — PDF reports via Jinja2 + Plotly/Kaleido + WeasyPrint."""
import asyncio
import base64 import base64
from datetime import datetime from datetime import datetime
from pathlib import Path 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") 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: def _generate_histogram_svg(hist_data, subtask_info: dict | None) -> str:
"""Generate histogram SVG using Plotly + Kaleido.""" """Generate histogram SVG using Plotly + Kaleido."""
if not hist_data.bins: if not hist_data.bins:
@@ -300,9 +306,13 @@ async def generate_spc_report(
) )
histogram = compute_histogram(values) histogram = compute_histogram(values)
# 3. Generate SVG charts # 3. Generate SVG charts (kaleido shells out and blocks: off the event loop)
control_chart_svg = _generate_control_chart_svg(control_chart, subtask_info) control_chart_svg = await asyncio.to_thread(
histogram_svg = _generate_histogram_svg(histogram, subtask_info) _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 # 4. Get company info and recipe info
company = await _get_company_info(db) company = await _get_company_info(db)
@@ -336,8 +346,8 @@ async def generate_spc_report(
n_measurements=len(measurements), n_measurements=len(measurements),
) )
# 7. Convert HTML to PDF # 7. Convert HTML to PDF (WeasyPrint blocks: off the event loop)
pdf_bytes = HTML(string=html_content).write_pdf() pdf_bytes = await asyncio.to_thread(_render_pdf, html_content)
return pdf_bytes return pdf_bytes
@@ -424,6 +434,6 @@ async def generate_measurement_report(
n_measurements=len(measurements), n_measurements=len(measurements),
) )
# 8. Convert to PDF # 8. Convert to PDF (WeasyPrint blocks: off the event loop)
pdf_bytes = HTML(string=html_content).write_pdf() pdf_bytes = await asyncio.to_thread(_render_pdf, html_content)
return pdf_bytes return pdf_bytes