TRUNK-6516: Provide CDC mechanism with Debezium#6151
Conversation
| * @since 2.9.x | ||
| */ | ||
| class BaseEvent extends ApplicationEvent implements OutboxableEvent { | ||
| class BaseEvent implements OutboxableEvent { |
There was a problem hiding this comment.
According to Spring docs extending ApplicationEvent is no longer recommended or needed for events.
| * | ||
| * @since 2.9.x | ||
| */ | ||
| public abstract class BaseSessionEvent extends BaseEvent { |
There was a problem hiding this comment.
Moving sessionId to BaseSessionEvent since BaseEvent is now extended by CDCEvent, for which sessionId does not make any sense as it is published by an asynchronous task.
|
|
||
| private boolean shapshot = false; | ||
|
|
||
| private Class<T> entityType; |
There was a problem hiding this comment.
It makes it easier to listen to events for certain entity types without a need for conditions in code.
141a5d7 to
d443cde
Compare
| import org.springframework.test.context.TestExecutionListeners; | ||
| import org.springframework.test.context.junit.jupiter.SpringExtension; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
| import org.xml.sax.InputSource; |
|
|
||
| <jdbc:initialize-database data-source="dataSource" ignore-failures="DROPS"> | ||
| <jdbc:script location="classpath:create_shedlock_table.sql"/> | ||
| </jdbc:initialize-database> |
There was a problem hiding this comment.
Fix for tests in modules with custom DB instead of in-memory one.
dkayiwa
left a comment
There was a problem hiding this comment.
Reviewed the full diff plus the surrounding event/outbox/test infrastructure, and verified behavior locally:
OutboxEventITpasses 4/4 on both the base branch and this PR's branch (run locally) — covering POJO events →PayloadApplicationEventwrapping → generic@EventListener/@TransactionalEventListener(AFTER_COMMIT)matching → aggregator → outbox JSON round-trip → shedlock-backed polling. The newcreate_shedlock_table.sqlinitializer works, and is strictly more robust than the old inline creation (it also covers@SkipBaseSetupand JUnit-4 paths that previously missed the table).- Round-tripped the new
CDCEventthrough the outbox-configuredObjectMapper(works) and a plainObjectMapper(fails — see inline comment ongetResolvableType()). - Checked the referenced debezium module commit against this API (
entityClassmay be null for unmapped tables —getResolvableType()handles that correctly).
Overall the direction looks right: POJO events per current Spring guidance, sessionId separated into BaseSessionEvent so CDC events don't carry a meaningless session id, and the EventPublisher change is what makes publishing from background threads possible at all. The test-infra changes unblock module-supplied containers.
Beyond the inline comments:
- Tests: the diff adds no core tests for the new API. Two cheap, high-value ones: typed-listener matching for
CDCEvent<T>(theResolvableTypeProviderdispatch is its main feature), andEventPublisherpublishing aBaseSessionEventwith no session bound (the new catch branch is untested). - Follow-up ticket worth filing (pre-existing, surfaced while verifying): outbox serialization silently depends on
JobRunrConfigmutating the sharedObjectMapperbean (JobRunr'sJacksonJsonMapperconstructor switches it to field visibility and activates@classdefault typing — which is also the only reason abstractList<EntityEvent<?>>elements deserialize at all). If that coupling ever changes, outbox serialization ofResolvableTypeProviderevents breaks outright; and a context-wide mapper with default typing enabled is a deserialization-gadget hazard if any module reuses the bean on untrusted input. A dedicated, explicitly-configured outbox mapper would remove both risks. - 45 files still carry
@since 2.9.xafter this PR fixes 5 — worth a one-shot sweep before 2.9.0 ships. - Trivia:
OpenmrsPropertyConfigis a whitespace-only hunk;CDCTransactionStartedEvent/CDCTransactionCompletedEventare identical and could share a small base.
I'd treat the shapshot typo as the one blocker while this API is still unreleased; everything else is should-fix or at your discretion.
🤖 Review prepared with Claude Code
| */ | ||
| public class CDCEvent<T> extends BaseEvent implements ResolvableTypeProvider { | ||
|
|
||
| private boolean shapshot = false; |
There was a problem hiding this comment.
Typo (the one thing I'd block on): shapshot → snapshot. This ships in 2.9.0 as public API (isShapshot()/setShapshot()), and since outbox serialization is field-based, the misspelling also lands in persisted payloads ({"shapshot":false,...} — verified locally). Renaming after release would be breaking; renaming now is free — code search shows openmrs-module-debezium doesn't reference it yet.
Related design question: the debezium module currently never calls setShapshot(...) (it maps Debezium op="r" to Operation.READ instead), so as merged the flag can never be true. Either wire it to payload.source.snapshot in the module or drop it from core.
| } | ||
|
|
||
| public enum Operation { | ||
| READ, CREATE, UPDATE, DELETE |
There was a problem hiding this comment.
Debezium also emits op="t" (truncate) — the module's getOperation() currently maps that to a null operation. Worth deciding explicitly whether to add TRUNCATE here or document that truncates are ignored.
| } | ||
|
|
||
| @Override | ||
| public @Nullable ResolvableType getResolvableType() { |
There was a problem hiding this comment.
Suggest @JsonIgnore here (and on the pre-existing EntityEvent.getResolvableType()): serializing this class with a plain ObjectMapper fails with InvalidDefinitionException: Direct self-reference leading to cycle (...resolvableType->ResolvableType["componentType"]->...) — reproduced locally against this branch. It only works through the outbox today because JobRunrConfig's JacksonJsonMapper mutates the shared ObjectMapper bean to field-based visibility, so getters are never serialized. Since the class javadoc recommends forwarding events to message brokers, the first consumer to use a standard mapper will hit this.
There was a problem hiding this comment.
Added @JsonIgnore to getResolvableType
| * blocked for too long, and it keeps up with the events queue. | ||
| * <p> | ||
| * If you combine {@link org.springframework.context.event.EventListener} with {@link org.springframework.scheduling.annotation.Async} | ||
| * you may run longer processing, but you loose the ability to re-try if anything fails, and in worst case you may lose the event. |
There was a problem hiding this comment.
Javadoc typos: "you loose the ability" → "lose"; and above: "not happening inside an application transaction rather are fetched" → "rather they are fetched".
| */ | ||
| public abstract class BaseSessionEvent extends BaseEvent { | ||
| private static final long serialVersionUID = 1L; | ||
|
|
There was a problem hiding this comment.
Consistency nit: BaseEvent is Serializable (via OutboxableEvent), but only this class declares serialVersionUID — BaseEvent, CDCEvent and the two CDCTransaction*Events don't. Add it everywhere or drop it here.
Also: double blank line before getSessionId(), and the (String sessionId, Set<String> tags) constructor has no callers (fine if intended as API).
| BaseSessionEvent sessionEvent = (BaseSessionEvent) event; | ||
| sessionEvent.setSessionId(sessionId); | ||
| } catch (HibernateException e) { | ||
| log.warn("No session bound to the current thread", e); |
There was a problem hiding this comment.
Minor: this warns with a full stack trace on every session-less publish — if a scheduled task routinely publishes entity events, it gets noisy. Consider log.debug, or warn without the throwable. Otherwise the catch itself is the right call — it's what lets session events be published from background threads.
| /** | ||
| * @since 2.9.0 | ||
| */ | ||
| public void ensureDatabaseRunning() { |
There was a problem hiding this comment.
This overridable method is invoked from the constructor, so a subclass override runs before subclass instance fields are initialized — it can only safely reference static state. That's the usual testcontainers idiom, but easy to trip over; suggest documenting the constraint in the javadoc. Also protected seems more fitting than public for an override hook.
| } | ||
| } | ||
|
|
||
| public static void ensureDatabaseRunning(JdbcDatabaseContainer<?> dbContainer, String database) { |
There was a problem hiding this comment.
Unlike the legacy paths, the custom container isn't recorded in this class's static state — so if a default-container test later runs in the same JVM, ensureDatabaseRunning() will start a second container and repoint the database* system properties mid-suite. Probably fine for homogeneous module suites, but worth a comment. (Also a couple of trailing-whitespace lines crept into this method.)
| System.setProperty("databaseName", dbContainer.getDatabaseName()); | ||
| System.setProperty("databaseUsername", dbContainer.getUsername()); | ||
| System.setProperty("databasePassword", dbContainer.getPassword()); | ||
| if (database.equals("mariadb")) { |
There was a problem hiding this comment.
database.equals("mariadb") NPEs if database is null — prefer "mariadb".equals(database). And com.mysql.jdbc.Driver below is the deprecated alias (works on mysql-connector-java 8.0.30 but logs a deprecation notice) — com.mysql.cj.jdbc.Driver is the proper name.
| public abstract class BaseModuleContextSensitiveTest extends BaseContextSensitiveTest { | ||
| @Transactional | ||
| @Rollback | ||
| public abstract class BaseModuleContextSensitiveTest extends BaseModuleContextSensitiveNonTransactionalTest { |
There was a problem hiding this comment.
Compatibility note: this class no longer extends BaseContextSensitiveTest. Moving the implementation up to BaseContextSensitiveNonTransactionalTest is binary-compatible and @Transactional/@Rollback are re-applied, so behavior is preserved — but module test utilities doing instanceof BaseContextSensitiveTest (or typed against it) will need adjusting. Worth a release note.
|
@dkayiwa addressed all review comments |
3020ad1 to
0440345
Compare
|
| } | ||
|
|
||
| public enum Operation { | ||
| READ, CREATE, UPDATE, DELETE, TRUNCATE |
There was a problem hiding this comment.
See https://debezium.io/documentation/reference/stable/connectors/mysql.html#mysql-truncate-events not useful to us at the moment, but added as suggested by AI for completeness.
| * | ||
| * @since 2.9.0 | ||
| */ | ||
| public class CDCTransactionCompletedEvent extends BaseEvent { |
There was a problem hiding this comment.
Do we also need one for transaction rolled back?
There was a problem hiding this comment.
No CDC for roll back. The DB log contains only committed data.
|
@dkayiwa thanks for reviewing! As soon as this is merged I'll be able to issue a PR with post-commit review fixes for TRUNK-6429. Also have a pending PR for TRUNK-6515 that depends on this one :) |
|
|
||
| public EntityEvent(T entity, Set<String> tags) { | ||
| super(entity, tags); | ||
| super(tags); |
There was a problem hiding this comment.
What do you think about the above sonar suggestions? 😊
There was a problem hiding this comment.
I don't think we ever followed this, but fine with me to start doing that. Technically no difference.
|
As per my initial review, this looks good. So i have merged it to unblock you. In the meantime, i will continue with testing it out by using it for events for the querystore/chartsearchai module. If i experience any issues, i will raise them here. |
(cherry picked from commit 57f4018)
* TRUNK-6429: Create application events for service method calls and entity changes (#6084) (cherry picked from commit 144fee3) * TRUNK-6516: Provide CDC mechanism with Debezium (#6151) (cherry picked from commit 57f4018) * TRUNK-6429: Addressing post commit review (#6190) (cherry picked from commit a4331e2)



Description of what I changed
Please see openmrs/openmrs-module-debezium@6fdf039 for a module that produces CDC events.
Issue I worked on
see https://issues.openmrs.org/browse/TRUNK-6516
Checklist: I completed these to help reviewers :)
My IDE is configured to follow the code style of this project.
No? Unsure? -> configure your IDE, format the code and add the changes with
git add . && git commit --amendI have added tests to cover my changes. (If you refactored
existing code that was well tested you do not have to add tests)
No? -> write tests and add them to this commit
git add . && git commit --amendI ran
mvn clean packageright before creating this pull request andadded all formatting changes to my commit.
No? -> execute above command
All new and existing tests passed.
No? -> figure out why and add the fix to your commit. It is your responsibility to make sure your code works.
My pull request is based on the latest changes of the master branch.
No? Unsure? -> execute command
git pull --rebase upstream master