Repository navigation
Conversation
…purged Add VisitWithQueueEntriesDeleteAdvice AOP advisor that intercepts VisitService.voidVisit() and VisitService.purgeVisit() to ensure associated queue entries are voided or purged accordingly. The existing VoidHandler in VisitWithQueueEntriesSaveHandler does not reliably fire when visitService.voidVisit() is called, so the AOP advice provides a reliable mechanism for both void and purge paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Purge test now actually calls visitService.purgeVisit() to verify the FK constraint issue is resolved end-to-end - Void test now isolates the advice behavior, proving the advice (not the VoidHandler) is what voids the queue entries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
great!! One question: is there a risk of partial failure if purging one queue entry fails midway? A transactional guarantee would be worth confirming. |
denniskigen
left a comment
There was a problem hiding this comment.
Reviewed and verified this end to end, including a live deployment. Verdict: works as described, and the advice approach is the right mechanism.
Live verification (RefApp stack, core 2.8.7, module built from this branch): DELETE /visit/{uuid}?reason=... voided the associated queue entry with the reason propagated (DB-verified), where queue 3.0.0 leaves it active, which is the O3-5459 bug. DELETE /visit/{uuid}?purge=true purged the queue entry and visit cleanly, where 3.0.0 fails on the queue_entry_visit_id_fk constraint. This also confirms the config.xml advice registration fires in a deployed module, which the integration tests can't exercise (as your test comments note).
On the root cause, to sharpen "does not reliably fire": the O3-4416 void branch in VisitWithQueueEntriesSaveHandler is guarded by visit.getVoided(), which is only true if core's BaseVoidHandler has already run. Both handlers carry the default @Handler order, and core returns tied handlers in HashMap iteration order via ServiceContext.getRegisteredComponents, so on the current RefApp the queue handler runs first and silently no-ops. Its test passes because it calls saveVisit() on a pre-voided visit, a path the real delete flow never takes. Your advice reads the method arguments directly, so it has no ordering dependency, and it is the only mechanism that can cover purgeVisit at all.
On @RoshanMadival08's question: it's better than transactional. ServiceContext.addAdvice appends this advice to the tail of the service proxy chain, inside the TransactionInterceptor, so the queue-entry writes and the visit void commit or roll back together. Notably, the mechanism this replaces had the weaker guarantee: RequiredDataAdvice is registered as a pre-interceptor (outside the transaction interceptor), so the old handler's saveQueueEntry calls committed independently of the visit operation.
Three non-blocking suggestions:
- Consider retiring the void branch in
VisitWithQueueEntriesSaveHandleronce this merges. It isn't dead code, which is the problem: whether it fires depends on@Handlerorder ties resolved byHashMapiteration order, so it runs in some deployments (O3-5594 arose through itssaveQueueEntrypath) and silently no-ops in others (verified on the RefApp). With this advice in place it's redundant on everyvoidVisitpath, and I could find no production caller that saves a pre-voided visit, so removing it would leave the behavior with a single deterministic owner. ItsshouldVoidQueueEntriesIfVisitIsVoidedtest would go with it. unvoidVisitis still unhandled, so restore-after-delete leaves entries voided. Seems fine as a follow-up ticket; the natural semantics would unvoid only entries whose void metadata matches the visit's.- The branch merges clean onto current main (checked, zero conflicts); worth a rebase before merge, and a multi-entry-per-visit test would round out coverage.
Small observation in favor of the implementation: using voidQueueEntry rather than saveQueueEntry keeps QueueEntryValidator out of the cascade path entirely, which sidesteps the class of multi-entry issues O3-5594 addressed.
For cross-reference: openmrs/openmrs-esm-patient-chart#3427 is a frontend workaround for the same ticket; once this merges and ships in a release, that PR can be closed.
…lete main's O3-5955 (#124) made VisitWithQueueEntriesSaveHandler void a visit's queue entries on the voidVisit path too, and its unvoid handler relies on those entries carrying the visit's own void date and user. The advice added here voided them itself with voidQueueEntry, which stamps a different date and user, so keeping both would have broken that pairing. The advice is now purge-only, which is the part core cannot cover: RequiredDataAdvice runs handlers for save/void/unvoid/retire/unretire prefixes only, never purge. Resolutions: - VisitWithQueueEntriesSaveHandlerTest: took main's tests, which cover the voidVisit path through the real service instead of this branch's reflection-based call into the advice. - VisitWithQueueEntriesDeleteAdvice: dropped the voidVisit branch, took the proxy-privilege pattern the queue handlers use, and included voided entries in the purge search. - VisitWithQueueEntriesDeleteAdviceTest: registers the advice with Context.addAdvice and purges through visitService, so the test exercises the interceptor chain rather than calling before() directly; added a case for a visit whose entry is already voided. Both purge tests fail with the advice unregistered and pass with it. Full build green: 119 integration tests, 0 failures. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // Purging a visit is driven by a core service whose callers need not hold queue privileges, | ||
| // so grant them for the duration of this cascade, as the queue handlers do | ||
| Context.addProxyPrivilege(PrivilegeConstants.GET_QUEUE_ENTRIES); | ||
| Context.addProxyPrivilege(PrivilegeConstants.MANAGE_QUEUE_ENTRIES); |
There was a problem hiding this comment.
This grants the wrong privilege, and it needs fixing before merge. purgeQueueEntry is annotated @Authorized(PrivilegeConstants.PURGE_QUEUE_ENTRIES) (QueueEntryService.java:132), so a caller holding Purge Visits but not Purge Queue Entries still can't get through the cascade, which is exactly the case the comment above says this block is here to cover.
I ran it against this branch. With Context.becomeUser("3-4") and only Purge Visits and Get Encounters proxied, visitService.purgeVisit(visit) throws APIAuthenticationException: Privileges required: Purge Queue Entries out of the before-advice, so the visit doesn't get purged either. Swapping the constant here and in the finally on line 70 makes that same call succeed, which also shows MANAGE_QUEUE_ENTRIES is doing nothing on this path; the advice only calls getQueueEntries and purgeQueueEntry.
| Context.addProxyPrivilege(PrivilegeConstants.MANAGE_QUEUE_ENTRIES); | |
| Context.addProxyPrivilege(PrivilegeConstants.PURGE_QUEUE_ENTRIES); |
Worth a test with it. VisitWithQueueEntriesSaveHandlerTest.shouldVoidQueueEntriesForUserWithoutQueuePrivileges is the pattern to copy. Both tests in the new class run as the superuser, which is why this got past them.
| * which is not consulted on a purge, so advice on the service is the only hook. Voiding needs no | ||
| * advice - {@link VisitWithQueueEntriesSaveHandler} already voids the entries on both the | ||
| * {@code saveVisit} and {@code voidVisit} paths, and stamps them with the visit's own void date and | ||
| * user so an unvoid can tell them apart from entries voided on their own. |
There was a problem hiding this comment.
Not a blocker, but the PR description hasn't caught up with this class. It still says the advice intercepts voidVisit() and "voids all associated queue entries with the same void reason", and its Root Cause section says the save handler "does not reliably fire when visitService.voidVisit() is called. The handler only works through the saveVisit() path", which is the opposite of what this paragraph says. That text is what lands in the merge commit, so could you bring it in line with what the code now does: purge only, with the void path owned by VisitWithQueueEntriesSaveHandler on main?
One qualification on the paragraph itself while you're here. "Already voids the entries on both the saveVisit and voidVisit paths" holds only for a particular handler order. BaseVoidHandler and VisitWithQueueEntriesSaveHandler both carry the default @Handler order of Integer.MAX_VALUE, and HandlerUtil.getHandlersForType breaks that tie with the iteration order of the new HashMap<>(...) in ServiceContext.getRegisteredComponents, so it moves with the set of registered VoidHandler beans. Printing the order in the module tests gives BaseVoidHandler first, which is why it holds there; calling the save handler directly on a visit that isn't voided yet leaves its entries untouched. "Voids them when it runs after core's BaseVoidHandler" would say it without promising more than the code guarantees.
| import org.openmrs.api.VisitService; | ||
| import org.openmrs.api.context.Context; | ||
| import org.openmrs.module.queue.SpringTestConfiguration; | ||
| import org.openmrs.module.queue.model.QueueEntry; |
There was a problem hiding this comment.
Left over from the merge, QueueEntry isn't referenced anywhere in the class any more, so this import can go.
The advice granted Get/Manage Queue Entries, but QueueEntryService.purgeQueueEntry is annotated @Authorized(PURGE_QUEUE_ENTRIES), so a caller holding Purge Visits but no queue privileges hit APIAuthenticationException out of the before-advice and the visit was not purged. Grant Purge Queue Entries instead; Manage was never used on this path, which only calls getQueueEntries and purgeQueueEntry. Cover it with a test modelled on VisitWithQueueEntriesSaveHandlerTest#shouldVoidQueueEntriesForUserWithoutQueuePrivileges; both existing advice tests ran as the superuser, which is why this got through. Also qualify the class javadoc: VisitWithQueueEntriesSaveHandler only voids entries on the voidVisit path when it runs after core's BaseVoidHandler, and nothing pins that order, since both handlers carry the default @handler order and the tie falls to the iteration order of the map ServiceContext.getRegisteredComponents builds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| Integer visitId = visit.getVisitId(); | ||
| assertTrue(queueEntryService.getQueueEntryById(3).isPresent()); | ||
|
|
||
| visitService.purgeVisit(visit); |
There was a problem hiding this comment.
A Context.flushSession() after each purge would make these cover the foreign key failure the PR is fixing. As they stand, all three assert through getQueueEntryById and getVisit, both of which read the Hibernate session, so nothing forces a flush and the DELETE FROM visit that queue_entry_visit_id_fk rejects never reaches H2.
I checked what that leaves out by removing the Context.addAdvice call and running the same purge on this branch. visitService.purgeVisit(visit) returns without complaint, and the constraint only speaks up on an explicit flush: Referential integrity constraint violation: "PUBLIC.QUEUE_ENTRY FOREIGN KEY(VISIT_ID) REFERENCES PUBLIC.VISIT(VISIT_ID) (102)". So the tests currently pin "the advice removed the entries" but not "the visit row can now be deleted", which is the failure described at the top of the PR.
I added the flush to all three tests and they stay green.
| visitService.purgeVisit(visit); | |
| visitService.purgeVisit(visit); | |
| Context.flushSession(); |
| QueueEntryService queueEntryService = Context.getService(QueueEntryService.class); | ||
| QueueEntrySearchCriteria criteria = new QueueEntrySearchCriteria(); | ||
| criteria.setVisit(visit); | ||
| // the visit or its patient may already be voided, which would hide the entries from the default search |
There was a problem hiding this comment.
A voided visit doesn't hide its entries, so the first half of this is off. QueueEntryDaoImpl.createCriteriaFromSearchCriteria filters on qe.voided and, through the patient alias, on p.voided; the visit shows up only as Restrictions.eq("qe.visit", visit), with no join to the visit table, so its own voided flag plays no part. I confirmed it: voiding visit 102, then unvoiding its queue entries again, still leaves the entry in the default search, whereas voiding the patient drops it.
The reason that does apply here is the one in the PR description, that voided entries hold the same foreign key, which is what shouldPurgeVoidedQueueEntriesOfAPurgedVisit covers. PatientWithQueueEntriesVoidHandler words the patient half accurately if you want a model.
| // the visit or its patient may already be voided, which would hide the entries from the default search | |
| // voided entries hold the same foreign key, and a voided patient hides them from the default search |
The three tests asserted through getQueueEntryById and getVisit, both of which read the Hibernate session, so nothing forced a flush and the DELETE FROM visit that queue_entry_visit_id_fk rejects never reached the database. They pinned that the advice removed the entries, not that the visit row can now be deleted, which is the failure this PR fixes. With the advice unregistered they now fail on queue_entry_visit_id_fk instead of on the entry-present assertion. Also correct the comment above setIncludedVoided(true). A voided visit does not hide its entries: the default search filters on qe.voided and, through the patient alias, on p.voided, while the visit appears only as an equality on qe.visit with no join to the visit table. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
||
| @Override | ||
| public void before(Method method, Object[] args, Object target) { | ||
| if (!"purgeVisit".equals(method.getName()) || args.length == 0 || !(args[0] instanceof Visit)) { |
There was a problem hiding this comment.
Narrowing the advice to purgeVisit leaves the half of O3-5459 that the ticket actually reports resting on handler order, and I think that has to be settled before this merges.
The chart's "delete visit" is a void, not a purge: VisitResource1_9.delete calls voidVisit(visit, reason) and only ?purge=true reaches purgeVisit. So the reported symptom sits entirely on the void path, which this PR now hands to VisitWithQueueEntriesSaveHandler.
That handler does work today, but only by luck. Its void branch is guarded by visit.getVoided(), and VisitServiceImpl.voidVisit is just dao.saveVisit(visit), so the only thing that runs is its VoidHandler side. Whether the guard is satisfied depends on BaseVoidHandler having gone first, the two are tied at the default @Handler order, and HandlerUtil sorts stably, so the tie falls to the iteration order of the HashMap that ServiceContext.getRegisteredComponents builds over bean names. baseVoidHandler and visitWithQueueEntriesSaveHandler land in buckets 4 and 1 when that table is 8 wide, 4 and 9 at 16, 20 and 25 at 32, then 52 and 25 at 64. Core wins only while the deployment sits between 7 and 24 VoidHandler beans.
I drove both ends of that through the real visitService.voidVisit in the integration-test context:
- Six beans, which is core's five plus the module's one, exactly what queue 3.0.0 ships. Order comes out
[VisitWithQueueEntriesSaveHandler, VisitVoidHandler, BaseVoidHandler]and the entry is leftvoided=false, dateVoided=null. That is O3-5459 reproducing. - Seven beans, which is main today. Order is
[BaseVoidHandler, VisitWithQueueEntriesSaveHandler, VisitVoidHandler]and the entry is voided.
The only thing that moved us from the first to the second is PatientWithQueueEntriesVoidHandler, added last week for O3-5955. Nothing in the module pins it, and @Handler(order=...) can't either, since lower wins and the default is already Integer.MAX_VALUE, so an explicit order only moves us earlier. No test covers the losing side: shouldVoidQueueEntriesWhenVisitIsVoidedThroughVoidVisit runs at seven beans and structurally cannot see it.
If merged as-is, O3-5459 is closed on behaviour that reverts the moment a distro drops that unrelated handler or grows past 24 VoidHandler beans, and nothing fails to tell us. It is also the void+purge version that was verified live on a RefApp in the approving review above (against 36b2040, which still had the branch), and on the strength of which the contributor closed esm-patient-chart#3427 "in favor of that backend fix".
I'd put the voidVisit branch back as it was at 36b2040: it takes the reason from args[1] rather than off the entity, so handler order stops mattering. If you'd rather the void half stay a handler, give it its own VoidHandler<Visit> shaped like PatientWithQueueEntriesVoidHandler, or like core's VisitVoidHandler sitting next to it in the same list; both take the user, date and reason from the handler arguments and carry no getVoided() guard. Either way the handler's current void branch should then go, so the cascade has one owner and the void stamp is deterministic.
The symptom this ticket reports arrives on the voidVisit path: the chart's "delete visit" calls VisitResource1_9.delete, which voids the visit. That half was left to VisitWithQueueEntriesSaveHandler, whose void branch is guarded by visit.getVoided() and so only fires when core's BaseVoidHandler happens to run first. Nothing pins that. Both carry the default @handler order, HandlerUtil sorts stably, and the tie falls to the iteration order of the HashMap that ServiceContext.getRegisteredComponents builds over bean names, which moves with the number of VoidHandler beans a deployment registers. Driving the real voidVisit with core's five handlers plus the module's one, the shape queue 3.0.0 ships, the queue handler goes first and the entry is left active. Move the cascade to VisitWithQueueEntriesVoidHandler, which takes the void user, date and reason from the handler arguments instead of off the visit, so the order stops mattering. RequiredDataAdvice hands every handler the same values it gives BaseVoidHandler, so the entries still carry the visit's own void stamp. This is the shape PatientWithQueueEntriesVoidHandler already uses, and core's VisitVoidHandler with it. Retire the save handler's void branch so the cascade has one owner; that handler now only ends open entries when a visit is stopped. Purging stays with VisitWithQueueEntriesDeleteAdvice, which core cannot reach from a handler at all. shouldVoidQueueEntriesBeforeTheVisitItselfIsFlaggedVoided pins the property the old code lacked: it fails if the getVoided() guard comes back, while the tests that go through voidVisit stay green either way, since the module's own test context lands on the favourable order. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| * which is not consulted on a purge, so advice on the service is the only hook. Voiding is owned by | ||
| * {@link VisitWithQueueEntriesVoidHandler}, which core does reach on the {@code voidVisit} path. | ||
| */ | ||
| public class VisitWithQueueEntriesDeleteAdvice implements MethodBeforeAdvice { |
There was a problem hiding this comment.
This needs fixing before merge. Purging the entries from a before-advice commits that half before core has decided whether it will delete the visit at all, and VisitServiceImpl.purgeVisit has a refusal path that is easy to hit: if the visit still has encounters (getEncountersByVisit(visit, true), so voided ones count) it throws APIException("Visit.purge.inUse") at VisitServiceImpl.java:221-222, before dao.deleteVisit is ever reached.
If merged as-is, DELETE /visit/{uuid}?purge=true on a visit that has any encounter deletes that visit's queue entries and then fails with "Cannot permanently delete a visit that has encounters associated to it". The visit survives, the queue rows are gone, and the caller is handed an error that says nothing was deleted. On main nothing is lost in that case, because core throws before the foreign key is reached and nothing has touched the entries, so this is new.
The transaction does not save it, which is the part I did not expect. Context.addAdvice appends to the outer visitService proxy, but that proxy's target is already a transaction proxy: applicationContext-service.xml registers a DefaultAdvisorAutoProxyCreator (line 46) and a TransactionAttributeSourceAdvisor (line 52), so visitServiceTarget gets wrapped, and the outer TransactionProxyFactoryBean (line 515) resolves no transaction attribute for purgeVisit because its target class is a JDK interface proxy. In the module's integration-test context, which loads that same file, printing the chain behind Context.getVisitService() gives [AuthorizationAdvice, RequiredDataAdvice, LoggingAdvice, CacheInterceptor, TransactionInterceptor, VisitWithQueueEntriesDeleteAdvice] sitting over a second proxy whose only advisor is the TransactionInterceptor, and inside before() during a real purgeVisit, TransactionSynchronizationManager.isActualTransactionActive() is false.
I reproduced the loss with a control alongside it. In a test method marked @Transactional(propagation = NOT_SUPPORTED), so there is no ambient test transaction, purging a visit that has one encounter throws that APIException, the queue entry is gone from the database, and the visit row is still there. In the same test a purgeQueueEntry inside a TransactionTemplate that then throws leaves its entry intact, so rollback does work in that setup; these deletes are simply not in the transaction being rolled back.
Making it an around interceptor fixes it. I changed the class to implement MethodInterceptor and ran the entry purge plus invocation.proceed() inside a TransactionTemplate built from Context.getRegisteredComponent("transactionManager", PlatformTransactionManager.class). After the same refusal the entry is still present, and the three tests in VisitWithQueueEntriesDeleteAdviceTest stay green. MethodInterceptor is an Advice, so config.xml and ModuleFactory.loadAdvice need no change. Worth a test with it: a visit with an encounter, the purge refused, the entries still there.
This is also the answer to the partial-failure question in the conversation above, which I think was pointing at exactly this.
|
|
||
| <advice> | ||
| <point>org.openmrs.api.VisitService</point> | ||
| <class>org.openmrs.module.queue.api.VisitWithQueueEntriesDeleteAdvice</class> |
There was a problem hiding this comment.
Nothing in the build would notice if either of these two strings were wrong. I deleted this whole <advice> block and ran mvn install: green, with 59 api, 73 omod and 120 integration tests. VisitWithQueueEntriesDeleteAdviceTest registers the advice itself through Context.addAdvice, which is the right way to exercise the interceptor chain, but it does leave config.xml as the one piece of this fix that no test reads.
The failure would be quiet too. With a wrong class name, AdvicePoint.getClassInstance catches Exception | LinkageError and logs a warning, ModuleFactory.loadAdvice then logs at debug and carries on, and the module starts normally with the purge cascade simply absent. A small test in omod that parses the packaged config.xml and asserts that each <advice>'s point and class both load, and that the class is an Advice, would close that. The filtered file lands in omod/target/classes and queue-api is already an omod dependency, so it can be a plain unit test. Both values are right today, so this is only about keeping it that way.
| QueueEntry cascaded = queueEntryService.getQueueEntryById(2).get(); | ||
| assertTrue(cascaded.getVoided()); | ||
| assertThat(cascaded.getDateVoided().getTime(), equalTo(voidedVisit.getDateVoided().getTime())); |
There was a problem hiding this comment.
Entry 1 on this visit is the case that isn't asserted, and it's the one the reported workflow produces. It's already in the dataset, ended at 2022-02-02 18:40:56 and not voided, and the cascade does void it, because QueueEntrySearchCriteria.isEnded defaults to null so the search doesn't filter on it.
Nothing pins that, though. I added criteria.setIsEnded(false) to the handler, which is exactly what VisitWithQueueEntriesSaveHandler does two files over, and all 120 integration tests still passed while entry 1 was left active on a voided visit. O3-5461's repro is add to queue, call, serve, then delete the visit, and serving transitions the entry, so a deleted visit in that flow has one ended entry and one open one. VisitWithQueueEntriesValidator writes criteria.setIsEnded(null) explicitly for the same reason. The cascade is right today, this is just to hold it there.
| QueueEntry cascaded = queueEntryService.getQueueEntryById(2).get(); | |
| assertTrue(cascaded.getVoided()); | |
| assertThat(cascaded.getDateVoided().getTime(), equalTo(voidedVisit.getDateVoided().getTime())); | |
| QueueEntry cascaded = queueEntryService.getQueueEntryById(2).get(); | |
| assertTrue(cascaded.getVoided()); | |
| assertThat(cascaded.getDateVoided().getTime(), equalTo(voidedVisit.getDateVoided().getTime())); | |
| // entry 1 was already ended before the void, which must not keep it off the cascade | |
| QueueEntry endedBeforeTheVoid = queueEntryService.getQueueEntryById(1).get(); | |
| assertNotNull(endedBeforeTheVoid.getEndedAt()); | |
| assertTrue(endedBeforeTheVoid.getVoided()); |
VisitWithQueueEntriesDeleteAdvice purged a visit's queue entries from a MethodBeforeAdvice, which commits that half before core has decided whether it will delete the visit at all. VisitServiceImpl.purgeVisit refuses a visit that still has encounters, and it does so after the advice has run, so the entries went and the visit stayed. Nothing rolled them back either. Advice registered through Context.addAdvice is appended to the proxy ServiceContext holds, and core's applicationContext-service.xml already auto-proxies every @transactional service implementation, so that outer proxy sits one layer outside the one which begins the transaction. Make it a MethodInterceptor and open the transaction here, so the cascade and core's delete stand or fall together. VisitWithQueueEntriesDeleteAdviceTransactionTest covers it, in its own class because it has to run outside the usual test transaction: inside one the purged entries read as deleted either way, so a transactional test passes against the bug. Also close two gaps the same review found: - ModuleAdviceConfigTest walks the config.xml <advice> elements and does what ModuleFactory.loadAdvice does with them. Nothing else in the build reads them, and a bad point or class fails quietly: the module starts with the cascade simply absent. - shouldVoidQueueEntriesThatHadAlreadyEnded pins that the void cascade covers entries already ended by a queue transition, which is the shape a visit deleted after "serve patient" actually has. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012KvveaJ2rSEqEgxHrUPZU4
Phase 1 of a harden pass over the previous commit. - VisitWithQueueEntriesDeleteAdviceTest covers the advice's pass-through. It is an around interceptor now, so every VisitService call in a server with the module installed goes through it, and a no-arg method covers the empty argument array. Dropping the returned value reddens it. - The transaction test pins why the purge was refused. Without that it passed on any APIException, including one raised before the cascade ran at all: becoming a user without Purge Visits satisfied every other assertion in it. - ModuleAdviceConfigTest reaches for the advice class's no-arg constructor before casting to Advice, which is the order AdvicePoint uses and also the only order in which the constructor check is reachable. Pointed at a class with an @Autowired constructor it now reddens; behind the Advice cast it never could. It looks the constructor up rather than calling it, so an advice that touches Context on construction is not failed for that here. - Say what the proxy stack does rather than that core auto-proxies every @transactional service: applicationContext-service.xml declares a DefaultAdvisorAutoProxyCreator beside a TransactionAttributeSourceAdvisor, which is checkable. - Record why the transaction manager is looked up per call. ModuleUtil and DispatcherServlet re-run ModuleFactory.loadAdvice after a Spring refresh and AdvicePoint hands them the same cached advice instance, so a field would outlive the context its bean came from. - "config.xml <advice> is not read by module tests" is no longer true now that ModuleAdviceConfigTest reads it; say that the harness does not register advice from it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012KvveaJ2rSEqEgxHrUPZU4
Phase 2 of the harden pass. Two lenses independently found the first of these; the rest came one each. - ModuleAdviceConfigTest rejected a legal config.xml. ModuleFactory.loadAdvice registers the instance with Context.addAdvisor if it is an Advisor and Context.addAdvice otherwise, and org.springframework.aop.Advisor holds an Advice rather than being one, so the Advice-only assertion failed a DefaultPointcutAdvisor that core would have loaded. Both arms checked: a throwaway Advisor now passes, and a class that is neither, with a no-arg constructor, still fails. - A throwing rollback took the refusal reason with it. The caller saw only TransactionSystemException; the APIException saying the visit still has encounters was gone, not as a cause and not in any log. Attach it as a suppressed exception, which is what Spring's own TransactionAspectSupport does for this case. Forcing the rollback to throw now prints it. - The transaction test's javadoc had the mechanism backwards. It said a transactional test sees the entries as deleted, which would fail the assertion rather than pass it. Measured on the un-transacted advice inside a transaction: present=false before Context.clearSession() and present=true after, so the clear discards the unflushed deletes and the reading cannot tell a rollback from one. - deleteAllData() in that test's teardown is not what protects the next class; core's @afterclass wipes after every class already. It is there for a future second method in this class, whose executeDataSet would otherwise meet this one's committed rows. - The advice javadoc now names the inert advisor. visitService really is a TransactionProxyFactoryBean, so a maintainer who greps for that and concludes the advice is already inside a transaction would simplify the explicit one away. Its transaction advisor does not match purgeVisit, because its target is the interface proxy the auto-proxy creator wrapped around visitServiceTarget. - The pass-through comment claimed the interface-wide interception was new. It is not: config.xml is byte-identical at the base sha and addAdvice has always registered against the whole point. What is new is that an around interceptor has to proceed and return the result itself. Also add a message to the assertion that fires on every mutation of this cascade, since it reported a bare AssertionError. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012KvveaJ2rSEqEgxHrUPZU4
Phase 2 pass 2. Two lenses found the first of these independently, and
one of them built the slice against core 2.8.7 to show it.
The advice javadoc explained why the platform's own transaction does not
cover advice added with Context.addAdvice, naming three beans and their
line numbers in core's applicationContext-service.xml. That was exact for
2.7.4 and inverted for 2.8.x: core 2.8.0 removed DefaultAdvisorAutoProxyCreator
and TransactionAttributeSourceAdvisor outright, and there the outer
visitService proxy is transactional for purgeVisit, so a maintainer on 2.8
would have found no such beans, read that the transaction was unnecessary,
and deleted it. The regression test would have agreed with them: with the
transaction stripped it fails on 2.7.4 and passes on 2.8.7.
This is the third try at that paragraph, so rather than write a fourth,
the platform archaeology is gone. What is left is the invariant that holds
on both: addAdvice gives no guarantee a transaction is open, default
propagation joins one if there is, and the test is what decides whether
the entries survive a refused delete. The same applies to the teardown
comment, whose stated reason was refuted twice - dbunit REFRESH tolerates
the committed dataset rows, so that was never the reason - and which now
says only that the test commits and wipes after itself.
Also from that pass:
- ModuleAdviceConfigTest reads omod/src/main/resources/config.xml instead
of scanning the classpath for it. The scan parsed every config.xml
there, and a required module ships one with a DOCTYPE; a malformed or
entity-bearing document of somebody else's decided whether this test
passed, and external general entities were still resolved, which means
a network call for a document we did not write. Controls both ways: a
bad <class> still reddens, and a missing file fails loudly rather than
finding nothing.
- Its javadoc no longer says a bad <advice> always fails quietly. That is
true of a class that will not load or instantiate, but loadAdvice casts
to Advice without testing and ModuleUtil.refreshApplicationContext has
no catch, so a wrong type aborts the post-refresh loop for every
started module.
- TransactionDefinition.withDefaults() instead of allocating a mutable
DefaultTransactionDefinition per purge, which also states in code what
the javadoc otherwise has to assert in prose.
- The addSuppressed comment said the caller sees the refusal reason. It
reaches the log; error responses built from getCause() do not show it.
- Neither advice test now names ModuleAdviceConfigTest: integration-tests
does not depend on queue-omod, so that could never have been a {@link}
and renaming the class would have left both comments pointing nowhere.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KvveaJ2rSEqEgxHrUPZU4
…onstraint
Phase 2 pass 3. Three of four lenses found nothing; the fourth and a
second one found the same thing, in a javadoc the previous pass had just
introduced.
That javadoc said a <class> which will not load leaves loadAdvice logging
a warning, and one which will not instantiate leaves getClassInstance
returning null and loadAdvice logging at debug. Both halves are wrong and
the distinction does not exist: AdvicePoint.getClassInstance wraps the
class load and the newInstance in one catch, logs its own warning keyed on
the <point> rather than the class name, and returns null, so both <class>
failures take the same path. loadAdvice's warning is reachable only from
Context.loadClass(advice.getPoint()) - a bad <point>, which that sentence
never mentioned. It also contradicted the file's own inline comment
twenty lines below, which had it right all along.
That is the second refuted attempt at describing what core logs when part
of an <advice> is wrong, so the attribution is gone rather than corrected
again. What stays is the part three lenses and a reading of core agree on:
loadAdvice casts to Advice without testing and
ModuleUtil.refreshApplicationContext has no catch, so a wrong type
propagates out of the post-refresh loop. Narrowed from "every started
module" to the ones behind this one, since those already iterated have
their advice.
Also: reading config.xml from the source tree requires the <advice>
elements to stay free of the ${...} tokens the rest of the file uses,
because Maven resolves those in the packaged copy and this test does not.
The <activator> twelve lines above uses exactly that style, so say it is
a constraint instead of assuming nobody follows the neighbour.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KvveaJ2rSEqEgxHrUPZU4
Cycle 2. Three lenses found nothing; one found that ModuleAdviceConfigTest gave <point> a single check - that the class loads - while giving <class> three, and demonstrated what that lets past. With <point>org.openmrs.api.impl.VisitServiceImpl</point> and a correct <class>, the test exits 0. Driving the real registration path, Context.addAdvice(VisitServiceImpl.class, advice) throws NullPointerException from ServiceContext.addAdvice, which does services.get(cls) with no null check on a map keyed by the registered service interface. That escapes loadAdvice's ClassNotFoundException | NoClassDefFoundError catch and the catch-less post-refresh loop in ModuleUtil, so naming the implementation has the same blast radius as a wrong advice type: the started modules behind this one never get their advice loaded. Interface-for-implementation is the typo this invites, and a deployed server is where it would surface. So the point must be an interface. That is necessary and not sufficient - only a running Context could say whether it is a registered service - and the comment says so rather than implying the check is complete. Also check the advice class is not abstract. AdvicePoint calls newInstance(), so an abstract class fails there and quietly leaves the cascade absent, but it passes both a getConstructor() lookup and the Advisor test: AbstractPointcutAdvisor is abstract, is an Advisor, and has a public no-arg constructor. Testing the modifier keeps the constructor looked up rather than called, so an advice that legitimately touches Context on construction is still not failed for it. Both controls redden on exactly those two classes, and the slice is green against core 2.8.7 as well as the 2.7.4 the pom defaults to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012KvveaJ2rSEqEgxHrUPZU4
|
| * Entries that were already voided keep the stamp they had, so an unvoid can tell them apart from | ||
| * entries taken down with the visit. |
There was a problem hiding this comment.
Nothing performs that discrimination today, so this claims more than the module does. PatientWithQueueEntriesUnvoidHandler.shouldRestore is the only unvoid that looks at queue entries, and it rejects any entry whose visit is voided (PatientWithQueueEntriesUnvoidHandler.java:95) before it gets as far as comparing stamps, so for entries taken down with the visit the stamp plays no part. The stamp comparison below that check is answering a different question, telling the patient cascade's own entries apart from independently voided ones.
The reason is that there is no UnvoidHandler<Visit> anywhere in the module, which makes this cascade the odd one out among its siblings: core pairs VisitVoidHandler with VisitUnvoidHandler, and the module pairs PatientWithQueueEntriesVoidHandler with an unvoid handler of its own.
I'm not asking for that handler here, since O3-5459 already scopes it as a follow-up to be filed separately ("handle unvoidVisit so restoring a deleted visit also restores its queue entries"). It is reachable, for what it's worth: VisitResource1_9.undelete calls unvoidVisit, and POST /visit/{uuid} with {"voided": false} gets there. I searched O3 and couldn't find a ticket for it yet. What I would change is just the sentence, so that the next person reading shouldRestore's own comment (which now points at this class) isn't left to work the gap out for themselves.
| * Entries that were already voided keep the stamp they had, so an unvoid can tell them apart from | |
| * entries taken down with the visit. | |
| * Entries that were already voided keep the stamp they had, so their own void is not overwritten. | |
| * Nothing restores any of them: the module has no {@code UnvoidHandler<Visit>}, so unvoiding a | |
| * visit leaves its queue entries voided. |
There was a problem hiding this comment.
{"voided": false} doesn't reach unvoidVisit, so the aside above names the wrong request. The Javadoc suggestion doesn't depend on it, so nothing changes in the PR.
MainResourceController.update only routes a POST to undelete when the body is exactly {"deleted": "false"}, and every other body goes through DelegatingCrudResource.update to saveVisit. {"voided": false} is the body the chart's "Restore visit" sends (restoreVisit in visit.resource.tsx). I ran both through webservices.rest 3.0.0's own controller test harness with an advice on VisitService recording each call: {"voided": false} reached saveVisit and never unvoidVisit, and {"deleted": "false"} reached unvoidVisit.
Where it does matter is the unvoid follow-up. When that gets filed, could it note that an UnvoidHandler<Visit> on its own won't fire when the chart restores a visit?
| // Context.addAdvice registers against the whole point, so every VisitService call in a server | ||
| // with the module installed reaches this advice and not just the purges. What is new is that an | ||
| // around interceptor has to proceed and hand back the result itself, where a before-advice did | ||
| // not. A no-arg method also covers the empty argument array it tolerates on the way past. |
There was a problem hiding this comment.
The last sentence doesn't hold. getAllVisitTypes() never reaches the args.length == 0 clause, because || short-circuits on the method-name check first, and nothing else on VisitService reaches it either, since purgeVisit(Visit) is the only purgeVisit there. That clause and the instanceof Visit one beside it are unreachable belt and braces, worth keeping but not worth claiming coverage of.
The pass-through half of the comment is real: I changed the non-purge branch to invocation.proceed(); return null; and this test failed on getAllVisitTypes() coming back null.
| // not. A no-arg method also covers the empty argument array it tolerates on the way past. | |
| // not. A no-arg method is enough to prove that much. |
|
This is marked as a nice to have fix for the RefApp 3.8.0. |



Summary
Deleting a visit leaves its queue entries behind, so the patient stays in the service queue. Both halves of "delete" are affected, and both are fixed here:
DELETE /visit/{uuid}, which is what the O3 chart's "Delete visit" calls) left the entries active. This is the symptom O3-5459 reports.DELETE /visit/{uuid}?purge=true) failed outright on thequeue_entryforeign key tovisit, so the visit could not be deleted at all.JIRA: O3-5459
Root cause
Void. The module already had a void cascade, in the
VoidHandlerhalf ofVisitWithQueueEntriesSaveHandler, but that branch is guarded byvisit.getVoided(). On thevoidVisitpath the flag is only set once core'sBaseVoidHandlerhas run, and nothing pins the two in that order: both carry the default@Handlerorder,HandlerUtilsorts stably, and the tie falls to the iteration order of theHashMapthatServiceContext.getRegisteredComponentsbuilds over bean names. That order moves with the number ofVoidHandlerbeans a deployment registers, and@Handler(order = ...)cannot pin it either, since lower wins and the default is alreadyInteger.MAX_VALUE.Driving the real
visitService.voidVisit, the outcome flips with the bean count. At six beans, which is core's five plus the single one thequeue-3.0.0tag ships, the queue handler goes first and the entry is left active withdateVoidednull. At seven, core goes first and the entry is voided. The module's own test context has seven, which is why the existing tests never saw the failure.Purge. Core's purge cascade knows nothing about queue entries, and no handler can cover it:
RequiredDataAdvicedispatches only on method names beginning save, create, void, unvoid, retire or unretire, so a purge reaches none of them. That leaves advice on the service.Purge, second half. Clearing the foreign key from a
MethodBeforeAdvicecommits that half before core has decided whether it will delete the visit at all, andVisitServiceImpl.purgeVisitrefuses a visit that still has encounters, after the advice has run. Advice registered throughContext.addAdvicegives no guarantee that a transaction is open when it runs, and on 2.7.x none is: the entry deletes committed, the refusal could not roll them back, so the queue rows went, the visit stayed, and the caller got an error saying nothing had been deleted. On 2.8.x the platform does provide a transaction, which is why this is version-dependent and why a test inside the usual test transaction cannot see it either way.Changes
VisitWithQueueEntriesVoidHandler, aVoidHandler<Visit>that voids a visit's queue entries. It reads the void user, date and reason from the handler arguments rather than off the visit, so handler order stops mattering.RequiredDataAdvicehands every handler the same values it givesBaseVoidHandler, so the entries still carry the visit's own void stamp. This is the shapePatientWithQueueEntriesVoidHandleralready uses, and core's ownVisitVoidHandlerwith it. Entries that were already voided keep the stamp they had.VisitWithQueueEntriesSaveHandlerso the cascade has one owner. It now only ends open entries when a visit is stopped, and no longer implementsVoidHandler<Visit>.VisitWithQueueEntriesDeleteAdvice, aMethodInterceptoronVisitServicethat purges a visit's queue entries and then lets core delete the visit, both inside one transaction it takes out itself, so the entries go only if the visit goes. Propagation is the default, so it joins a transaction where the platform provides one and opens its own where it does not. Voided entries are included, since they hold the same foreign key. The advice proxiesGet Queue EntriesandPurge Queue Entriesfor the duration of the cascade, so a caller holdingPurge Visitsbut no queue privileges can still delete the visit. If the rollback itself fails, the exception saying why the purge was refused is attached to it withaddSuppressedso that it still reaches the log.config.xmlvia the<advice>element.Testing
mvn installis green: 59 api, 74 omod, 123 integration tests, both against the 2.7.4 the pom defaults to and against 2.8.7.VisitWithQueueEntriesVoidHandlerTestcovers the void half. The one that pins the fix isshouldVoidQueueEntriesBeforeTheVisitItselfIsFlaggedVoided, which invokes the handler on a visit whose voided flag is still false, exactly what it sees when ordered ahead ofBaseVoidHandler. It fails if thegetVoided()guard comes back, while the tests that go throughvoidVisitstay green either way, since the test context lands on the favourable order. Only the direct-handler test can see that failure mode.shouldVoidQueueEntriesThatHadAlreadyEndedcovers an entry already ended by a queue transition, which is the shape a visit deleted after "serve patient" actually has. Adding anisEnded(false)filter to the cascade fails that test and only it.VisitWithQueueEntriesDeleteAdviceTestcovers the purge half, for an active entry, a voided entry, a caller without queue privileges, and the pass-through every otherVisitServicemethod now takes through the interceptor. The three purge tests fail on the foreign key without the advice. The module test harness does not register advice fromconfig.xml, so the test registers it throughContext.addAdvice, the same callModuleFactorymakes for a deployed module.VisitWithQueueEntriesDeleteAdviceTransactionTestcovers the atomicity, in its own class because it has to run outside that transaction. Inside one, the cascade's deletes are still unflushed when the delete is refused andContext.clearSession()discards them, so the entry reads as present whether or not anything rolled back. It gives a visit an encounter, expects core's refusal, and asserts the visit and the entry both survive. Stripping the transaction fails it on 2.7.4; on 2.8.x it is green either way, which is worth knowing before anyone simplifies the transaction away.ModuleAdviceConfigTestwalks theconfig.xml<advice>elements and does whatModuleFactory.loadAdviceandAdvicePoint.getClassInstancedo with them: the point loads and is a service interface, and the class loads, is not abstract, has a public no-arg constructor, and is anAdviceor anAdvisor. Nothing else in the build reads those elements.🤖 Generated with Claude Code