OMR Report Job Lifecycle
Use this guide when OMR grading or report generation has already left the request path and the failure is in backend job state, callback counting, or artifact finalization.
This is distinct from worker CPU scaling. If the worker is simply slow or undersized, start with OMR Grading Scaling.
Symptoms
- An OMR or report job stays
PENDINGorPROCESSINGafter worker callbacks were expected. - A job reaches terminal state but success/fail counters do not add up to the requested item count.
- Duplicate callbacks are skipped but the job never finalizes.
- Report ZIP download returns not found even though individual PDFs exist.
- A retry or late callback mutates a terminal job.
Known Failure History
| Commit | Failure mode | Durable fix |
|---|---|---|
2b20846e | Job row persisted but RabbitMQ publish could be lost between commit and broker send. | Spring Modulith transactional outbox via JobRequestForwarder. |
bde8f625 | Concurrent callbacks hit optimistic locks and dedup keys could outlive failed transactions. | Retry callback updates and forget dedup keys after uncommitted failures. |
5023d873 | OMR results were not tied to per-job lifecycle strongly enough, and missing job versioning could break callback persistence. | Added V40 versioning, V41 result-to-job linkage, V42 lookup index, and status endpoints. |
4820197f | Stale async jobs could remain open indefinitely after worker loss. | ExamJobTimeoutSweeper marks old PENDING/PROCESSING OMR and report jobs as FAILED. |
a98cc026 | Batch report data used the wrong context for chunked jobs. | ReportBatchDataQueryService validates job, exam, tenant, and student chunk membership. |
Current Runtime Contract
| Surface | Source path | Required behavior |
|---|---|---|
| Job creation | ResultCommandService.confirmBatchOmrGrading(...), ReportJobService.createBatchJob(...) | Persist job and publish request events in the same transaction. |
| Outbox forwarding | modules/exam/src/main/java/com/lumie/exam/adapter/out/messaging/JobRequestForwarder.java | Forward committed outbox events to RabbitMQ and retry outstanding publications on restart. |
| OMR callback | OmrGradingJobProcessor.processGradingCallback(...) | Deduplicate per job/image, retry optimistic locks, finalize only when counters reach total. |
| Report callback | ReportJobService.processCallback(...) | Deduplicate per job/report, validate PDF artifact, finalize and create ZIP when done. |
| Timeout sweep | ExamJobTimeoutSweeper.sweepStaleJobs(...) | Fail stale open jobs after a generous timeout so polling resolves and users can retry. |
| Batch data | ReportBatchDataQueryService.getBatchData(...) | Validate job ownership, exam id, tenant, and chunk membership. |
The outbox retry behavior is configured in app/src/main/resources/application.yaml with spring.modulith.events.republish-outstanding-events-on-restart=true and completion-mode=delete.
Diagnosis
- Determine whether the job is OMR grading or report generation.
- Read the job row status, total count, processed count, success count, and fail count.
- Check backend logs for
Background job enqueued,enqueue_failed, duplicate callback skipped, optimistic lock retry, or terminal-state logs. - If no worker message was sent, inspect the Spring Modulith
event_publicationstate before blaming RabbitMQ. - If callbacks arrived but counters are wrong, inspect dedup keys and optimistic lock retry logs.
- If a worker disappeared, check whether
ExamJobTimeoutSweepermarked stale jobsFAILEDafter the timeout window. - If reports exist but ZIP download fails, inspect report
fileKeyvalidation and ZIPzipFileKey. - If batch report data is wrong for a subset, verify the
jobId,examId,studentIds, and tenant slug all belong to the same job.
Useful source check:
cd lumie-backend
rg -n "event_publication|tryFinalize|markIfAbsent|finalizeIfDone|zipFileKey|close.*stale|existsByIdAndStatusIn" \
modules/exam app/src/main/resources
Fix
- If the job row exists but no worker request was sent, preserve the outbox contract and replay outstanding events rather than manually publishing ad hoc messages.
- If duplicate callback handling blocked progress after an uncommitted failure, release the dedup key and let the callback retry path run again.
- If counters are below total and no more callbacks will arrive, use the stale-job close path rather than leaving the job
PROCESSING. - If report ZIP generation failed after terminal completion, rebuild the ZIP from validated per-student PDFs through
ReportJobService.getZipBytes(...). - If batch report data is using the wrong chunk, fix the
jobId/studentIdsrequest path rather than widening the query. - If callback processing fails with missing column or result-to-job lookup errors, verify
V40,V41, andV42have applied in order before changing Java code.
Verification Evidence
- job status reaches
COMPLETEDorFAILEDwith counters matching total, - outbox publications for the job are complete,
event_publicationhas no stuck publication for the job request,- duplicate callbacks do not increment counters twice,
- late callbacks do not mutate terminal jobs,
- report ZIP downloads successfully after finalization,
- tenant/job/exam/student validation rejects mismatched batch data requests.
Prevention
- Keep job persistence and enqueue intent atomic through the outbox.
- Keep worker callbacks idempotent per item index.
- Keep storage I/O outside long database transactions, but persist counters in short transactions.
- Do not remove optimistic-lock retries without replacing the concurrency protection.
- Treat job state, callback dedup, and artifact creation as one lifecycle, not separate features.