Skip to content

TRUNK-6516: Provide CDC mechanism with Debezium#6151

Merged
dkayiwa merged 1 commit into
openmrs:2.9.xfrom
rkorytkowski:TRUNK-6516
Jun 8, 2026
Merged

TRUNK-6516: Provide CDC mechanism with Debezium#6151
dkayiwa merged 1 commit into
openmrs:2.9.xfrom
rkorytkowski:TRUNK-6516

Conversation

@rkorytkowski

@rkorytkowski rkorytkowski commented Jun 2, 2026

Copy link
Copy Markdown
Member

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 --amend

  • I 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 --amend

  • I ran mvn clean package right before creating this pull request and
    added 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

* @since 2.9.x
*/
class BaseEvent extends ApplicationEvent implements OutboxableEvent {
class BaseEvent implements OutboxableEvent {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to Spring docs extending ApplicationEvent is no longer recommended or needed for events.

*
* @since 2.9.x
*/
public abstract class BaseSessionEvent extends BaseEvent {

@rkorytkowski rkorytkowski Jun 2, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes it easier to listen to events for certain entity types without a need for conditions in code.

@rkorytkowski
rkorytkowski force-pushed the TRUNK-6516 branch 5 times, most recently from 141a5d7 to d443cde Compare June 5, 2026 09:01
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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup.


<jdbc:initialize-database data-source="dataSource" ignore-failures="DROPS">
<jdbc:script location="classpath:create_shedlock_table.sql"/>
</jdbc:initialize-database>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix for tests in modules with custom DB instead of in-memory one.

@dkayiwa dkayiwa left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff plus the surrounding event/outbox/test infrastructure, and verified behavior locally:

  • OutboxEventIT passes 4/4 on both the base branch and this PR's branch (run locally) — covering POJO events → PayloadApplicationEvent wrapping → generic @EventListener/@TransactionalEventListener(AFTER_COMMIT) matching → aggregator → outbox JSON round-trip → shedlock-backed polling. The new create_shedlock_table.sql initializer works, and is strictly more robust than the old inline creation (it also covers @SkipBaseSetup and JUnit-4 paths that previously missed the table).
  • Round-tripped the new CDCEvent through the outbox-configured ObjectMapper (works) and a plain ObjectMapper (fails — see inline comment on getResolvableType()).
  • Checked the referenced debezium module commit against this API (entityClass may 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:

  1. Tests: the diff adds no core tests for the new API. Two cheap, high-value ones: typed-listener matching for CDCEvent<T> (the ResolvableTypeProvider dispatch is its main feature), and EventPublisher publishing a BaseSessionEvent with no session bound (the new catch branch is untested).
  2. Follow-up ticket worth filing (pre-existing, surfaced while verifying): outbox serialization silently depends on JobRunrConfig mutating the shared ObjectMapper bean (JobRunr's JacksonJsonMapper constructor switches it to field visibility and activates @class default typing — which is also the only reason abstract List<EntityEvent<?>> elements deserialize at all). If that coupling ever changes, outbox serialization of ResolvableTypeProvider events 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.
  3. 45 files still carry @since 2.9.x after this PR fixes 5 — worth a one-shot sweep before 2.9.0 ships.
  4. Trivia: OpenmrsPropertyConfig is a whitespace-only hunk; CDCTransactionStartedEvent/CDCTransactionCompletedEvent are 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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo (the one thing I'd block on): shapshotsnapshot. 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consistency nit: BaseEvent is Serializable (via OutboxableEvent), but only this class declares serialVersionUIDBaseEvent, 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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed.

System.setProperty("databaseName", dbContainer.getDatabaseName());
System.setProperty("databaseUsername", dbContainer.getUsername());
System.setProperty("databasePassword", dbContainer.getPassword());
if (database.equals("mariadb")) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@rkorytkowski

Copy link
Copy Markdown
Member Author

@dkayiwa addressed all review comments

@rkorytkowski
rkorytkowski force-pushed the TRUNK-6516 branch 2 times, most recently from 3020ad1 to 0440345 Compare June 8, 2026 07:41
@sonarqubecloud

sonarqubecloud Bot commented Jun 8, 2026

Copy link
Copy Markdown

}

public enum Operation {
READ, CREATE, UPDATE, DELETE, TRUNCATE

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is TRUNCATE used for?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok 👍

*
* @since 2.9.0
*/
public class CDCTransactionCompletedEvent extends BaseEvent {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we also need one for transaction rolled back?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No CDC for roll back. The DB log contains only committed data.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh i see! 😊

@rkorytkowski

Copy link
Copy Markdown
Member Author

@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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about the above sonar suggestions? 😊

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we ever followed this, but fine with me to start doing that. Technically no difference.

@dkayiwa
dkayiwa merged commit 57f4018 into openmrs:2.9.x Jun 8, 2026
13 checks passed
@dkayiwa

dkayiwa commented Jun 8, 2026

Copy link
Copy Markdown
Member

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.

rkorytkowski added a commit to rkorytkowski/openmrs-core that referenced this pull request Jun 30, 2026
rkorytkowski added a commit that referenced this pull request Jul 1, 2026
* 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)
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