Skip to content

Project events; sync a project's name after create or re-key - #19

Merged
andreasarf merged 8 commits into
mainfrom
claude/practical-dirac-t5uv58
Nov 5, 2026
Merged

andreasarf merged 8 commits into
mainfrom
claude/practical-dirac-t5uv58

Conversation

@andreasarf

@andreasarf andreasarf commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

Summary

First of three event/messaging PRs: in-app Spring events, then outbox notifications to RabbitMQ, then durable sync jobs on RabbitMQ.

Events. ProjectService publishes Spring application events (new package events) inside the transaction that changes a project:

  • ProjectCreated: the project's id, SDLC system, external id and name.
  • ProjectUpdated: the new state plus which fields changed (NAME, EXTERNAL_ID, SDLC_SYSTEM).
    • Not published when an update changes nothing, or when it is rejected (validation or conflict).
    • Renames made by a sync are published too.
  • occurredAt on both, so a listener (or, after Project notifications to RabbitMQ through an outbox (Spring Modulith) #20, another system) that receives events about one project out of order can drop stale ones. Added in the review round.

SyncOnProjectChange. A @TransactionalEventListener, so it only reacts to committed changes. It starts a background sync, the same as POST …/sync, when a project is:

  • created, so a project created without a name gets it from the SDLC system;
  • re-keyed (pointed at another SDLC project), so its name follows.

Other details:

  • Failures. A sync that can't start is logged and skipped, and the create or update itself still succeeds. Spring runs after-commit listeners in afterCompletion, so even an unexpected exception there can't fail the request (checked in Spring's source).
  • No loop. A sync only renames, so its own ProjectUpdated doesn't start another sync.
  • Switch. resourceapi.sync.on-change (default true). The test config turns it off, so tests that create projects don't call SDLC systems unexpectedly.
  • Transaction. createProject is now @Transactional, so its event belongs to the creating transaction.

Known cost. The listener runs on the request thread after commit. With Redis unreachable, the lock attempt adds about a second to a create or re-key, as it already does for POST …/sync. This is documented in the README.

Tests

  • ProjectEventsTest (@RecordApplicationEvents):
    • create publishes ProjectCreated with occurredAt;
    • rename publishes ProjectUpdated{NAME}, later than the create;
    • re-key reports EXTERNAL_ID + SDLC_SYSTEM;
    • a no-op update and a rejected update publish nothing.
  • SyncOnProjectChangeTest (WireMock SDLC system, real Redis):
    • a project created without a name gets it from the SDLC system;
    • re-keying fetches the new project's name;
    • renaming locally doesn't call the SDLC system.
  • Mutation check: without the re-key condition, the last test fails.

Full suite: 120 tests pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2

ProjectService publishes ProjectCreated and ProjectUpdated (with the changed
fields) inside the changing transaction. SyncOnProjectChange listens after
commit and starts a background sync when a project is created or pointed at
another SDLC project, so its name comes from the SDLC system without a
separate sync request (resourceapi.sync.on-change, off in tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012MLyPXGHeCc1fvTeB8JnT2
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
Events about one project can reach a listener or another system out of
order (sent in parallel, or retried later), so each carries when the change
was made and an event older than the last applied can be discarded.

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
@andreasarf
andreasarf merged commit 2d80a3a into main Nov 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants