Repository navigation
Project notifications to RabbitMQ through an outbox (Spring Modulith) - #20
Merged
Merged
Conversation
Spring Modulith's JDBC event publication registry stores ProjectCreated and ProjectUpdated in event_publication within the changing transaction. ProjectNotifier sends them after commit to the resourceapi.projects topic exchange (project.created / project.updated, JSON, traceparent) on its own virtual-thread executor, and only counts a send once the broker confirmed it; the row is then deleted. UnsentNotifications resends what is left, one instance at a time (Redis lock). Delivery is at least once. Modulith's RabbitMQ externalization isn't used: in module-listener mode it doesn't wait for publisher confirms, so a refused message was counted as sent (caught by the test that deletes the exchange). Also: spring.task.execution.mode=force keeps Boot's application task executor next to the notification executor; failed fire-and-forget tasks are logged in one line. RabbitMQ in compose.yaml and in tests (Testcontainers, an exchange per test context). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
POST /sync now puts a ProjectSyncRequest on the durable queue resourceapi.project-sync (202 once the broker confirmed it, 503 if RabbitMQ is unreachable) instead of running it on the in-memory @async executor. Requests survive restarts and wait for a free consumer instead of being refused. ProjectSyncConsumer (4 consumers per instance, virtual threads, prefetch 1, trace continued from the request) runs the sync with Spring's RetryTemplate: SDLC failures that may pass (unreachable, 5xx, 429, our own rate limit) are retried after 1s and 2s; a sync that still fails, or fails otherwise, is dead-lettered to <queue>.dead and logged in one line, without the upstream body. The per-project lock is taken by the request and released by the consumer via the owner id carried in the message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
With 16 sends each waiting for an unreachable broker, further sends made the committing request wait for a free slot, coupling API latency to the broker. A refused send leaves its event in the outbox, sent again later. README: consumers should order notifications by occurredAt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Second of three event/messaging PRs, stacked on #19 (Spring events). Other systems now learn about project changes from RabbitMQ, and no notification is lost if the broker is down or the app stops right after a change.
ProjectCreatedresourceapi.projects(resourceapi.notifications.exchange)project.createdProjectUpdated(with the changed fields)project.updatedMessages are JSON, carry
occurredAt(from #19) and the request's trace (traceparentheader).How the outbox works
event_publicationinschema.sql) stores each event in the transaction that changed the project. A row exists exactly if the change was committed.ProjectNotifier(@Async @TransactionalEventListener) sends the event and waits for the broker's publisher confirm. Only then is the row deleted (completion-mode=delete).UnsentNotifications(@Scheduled, every 1 min, for events older than 1 min) sends it again. With several instances, a Redis lock makes one instance do it at a time.occurredAt. This is documented in the README.Decisions worth reviewing
spring-modulith-events-amqp). I started with it. The test that makes the broker refuse a message (exchange deleted) showed the event being deleted from the outbox although RabbitMQ never took it. In its default (module-listener) mode it uses fire-and-forgetconvertAndSendwithout confirms; its confirm-waiting variant only exists for third-party outbox libraries (JobRunr, Namastack).ProjectNotifieris about 20 lines and doesconvertAndSend+waitForConfirmsOrDie(publisher-confirm-type=simple).notificationExecutor): virtual threads, trace propagated, at most 16 sends at once.TaskRejectedException, and the request is unaffected, because after-commit listeners run inafterCompletion.applicationTaskExecutor.spring.task.execution.mode=forcekeeps it, and theAsyncConfigurerpoints plain@Asyncat it.@TransactionalEventListener, includingSyncOnProjectChangefrom Project events; sync a project's name after create or re-key #19. An unfinished one is re-run by the resubmission too. A listener whose condition doesn't match leaves nothing behind; a test checks this.schema.sqllike the others (H2 version of Modulith's v2 schema).Local and Docker
rabbitmq:4-management-alpine(5672, management UI on 15672).SPRING_RABBITMQ_HOST.docker buildneeds no broker.Tests
RabbitTestContainerInitializer, shared container, an exchange per test context, like the Redis key prefix).NotificationsIntegrationTest:project.createdwith the project's JSON and atraceparentheader, outbox emptied;project.updatedwith"changed":["NAME"];ProjectUpdatednot listened to;Full suite: 124 tests pass.
Docker check of the stack's top (#21), AOT and native images, against Redis 8, RabbitMQ 4 and a WireMock stand-in for Jira:
project.created, thenproject.updated(NAME) after the automatic sync;🤖 Generated with Claude Code
https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2