TRUNK-6730 : Add Application Event Publishers for Module action events - #6384
TRUNK-6730 : Add Application Event Publishers for Module action events#6384sudhanshu-raj wants to merge 21 commits into
Conversation
dce4039 to
7efb5f1
Compare
|
Hi @dkayiwa @wikumChamith @ibacher @ManojLL , please review the changes. |
|
|
||
| } | ||
|
|
||
| enum ModuleEventType { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
agree, fixed it
| 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); |
There was a problem hiding this comment.
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:
| 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.
There was a problem hiding this comment.
flipped the success var value and added test cases too
| 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; | ||
| } |
There was a problem hiding this comment.
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:
| 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.
There was a problem hiding this comment.
It's good edge case, fixed and added test case too
| if (!deleted) { | ||
| file.deleteOnExit(); | ||
| isEventSuccess = false; | ||
| log.warn("Could not delete " + file.getAbsolutePath()); | ||
| } |
There was a problem hiding this comment.
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:
| if (!deleted) { | |
| file.deleteOnExit(); | |
| isEventSuccess = false; | |
| log.warn("Could not delete " + file.getAbsolutePath()); | |
| } | |
| if (!deleted) { | |
| file.deleteOnExit(); | |
| log.warn("Could not delete " + file.getAbsolutePath()); | |
| } |
There was a problem hiding this comment.
I guessed it same, anyway fixed
| */ | ||
| public class ModuleFactory { | ||
|
|
||
| private static Logger logger = LoggerFactory.getLogger(ModuleFactory.class); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
My bad, didn't see !
|
|
||
| private boolean isSuccess; | ||
|
|
||
| public ModuleActionEvent(Object source, ModuleEventType eventType, String moduleName, boolean isSuccess) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Ahh new learning for me, thnx
| DatabaseUtil.loadDatabaseDriver(props.getProperty(CONNECTION_URL), props.getProperty(CONNECTION_DRIVER_CLASS, | ||
| null)); |
There was a problem hiding this comment.
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.
…param and added test cases for it
| import org.openmrs.logic.LogicService; | ||
| import org.openmrs.messagesource.MessageSourceService; | ||
| import org.openmrs.messagesource.impl.DefaultMessageSourceServiceImpl; | ||
| import org.openmrs.module.ModuleFactory; |
| boolean isStoppedSuccess = true; | ||
| try { | ||
| dependentModulesStopped = doStopModule(mod, skipOverStartedProperty, isFailedStartup); | ||
| } catch(ModuleMustStartException ex) { |
There was a problem hiding this comment.
What will happen if we get an another type of exception here?
There was a problem hiding this comment.
Pivoted to more general exception now, I thought this module exception could be only reason for failure event
wikumChamith
left a comment
There was a problem hiding this comment.
@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() { |
There was a problem hiding this comment.
shouldn't this be getEventType()?
| private ModuleEventType eventType; | ||
|
|
||
| private String moduleId; | ||
|
|
||
| private String moduleName; | ||
|
|
||
| private String moduleVersion; | ||
|
|
||
| private boolean isSuccess; |
There was a problem hiding this comment.
I think we can make these final.
There was a problem hiding this comment.
What's gonna happen if this parser fails due to a corruption? Don't we need to log that?
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
What if we add a failureReason field? That way audit log can say why something failed.
|
@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(":"); |
There was a problem hiding this comment.
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.
| module = new Module(name, null, null, null, null, version, null); | ||
| } | ||
| publishModuleEvents(ModuleEventType.MODULE_LOAD, module, isModuleLoaded, failureReason); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Fixed, not tweaking the original return module .
|



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