diff --git a/src/backend/services/ai_service.py b/src/backend/services/ai_service.py index 852273d..95ccf57 100644 --- a/src/backend/services/ai_service.py +++ b/src/backend/services/ai_service.py @@ -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 [] diff --git a/src/backend/services/production_export_service.py b/src/backend/services/production_export_service.py index ed1d02b..c7e979a 100644 --- a/src/backend/services/production_export_service.py +++ b/src/backend/services/production_export_service.py @@ -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. diff --git a/src/backend/services/report_service.py b/src/backend/services/report_service.py index 2d25904..b89927a 100644 --- a/src/backend/services/report_service.py +++ b/src/backend/services/report_service.py @@ -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