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/batchreturned202;report-svcgenerated the reports successfully;- backend callbacks advanced the job and created
batch_{jobId}.zip; GET /v1/exams/{examId}/reports/jobs/{jobId}returned false404s.
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 innerTxcomponent 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.javalumie-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.