Report PDF Renderer Churn
Between February 8, 2026 and June 21, 2026, report-svc changed its rendering stack several times before settling on the current in-process PDF path. The churn is visible in the renderer commits alone:
| Date | Commit | Change |
|---|---|---|
| 2026-02-08 | 543ebb3b | Playwright replaced with WeasyPrint. |
| 2026-03-15 | 373b6d44, cd6ea3b7, 4ac41e5b, bdd2fbfd, 5311a873 | WeasyPrint replaced with Playwright, then optimized with browser warm-up and parallel backend fetches. |
| 2026-03-15 | ae1fc2c2, 6ca3ccfc, 5822041b | WeasyPrint+PyMuPDF replaced with imgkit, pinned to Bookworm for wkhtmltopdf, then reverted the same day. |
| 2026-06-21 | 6ee24b1d, bc2595a8, 21059827 | Rendering moved to ReportLab, batch jobs started uploading PDFs directly to MinIO, and the image build was repaired. |
What Changed Repeatedly
The unstable boundary was not report data collection. It was the rendering runtime:
- Playwright required a browser process plus startup warm-up in
services/report/main.pyandservices/report/services/report_generator.pyduring the March 15, 2026 series. - WeasyPrint and
fitzmoved rendering back into Python, but still depended on HTML templating plus PDF-to-image conversion inservices/report/services/report_generator.py. imgkitreplaced that withwkhtmltoimage, which added an OS package dependency inservices/report/Dockerfileand had to be pinned to Bookworm in6ca3ccfc.- ReportLab removed the browser and HTML template dependency from the runtime path by drawing the PDF directly in
services/report/src/adapters/reportlab_renderer.py.
That final move keeps the render path fully inside the worker process while still staying async-safe:
class ReportLabReportGenerator:
async def generate_pdf(self, report_data: ReportData) -> bytes:
return await asyncio.to_thread(self._generate_pdf_sync, report_data)
async def warm_up(self) -> None:
return None
Source: lumie-worker/services/report/src/adapters/reportlab_renderer.py
What Stuck
The stable design that emerged on June 21, 2026 has three parts.
1. Render PDF bytes directly
GenerateReportUseCase.execute() now returns {"report_bytes": bytes} and never converts the report into a screenshot artifact. execute_batch() uses the same PDF renderer for each student item in the chunk.
2. Batch callbacks pass a stored file reference
bc2595a8 introduced MinioReportStore.upload_pdf() in services/report/src/adapters/minio_store.py and changed batch success callbacks to send fileKey instead of inline reportBytes. The current schema enforces that a success payload contains exactly one artifact reference:
class SuccessCallbackPayload(BaseModel):
fileKey: str | None = None
reportBytes: str | None = None
@model_validator(mode="after")
def require_exactly_one_artifact_reference(self) -> SuccessCallbackPayload:
has_file_key = self.fileKey is not None
has_report_bytes = self.reportBytes is not None
if has_file_key == has_report_bytes:
raise ValueError("success callback must include exactly one of fileKey or reportBytes")
return self
Source: lumie-worker/services/report/src/schema.py
3. The image build became simpler, not more complex
The current services/report/Dockerfile no longer installs Playwright or wkhtmltopdf. It installs Python dependencies in a builder stage, keeps python:3.11-slim-bookworm, and only adds font packages plus fontconfig for ReportLab font discovery. 21059827 is the small but important repair that added fontconfig back after the ReportLab migration.
Current Steady State
The present report flow is:
services/report/src/usecase.pygathers backend data concurrently.services/report/src/adapters/reportlab_renderer.pyrenders the PDF in a worker thread withasyncio.to_thread(...).- The single-report path returns inline PDF bytes through
services/report/main.py. - The batch path uploads PDFs through
services/report/src/adapters/minio_store.pyand returns MinIO keys throughservices/report/src/mq/consumer.py.
In the inspected June 2026 steady state, the rendering contract, batch contract, and container image all point in the same direction: fewer runtime dependencies and fewer cross-process surprises.
Invariants To Keep
- Keep the renderer in-process. Avoid reintroducing browser-driven or external binary-driven rendering unless the output contract genuinely requires it.
- Keep CPU-bound drawing work behind
asyncio.to_thread(...)so the async service stays responsive. - Keep the batch callback contract split between inline
reportBytesfor single-message success andfileKeyfor uploaded batch artifacts. - Keep image changes minimal. If a rendering change requires new apt packages, document why
services/report/Dockerfilecan no longer stay on the slimmer ReportLab path.
For the related lifecycle and async recovery pattern, see Async-First Worker Recovery.