Skip to main content

OMR Grading Scaling

Use this guide when OMR batch grading slows down, queues build up, grading pods OOM, callbacks arrive out of order, or KEDA does not produce the expected throughput.

The incident behind this runbook happened during the April 7-8, 2026 OMR scaling push. The original shape was:

RabbitMQ -> Spring listener -> HTTP POST -> grading-svc

The recovered shape is:

RabbitMQ -> grading-svc direct consumer -> MinIO image fetch -> OpenCV grading
-> backend internal callback -> backend DB write and progress tracking

Symptom

Treat the old intermediary pattern as suspect when these signals appear together:

  • grading-svc OOMKills while RabbitMQ still has queued work.
  • Spring listener concurrency changes move the failure around instead of fixing it.
  • KEDA creates pods, but the batch finishes before cold pods contribute meaningful work.
  • CPU-bound OpenCV work shows long p95 latency even without Kubernetes CPU limits.
  • Backend callbacks need tenant, auth, and idempotency context that is hard to preserve through an HTTP relay.
  • Parallel grading creates duplicate ExamResult rows for the same student and exam.

Likely Cause

The most important failure was not the pod size. The queue consumer and the worker were different processes, so RabbitMQ backpressure stopped at Spring instead of at the actual image-processing work.

With RabbitMQ -> Spring -> HTTP -> grading-svc, there were two independent concurrency knobs:

LayerKnobFailure mode
Springlistener concurrencypushes more HTTP work than the worker can absorb
Workerreplicas, semaphore, memory, OpenCV threadssees bursts that are no longer shaped by RabbitMQ prefetch

That split made prefetch ineffective as the main safety control. It also made tenant headers, internal API auth, duplicate-result protection, and callback semantics harder to reason about.

Diagnostic Path

  1. Check whether the process consuming the RabbitMQ message is also the process doing the expensive image work.
  2. Inspect RabbitMQ prefetch. In the direct-consumer model, each grading pod should own a small bounded number of in-flight messages.
  3. Compare queue drain time with pod startup time. If pod startup plus OpenCV import warmup is about 40 seconds and batches finish sooner, minReplicaCount is the real capacity setting.
  4. Compare omr_grade timing with download, exam fetch, and callback timing. In the June 2026 follow-up, omr_grade dominated the request lifecycle.
  5. Check CPU limits before raising memory. CPU limits can create throttling for OpenCV-heavy work; Lumie keeps no CPU limit on this path and uses a realistic CPU request.
  6. Check database uniqueness for parallel result creation. Code-level "find then create" logic is not enough when multiple pods grade the same exam concurrently.

Useful read-only probes:

kubectl -n applications get scaledobject grading-svc -o yaml
kubectl -n applications get deploy grading-svc -o yaml
kubectl -n applications top pod -l app.kubernetes.io/name=grading-svc
kubectl -n applications logs deploy/grading-svc --tail=200

Fix

Use the direct worker-consumer pattern:

ConcernBeforeAfter
Message consumerSpring listenergrading-svc with aio-pika
Image accessSpring HTTP multipart relayworker downloads from MinIO
Result persistenceworker or relay path owns too much statebackend internal callback owns DB writes
Backpressuresplit across Spring and workerRabbitMQ prefetch plus pod count
Scaling unitHTTP requests into one serviceone pod equals one or a few bounded consumers

Keep the runtime assumptions explicit:

  • Set RabbitMQ prefetch to the intended per-pod in-flight work.
  • Keep CPU limits off for the CPU-bound grading worker unless production evidence says otherwise.
  • Keep CPU requests honest enough for scheduling. The June 2026 production path used 250m.
  • Cap OpenCV and native numeric library thread pools so queue concurrency does not multiply into excessive native threads.
  • Use a warm pool when batches are short enough that cold-start scale-up cannot help the first batch.

Verification

The April 2026 architecture recovery produced these scaling signals:

MetricBeforeAfter
170-image batchminutes with failures24s with 10 warm pods
89-image batchminutesabout 11s with 6 pods
Peak memory per podOOMKilled at 512Miabout 155Mi
Failure ratehigh0% in the measured run

The June 21, 2026 performance follow-up then improved the 100-image production command path:

StateWall timeUsecase avgomr_grade avgomr_grade p95
Original 50m request49.3s3970ms3828ms9375ms
Request raised to 250m38.3s3176ms2971ms5266ms
Optimized recognizer + 250m + thread env21.5s1743ms1327ms2011ms

Correctness still owns the release gate. Performance changes to OMR recognition must pass the golden real-scan corpus before rollout. The June 2026 optimization preserved output across 706 deduplicated scanned sheets.

Prevention

  • Keep consumer = worker for one-message-one-job queues unless a real fan-out router is needed.
  • Prefer broker-level backpressure over ad hoc semaphores for queue concurrency.
  • Keep a unique database constraint for (exam_id, student_id) style result ownership.
  • Treat KEDA minReplicaCount as the real capacity setting for short one-shot user batches.
  • Measure the expensive stage before changing Kubernetes resources.
  • Keep the OMR golden corpus runner current whenever recognition logic changes.

Source Incident Detail

The April 8, 2026 source incident records the scaling failure before the later June accuracy and performance work. The first architecture tried to coordinate one user's OMR grading burst through too many application-level concurrency controls and callback assumptions.

DetailValue
Affected workflowOMR batch grading
Initial failure shape11 cascading failures in the first architecture
Final modelone RabbitMQ message per grading job, broker backpressure, KEDA-backed workers
Important database invariantresult ownership needs uniqueness such as (exam_id, student_id)
Later validationJune work preserved output over 706 deduplicated scans

The source record grouped the original anti-patterns into these classes:

ClassFailure mode
fan-out in the wrong layerthe application tried to coordinate work that belonged in the broker
weak idempotencyrepeated callbacks and retries could drift job/result state
ad hoc concurrencysemaphores and local coordination fought the queue model
missing capacity floorshort user-triggered batches could finish before autoscaling reacted
insufficient observabilityexpensive stages were not isolated before resource changes

The second architecture kept the queue model simple: a message represents a unit of work, the broker controls delivery, workers process and callback, and KEDA keeps a warm minimum capacity for the expected short-batch shape.

When changing this path, keep the troubleshooting order:

  1. Verify message shape and queue ownership before changing worker code.
  2. Verify idempotency and unique constraints before increasing concurrency.
  3. Verify KEDA minimum capacity before relying on scale-from-zero behavior.
  4. Verify recognition correctness before accepting any performance improvement.