Skip to main content

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:

DateCommitChange
2026-02-08543ebb3bPlaywright replaced with WeasyPrint.
2026-03-15373b6d44, cd6ea3b7, 4ac41e5b, bdd2fbfd, 5311a873WeasyPrint replaced with Playwright, then optimized with browser warm-up and parallel backend fetches.
2026-03-15ae1fc2c2, 6ca3ccfc, 5822041bWeasyPrint+PyMuPDF replaced with imgkit, pinned to Bookworm for wkhtmltopdf, then reverted the same day.
2026-06-216ee24b1d, bc2595a8, 21059827Rendering 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.py and services/report/services/report_generator.py during the March 15, 2026 series.
  • WeasyPrint and fitz moved rendering back into Python, but still depended on HTML templating plus PDF-to-image conversion in services/report/services/report_generator.py.
  • imgkit replaced that with wkhtmltoimage, which added an OS package dependency in services/report/Dockerfile and had to be pinned to Bookworm in 6ca3ccfc.
  • 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:

  1. services/report/src/usecase.py gathers backend data concurrently.
  2. services/report/src/adapters/reportlab_renderer.py renders the PDF in a worker thread with asyncio.to_thread(...).
  3. The single-report path returns inline PDF bytes through services/report/main.py.
  4. The batch path uploads PDFs through services/report/src/adapters/minio_store.py and returns MinIO keys through services/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 reportBytes for single-message success and fileKey for uploaded batch artifacts.
  • Keep image changes minimal. If a rendering change requires new apt packages, document why services/report/Dockerfile can no longer stay on the slimmer ReportLab path.

For the related lifecycle and async recovery pattern, see Async-First Worker Recovery.