Skip to content

TRUNK-6730 : Add Application Event Publishers for Module action events - #6384

Open
sudhanshu-raj wants to merge 21 commits into
openmrs:2.7.xfrom
sudhanshu-raj:TRUNK-6730
Open

TRUNK-6730 : Add Application Event Publishers for Module action events#6384
sudhanshu-raj wants to merge 21 commits into
openmrs:2.7.xfrom
sudhanshu-raj:TRUNK-6730

Conversation

@sudhanshu-raj

@sudhanshu-raj sudhanshu-raj commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Description of what I changed

This is publishing the module action events load, stop, start or unload of modules, and for auditing the module events in the audit log web module and related to this PR.

Issue I worked on

see https://issues.openmrs.org/browse/TRUNK-6730

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 ./mvnw 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

@sudhanshu-raj
sudhanshu-raj marked this pull request as ready for review July 30, 2026 20:42
@sudhanshu-raj

Copy link
Copy Markdown
Contributor Author

Hi @dkayiwa @wikumChamith @ibacher @ManojLL , please review the changes.


}

enum ModuleEventType {

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.

ModuleEventType is package private, so nothing outside org.openmrs.module can name it. If this merges as is, the discriminator on the event is unusable by the very modules the event exists for. A consumer that writes the obvious thing:

ModuleEventType type = event.getActionType();

fails to compile with ModuleEventType is not public in org.openmrs.module; cannot be accessed from outside package (I compiled this class on its own and then a consumer in another package to check). The auditlogweb PR this pairs with is already living with the consequence: it reaches the value as String.valueOf(moduleActionEvent.getActionType()) and keeps its own copy of the enum in auditlogweb/api/utils/ModuleEventType, complete with a fromName(String) parser and an UNKNOWN bucket, to get a typed value back. Every future consumer will have to do the same, and they will all drift apart. A public getter whose return type is inaccessible is also just a broken signature.

Could you make the enum public? That moves it into its own ModuleEventType.java anyway, since javac will not take a public top level type in a file named after something else. While you are in there, ModuleActionEvent and the enum are new public API with no javadoc at all, and this branch is 2.7.10-SNAPSHOT, so they want a short class comment and @since 2.7.10. The @since 2.7 on the private publishModuleEvents helper can go, since @since on a private method does not tell anyone anything.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

agree, fixed it

Comment on lines +146 to +171
boolean isModuleLoaded = true;

Module oldModule = getLoadedModulesMap().get(module.getModuleId());
if (oldModule != null) {
int versionComparison = ModuleUtil.compareVersion(oldModule.getVersion(), module.getVersion());
if (versionComparison < 0) {
// if oldModule version is lower, unload it and use the new
unloadModule(oldModule);
} else if (versionComparison == 0) {
if (replaceIfExists) {
// if the versions are the same and we're told to replaceIfExists, use the new
try {
Module oldModule = getLoadedModulesMap().get(module.getModuleId());
if (oldModule != null) {
int versionComparison = ModuleUtil.compareVersion(oldModule.getVersion(), module.getVersion());
if (versionComparison < 0) {
// if oldModule version is lower, unload it and use the new
unloadModule(oldModule);
} else if (versionComparison == 0) {
if (replaceIfExists) {
// if the versions are the same and we're told to replaceIfExists, use the new
unloadModule(oldModule);
} else {
isModuleLoaded = false;
// if the versions are equal and we're not told to replaceIfExists, jump out of here in a bad way
throw new ModuleException("A module with the same id and version already exists", module.getModuleId());
}
} else {
// if the versions are equal and we're not told to replaceIfExists, jump out of here in a bad way
throw new ModuleException("A module with the same id and version already exists", module.getModuleId());
isModuleLoaded = false;
// if the older (already loaded) module is newer, keep that original one that was loaded. return that one.
return oldModule;
}
} else {
// if the older (already loaded) module is newer, keep that original one that was loaded. return that one.
return oldModule;
}

getLoadedModulesMap().put(module.getModuleId(), module);

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.

isModuleLoaded starts out true and only becomes false on the two paths that bail out deliberately, so anything else thrown inside this try leaves it true and the finally publishes a successful load. If this merges as is, an audit log fed by these events records MODULE_LOAD / success for a load that threw and loaded nothing, and because nothing blows up there is no way to notice from the logs.

I reproduced it on your branch. Load a module, mark it mandatory, then load the same version again with replaceIfExists true. unloadModule(oldModule) throws MandatoryModuleException, the old module stays in the loaded map, the new one never gets put there, loadModule propagates the exception to its caller, and the listener still receives:

Test1 Module:MODULE_STOP:false, Test1 Module:MODULE_UNLOAD:false, Test1 Module:MODULE_LOAD:true

Flipping the default so the flag is only raised once the module is actually in the map fixes it, and the two explicit false assignments fall out:

Suggested change
boolean isModuleLoaded = true;
Module oldModule = getLoadedModulesMap().get(module.getModuleId());
if (oldModule != null) {
int versionComparison = ModuleUtil.compareVersion(oldModule.getVersion(), module.getVersion());
if (versionComparison < 0) {
// if oldModule version is lower, unload it and use the new
unloadModule(oldModule);
} else if (versionComparison == 0) {
if (replaceIfExists) {
// if the versions are the same and we're told to replaceIfExists, use the new
try {
Module oldModule = getLoadedModulesMap().get(module.getModuleId());
if (oldModule != null) {
int versionComparison = ModuleUtil.compareVersion(oldModule.getVersion(), module.getVersion());
if (versionComparison < 0) {
// if oldModule version is lower, unload it and use the new
unloadModule(oldModule);
} else if (versionComparison == 0) {
if (replaceIfExists) {
// if the versions are the same and we're told to replaceIfExists, use the new
unloadModule(oldModule);
} else {
isModuleLoaded = false;
// if the versions are equal and we're not told to replaceIfExists, jump out of here in a bad way
throw new ModuleException("A module with the same id and version already exists", module.getModuleId());
}
} else {
// if the versions are equal and we're not told to replaceIfExists, jump out of here in a bad way
throw new ModuleException("A module with the same id and version already exists", module.getModuleId());
isModuleLoaded = false;
// if the older (already loaded) module is newer, keep that original one that was loaded. return that one.
return oldModule;
}
} else {
// if the older (already loaded) module is newer, keep that original one that was loaded. return that one.
return oldModule;
}
getLoadedModulesMap().put(module.getModuleId(), module);
boolean isModuleLoaded = false;
try {
Module oldModule = getLoadedModulesMap().get(module.getModuleId());
if (oldModule != null) {
int versionComparison = ModuleUtil.compareVersion(oldModule.getVersion(), module.getVersion());
if (versionComparison < 0) {
// if oldModule version is lower, unload it and use the new
unloadModule(oldModule);
} else if (versionComparison == 0) {
if (replaceIfExists) {
// if the versions are the same and we're told to replaceIfExists, use the new
unloadModule(oldModule);
} else {
// if the versions are equal and we're not told to replaceIfExists, jump out of here in a bad way
throw new ModuleException("A module with the same id and version already exists", module.getModuleId());
}
} else {
// if the older (already loaded) module is newer, keep that original one that was loaded. return that one.
return oldModule;
}
}
getLoadedModulesMap().put(module.getModuleId(), module);
isModuleLoaded = true;

Worth a test as well, since none of the four new load tests exercise a throw coming out of unloadModule.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

flipped the success var value and added test cases too

Comment on lines 1066 to 1073
List<Module> dependentModulesStopped = new ArrayList<>();

if (mod != null) {

if (!ModuleFactory.isModuleStarted(mod)) {
return dependentModulesStopped;
}

try {
// if extends BaseModuleActivator
if (mod.getModuleActivator() != null) {
mod.getModuleActivator().willStop();
boolean isStoppedSuccess = true;
try {
if (mod != null) {

if (!ModuleFactory.isModuleStarted(mod)) {
return dependentModulesStopped;
}

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.

The early return for a module that is not running sits inside the try, so the finally still fires, and at that point isStoppedSuccess && !isModuleStarted(mod) evaluates to true. If this merges as is, the audit log gets a successful MODULE_STOP entry for a module that was never running.

The path that makes this matter is not somebody stopping a module twice, it is a failed start. When startModuleInternal throws before it reaches getStartedModulesMap().put(...) (a require_version mismatch against the running core is the everyday case) its catch block calls stopModule(module, false, true) to undo the startup, that call returns right here, and the listener sees:

Test1 Module:MODULE_STOP:true, Test1 Module:MODULE_START:false

I ran both that case and a plain stopModule on a loaded but never started module, and each produced a successful stop event. Someone auditing a server later cannot tell those apart from a real stop.

Hoisting the guard above the try fixes it:

Suggested change
List<Module> dependentModulesStopped = new ArrayList<>();
if (mod != null) {
if (!ModuleFactory.isModuleStarted(mod)) {
return dependentModulesStopped;
}
try {
// if extends BaseModuleActivator
if (mod.getModuleActivator() != null) {
mod.getModuleActivator().willStop();
boolean isStoppedSuccess = true;
try {
if (mod != null) {
if (!ModuleFactory.isModuleStarted(mod)) {
return dependentModulesStopped;
}
List<Module> dependentModulesStopped = new ArrayList<>();
if (mod == null || !ModuleFactory.isModuleStarted(mod)) {
return dependentModulesStopped;
}
boolean isStoppedSuccess = true;
try {
if (mod != null) {

After that the if (mod != null) wrapper is redundant, and so is the mod != null check in the finally, so both can go and the body loses a level of indentation.

One thing you might do while you are in here: wrapping the existing body in place is what forced the ~150 lines of re-indentation that make up most of this diff, and it rewrites the blame for the method. Pulling the old body into a private doStopModule(...) and leaving stopModule as the guard plus a try/finally around that call would keep the actual change visible, and it should also clear the five java:S1141 "extract this nested try block" findings Sonar raised on this file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's good edge case, fixed and added test case too

Comment on lines +1262 to +1266
if (!deleted) {
file.deleteOnExit();
isEventSuccess = false;
log.warn("Could not delete " + file.getAbsolutePath());
}

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.

Not being able to delete the .omod file does not mean the unload failed. By the time we reach this branch the module has been stopped, removed from the loaded modules map and its class loader disposed, and deleteOnExit() has just been registered so the leftover file goes away at JVM exit. The surrounding code has always treated a failed delete as expected and recoverable, which is exactly why that fallback is there.

If this merges as is, a completed unload is recorded as a failed one. I forced file.delete() to return false on your branch and got:

Test1 Module:MODULE_STOP:true, Test1 Module:MODULE_UNLOAD:false

with the module genuinely gone from getLoadedModules(). Whoever reads that audit entry later concludes a module failed to unload when it did unload, and the only real symptom, a stale file in the module repository, is not what the entry says.

I would drop the flag here and let the existing warn carry the undeleted file:

Suggested change
if (!deleted) {
file.deleteOnExit();
isEventSuccess = false;
log.warn("Could not delete " + file.getAbsolutePath());
}
if (!deleted) {
file.deleteOnExit();
log.warn("Could not delete " + file.getAbsolutePath());
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I guessed it same, anyway fixed

*/
public class ModuleFactory {

private static Logger logger = LoggerFactory.getLogger(ModuleFactory.class);

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 logger is never read. The class declares log ten lines further down and all 51 logging calls in the file, including the new publishModuleEvents, go through that one. It is also the only non final logger field here. Worth deleting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

My bad, didn't see !


private boolean isSuccess;

public ModuleActionEvent(Object source, ModuleEventType eventType, String moduleName, boolean isSuccess) {

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.

Should this carry the module id alongside the display name? Module.getName() is the <name> from config.xml, a human label that can change between versions, whereas moduleId is the stable identifier the rest of the module API keys on (getModuleById, the <moduleId>.started global property, ModuleConstants.CORE_MODULES). An audit row that only says "Test1 Module" is awkward to join back to anything, and for load and start events the module version would be worth having too.

Since this is brand new API, adding moduleId (and version) to the constructor now is a lot cheaper than after 2.7.10 ships with the current shape.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

True the module name itself can't tell more about itself, so version is needed and module id also can be useful for the easy API queries, added both.

@Override
public void setApplicationContext(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
ModuleFactory.setApplicationEventPublisher(applicationContext);

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.

ServiceContext already holds this context and hands it out through getApplicationContext(), so ModuleFactory can read it when it publishes rather than keeping a second copy in a static field:

ApplicationEventPublisher publisher = ServiceContext.getInstance().getApplicationContext();
if (publisher != null) {
	publisher.publishEvent(new ModuleActionEvent(ModuleFactory.class, eventType, moduleName, isSuccess));
}

That takes out this line and the whole change to this file, drops the static field, and keeps setApplicationEventPublisher off ModuleFactory's public surface. As written, any caller can hand ModuleFactory a different publisher or a null one and quietly switch module auditing off, which is an odd thing to expose on the class this feature is meant to make auditable. It also avoids ServiceContext picking up its first dependency on org.openmrs.module, which is the awkward direction given ModuleFactory already depends on org.openmrs.api.context.

I tried it on your branch: with this file reverted and publishModuleEvents reading the publisher off ServiceContext, all 19 ModuleFactoryTest tests pass, so the timing works out the same. Both routes are fed by this same callback. Not a blocker, but I think it is the better shape.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ahh new learning for me, thnx

Comment on lines +45 to +46
DatabaseUtil.loadDatabaseDriver(props.getProperty(CONNECTION_URL), props.getProperty(CONNECTION_DRIVER_CLASS,
null));

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 file has nothing to do with TRUNK-6730. The two changes in it are this line wrap and a tab added to an otherwise blank line near the top, which look like an IDE formatter pass on a file that happened to be open. Reverting it keeps the PR inside the api module.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

fixed

@sudhanshu-raj
sudhanshu-raj requested a review from dkayiwa August 1, 2026 18:18
import org.openmrs.logic.LogicService;
import org.openmrs.messagesource.MessageSourceService;
import org.openmrs.messagesource.impl.DefaultMessageSourceServiceImpl;
import org.openmrs.module.ModuleFactory;

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 need this import?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed

boolean isStoppedSuccess = true;
try {
dependentModulesStopped = doStopModule(mod, skipOverStartedProperty, isFailedStartup);
} catch(ModuleMustStartException ex) {

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 will happen if we get an another type of exception here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Pivoted to more general exception now, I thought this module exception could be only reason for failure event

@wikumChamith wikumChamith 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.

@sudhanshu-raj this PR contains a lot of formatting changes, and that's making it harder to review. Can we revert those changes?

@sudhanshu-raj

Copy link
Copy Markdown
Contributor Author

@sudhanshu-raj this PR contains a lot of formatting changes, and that's making it harder to review. Can we revert those changes?

Not sure why getting such view here, I tried now some changes but seems not that helpful, any suggestions ? Though if view from the IntelliJ compare with branch feature its not that cluttered and it's get very clear the new changes.

this.isSuccess = isSuccess;
}

public ModuleEventType getActionType() {

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.

shouldn't this be getEventType()?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done!

Comment on lines +22 to +30
private ModuleEventType eventType;

private String moduleId;

private String moduleName;

private String moduleVersion;

private boolean isSuccess;

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.

I think we can make these final.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done!

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's gonna happen if this parser fails due to a corruption? Don't we need to log that?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I added the tracker for this so when ever the module file parsing will fail, we will mark with fail event and will extract the module name and version(whatever is possible) from the module filename.

*
* @since 2.7.10
*/
public class ModuleActionEvent extends ApplicationEvent {

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 if we add a failureReason field? That way audit log can say why something failed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added

@wikumChamith

Copy link
Copy Markdown
Member

@sudhanshu-raj what do you think about the two issues SonarCloud is pointing at?

@sudhanshu-raj

Copy link
Copy Markdown
Contributor Author

@sudhanshu-raj what do you think about the two issues SonarCloud is pointing at?

One is for the cognitive complexity for which it saying to re-factor the whole method to make easy to understand but this is not new method I just added new name to it and another issue is to simplify the regular expression for the module file name pattern due to lazy quantifiers which says it can take increase runtime incase we are matching this regex against the large string but in this case string is just the module name which should not go in that way and thing is this lazy quantifier (*?) is needed to extract the proper module name and version format.

if(module == null) {
fileName = ModuleUtil.getModuleNameAndVersionFromFileName(moduleFile.getName());
isModuleLoaded = false;
String[] parts = fileName.split(":");

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.

getModuleNameAndVersionFromFileName returns null for an empty filename (e.g. new File("/")), so this split can NPE inside the finally, replacing the real ModuleException and publishing no event at all. Please null-guard the result and wrap this whole block in a try/catch so event metadata can never outrank the actual failure.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed !

Comment on lines +150 to +152
module = new Module(name, null, null, null, null, version, null);
}
publishModuleEvents(ModuleEventType.MODULE_LOAD, module, isModuleLoaded, failureReason);

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.

The activator is assigned to module and returned to the caller, breaking the documented "returns null on error" contract. loadModules() would hold onto it and fail much later somewhere confusing. Can we keep the synthetic module local to the event publish and keep the return value honest?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed, not tweaking the original return module .

@sonarqubecloud

Copy link
Copy Markdown

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.

3 participants