Skip to main content

Messaging

RabbitMQ is a selective boundary in Lumie's backend, not the default way modules collaborate. Most in-process follow-up work stays on Spring Modulith's JDBC outbox and @ApplicationModuleListener; queue-backed messaging is mainly owned by the exam module.

This page is a reference document for the current async contract.

Source Paths

PathRole
libs/messaging/src/main/java/com/lumie/messaging/config/RabbitMqConstants.javaShared AMQP names and routing keys
app/src/main/resources/application.yamlRabbitMQ connection and Modulith retry settings
modules/exam/src/main/java/com/lumie/exam/adapter/out/config/RabbitMqConfig.javaRabbitTemplate, JSON message converter, listener-container behavior
modules/exam/src/main/java/com/lumie/exam/adapter/out/messaging/JobRequestForwarder.javaOutbox-to-RabbitMQ bridge
modules/exam/src/main/java/com/lumie/exam/adapter/in/messaging/*RabbitMQ callback consumers
modules/billing/src/main/java/com/lumie/billing/adapter/in/event/TenantCreatedListener.javaIn-process Modulith event consumer example
modules/staff/src/main/java/com/lumie/staff/adapter/in/event/OwnerRegisteredListener.javaIn-process Modulith event consumer example
modules/exam/src/main/java/com/lumie/exam/adapter/in/event/StudentRegisteredListener.javaIn-process Modulith event consumer example

Runtime Flow

Queue-Backed Contracts In Use Today

PurposeProducerMessage contractQueue or routingConsumer
OMR grading requestJobRequestForwarder.onOmrGradingRequested(...)OmrGradingImageMessageexchange lumie.commands, routing key grading.omr.request, queue grading.omr-requestgrading worker
OMR grading callbackgrading workerOmrGradingCallbackRequestqueue grading.omr-callbackOmrGradingCallbackListener
Report generation requestJobRequestForwarder.onReportGenerationRequested(...)ReportGenerationMessageexchange lumie.commands, routing key report.generation.request, queue report.generation-requestreport worker
Report generation callbackreport workerReportCallbackRequestqueue report.generation-callbackReportGenerationCallbackListener

Example Message Bodies

These examples come directly from OmrGradingImageMessage, ReportGenerationMessage, OmrGradingCallbackRequest, ReportCallbackRequest, and lumie-worker/contracts/mq-schemas-v1.yaml.

OMR Request

{
"jobId": 341,
"examId": 15,
"tenantSlug": "acme",
"imageKey": "tmp/15/omr/20260614-01/page-1.png",
"imageIndex": 0,
"totalImages": 2,
"schemaVersion": 1
}

OMR Callback

{
"jobId": 341,
"examId": 15,
"tenantSlug": "acme",
"imageKey": "tmp/15/omr/20260614-01/page-1.png",
"imageIndex": 0,
"totalImages": 2,
"success": true,
"error": null,
"phoneNumber": "01012345678",
"totalScore": 92,
"grade": 1,
"results": [
{
"questionNumber": 1,
"studentAnswer": "3",
"correctAnswer": "3",
"score": 5,
"earnedScore": 5,
"questionType": "MULTIPLE_CHOICE"
}
]
}

Report Request

{
"jobId": 812,
"examId": 15,
"studentId": 101,
"tenantSlug": "acme",
"reportIndex": 0,
"totalReports": 2,
"schemaVersion": 1
}

Report Callback

{
"jobId": 812,
"examId": 15,
"studentId": 101,
"tenantSlug": "acme",
"reportIndex": 0,
"totalReports": 2,
"success": true,
"error": null,
"reportBytes": "<base64-pdf>"
}

Why The Exam Module Uses An Outbox

OmrGradingRequestedEvent and ReportGenerationRequestedEvent are published inside the same transaction that persists the job row. Spring Modulith stores them in public.event_publication, then JobRequestForwarder publishes to RabbitMQ after commit.

That buys a specific guarantee:

  • if the transaction rolls back, no message is sent
  • if the transaction commits but the broker send fails, the publication stays incomplete and is retried on restart

It does not buy a two-phase commit with RabbitMQ. The guarantee is "job persisted and message will be retried," not "database and broker committed atomically in one XA transaction."

Listener Behavior And Failure Handling

RabbitMqConfig sets:

  • JSON conversion for publishers and listeners
  • defaultRequeueRejected=false
  • missingQueuesFatal configurable via lumie.rabbitmq.missing-queues-fatal

The important consequence is:

  • listener exceptions do not tight-loop requeue in the same consumer thread
  • the backend expects broker-side delivery-limit and DLQ policies to count redeliveries and eventually route failures to lumie.dlx

The exam callback listeners also:

  • reject messages with blank or unknown tenantSlug
  • resolve tenantSlug -> tenantId through TenantService
  • restore tenant context with TenantContextHolder.withinContext(...)
  • log structured background-job fields for success and failure

In-Process Modulith Events That Do Not Leave The JVM

These are still asynchronous after commit, but they are not RabbitMQ messages:

EventProducer moduleConsumer modulePurpose
TenantCreatedEventtenantbillingProvision initial trial subscription
OwnerRegisteredEventauthstaffBootstrap OWNER staff row
StudentRegisteredEventstudentexamBackfill previously unmatched exam results

These events use the same event_publication durability mechanism as the queue-backed exam events.

What Stays Synchronous Or HTTP-Based

  • AI chat goes over HTTP to chatbot-svc, which calls back into /internal/chatbot/**.
  • OmrServiceClient.gradeOmrImage(...) is still a direct HTTP path for the one-image grading contract.
  • ReportServiceClient.generateReport(...) is still a direct HTTP path for synchronous report generation.
  • Billing webhooks arrive over HTTP at /internal/webhooks/toss/billing.
  • Normal module-to-module collaboration uses libs/internal-api, not RabbitMQ.

Contract Drift Found During Review

  • RabbitMqConstants still defines GRADING_OMR_COMPLETED_QUEUE = "grading.omr-completed", but the current backend listeners and exam internal controller refer to grading.omr-callback.
  • The docs on this page follow the live consumer code (OmrGradingCallbackListener) because that is the active runtime contract in lumie-backend.

Verification Commands

cd /Users/bluemayne/Projects/Lumie/lumie-backend
./gradlew test
./gradlew :modules:exam:test
./gradlew :app:test

When you need stronger confidence in the durability path, also run:

./gradlew integrationTest

Expected success signals:

  • Gradle exits with BUILD SUCCESSFUL, and the exam callback listeners still deserialize the documented JSON bodies without wrapper envelopes.
  • OmrGradingCallbackListener still binds grading.omr-callback, ReportGenerationCallbackListener still binds report.generation-callback, and the request publishers still use exchange lumie.commands.