Skip to main content

Report Job RLS Transaction Boundary

Context

On June 6, 2026, Lumie's multi-student report-generation flow accepted the batch request, published worker messages, processed callbacks, and created the ZIP file, but the UI kept surfacing Exam result not found while polling the report-job status.

The incident only affected the control-plane read path. The data-plane work was healthy:

  • POST /v1/exams/{examId}/reports/batch returned 202;
  • report-svc generated the reports successfully;
  • backend callbacks advanced the job and created batch_{jobId}.zip;
  • GET /v1/exams/{examId}/reports/jobs/{jobId} returned false 404s.

What Broke

ReportGenerationJob became a tenant-scoped row after the RLS migration, so even read-only JPA lookups needed an active Spring transaction. The batch-report status endpoint was still calling jobRepository.findById(jobId) outside @Transactional, which meant Postgres never received app.tenant_id for that connection.

The result looked contradictory from the outside:

  • write paths inside transactional helpers could still see and mutate the job row;
  • the public status-read path treated the same row as invisible and mapped the miss to ExamErrorCode.RESULT_NOT_FOUND.

Root Cause

The current code confirms the settled fix in lumie-backend/modules/exam/src/main/java/com/lumie/exam/application/service/ReportJobService.java:

  • getJobStatus(...) is now @Transactional(readOnly = true);
  • getZipBytes(...) loads its database state through the inner Tx component before doing MinIO I/O outside the transaction.

That behavior depends on the RLS binding contract introduced in:

  • lumie-backend/libs/common/src/main/java/com/lumie/common/tenant/RlsTenantContextAspect.java
  • lumie-backend/libs/common/src/main/java/com/lumie/common/tenant/RlsTenantTransactionBinder.java

Those components bind app.tenant_id only when the code path enters a Spring transaction, so a "simple repository read" became a functional correctness requirement after the RLS cutover.

Resolution

The shipped fix was to wrap tenant-scoped reads in explicit read-only transactions while keeping storage I/O outside the transaction boundary. That restored correct RLS visibility without turning ZIP download into a long-lived database transaction.

One drift item remains visible in the current source: the public miss still maps to ExamErrorCode.RESULT_NOT_FOUND, so the user-facing message can still read like an exam-result lookup problem even when the missing row is really a ReportGenerationJob.

Prevention

  • In tenant-scoped modules, treat JPA reads as transaction-bound unless the code is explicitly platform-wide.
  • Debug async features as two separate planes: job-control reads and worker-side data processing.
  • When a row exists by one path and disappears by another under the same tenant, check the transaction boundary before blaming RabbitMQ or storage.