Skip to content

O3-5459: Void and purge a visit's queue entries when the visit is deleted - #96

Open
dkayiwa wants to merge 13 commits into
mainfrom
O3-5459/void-purge-queue-entries-on-visit-delete
Open

dkayiwa wants to merge 13 commits into
mainfrom
O3-5459/void-purge-queue-entries-on-visit-delete

Conversation

@dkayiwa

@dkayiwa dkayiwa commented Mar 3, 2026 •

Copy link
Copy Markdown
Member

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:

  • Void (DELETE /visit/{uuid}, which is what the O3 chart's "Delete visit" calls) left the entries active. This is the symptom O3-5459 reports.
  • Purge (DELETE /visit/{uuid}?purge=true) failed outright on the queue_entry foreign key to visit, so the visit could not be deleted at all.

JIRA: O3-5459

Root cause

Void. The module already had a void cascade, in the VoidHandler half of VisitWithQueueEntriesSaveHandler, but that branch is guarded by visit.getVoided(). On the voidVisit path the flag is only set once core's BaseVoidHandler has run, and nothing pins the two in that order: 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. That order moves with the number of VoidHandler beans a deployment registers, and @Handler(order = ...) cannot pin it either, since lower wins and the default is already Integer.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 the queue-3.0.0 tag ships, the queue handler goes first and the entry is left active with dateVoided null. 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: RequiredDataAdvice dispatches 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 MethodBeforeAdvice commits that half before core has decided whether it will delete the visit at all, and VisitServiceImpl.purgeVisit refuses a visit that still has encounters, after the advice has run. Advice registered through Context.addAdvice gives 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

  • Added VisitWithQueueEntriesVoidHandler, a VoidHandler<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. 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 own VisitVoidHandler with it. Entries that were already voided keep the stamp they had.
  • Retired the void branch of VisitWithQueueEntriesSaveHandler so the cascade has one owner. It now only ends open entries when a visit is stopped, and no longer implements VoidHandler<Visit>.
  • Added VisitWithQueueEntriesDeleteAdvice, a MethodInterceptor on VisitService that 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 proxies Get Queue Entries and Purge Queue Entries for the duration of the cascade, so a caller holding Purge Visits but 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 with addSuppressed so that it still reaches the log.
  • Registered the advice in config.xml via the <advice> element.

Testing

mvn install is green: 59 api, 74 omod, 123 integration tests, both against the 2.7.4 the pom defaults to and against 2.8.7.

VisitWithQueueEntriesVoidHandlerTest covers the void half. The one that pins the fix is shouldVoidQueueEntriesBeforeTheVisitItselfIsFlaggedVoided, which invokes the handler on a visit whose voided flag is still false, exactly what it sees when ordered ahead of BaseVoidHandler. It fails if the getVoided() guard comes back, while the tests that go through voidVisit stay green either way, since the test context lands on the favourable order. Only the direct-handler test can see that failure mode.

shouldVoidQueueEntriesThatHadAlreadyEnded covers an entry already ended by a queue transition, which is the shape a visit deleted after "serve patient" actually has. Adding an isEnded(false) filter to the cascade fails that test and only it.

VisitWithQueueEntriesDeleteAdviceTest covers the purge half, for an active entry, a voided entry, a caller without queue privileges, and the pass-through every other VisitService method 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 from config.xml, so the test registers it through Context.addAdvice, the same call ModuleFactory makes for a deployed module.

VisitWithQueueEntriesDeleteAdviceTransactionTest covers 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 and Context.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.

ModuleAdviceConfigTest walks the config.xml <advice> elements and does what ModuleFactory.loadAdvice and AdvicePoint.getClassInstance do 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 an Advice or an Advisor. Nothing else in the build reads those elements.

🤖 Generated with Claude Code

dkayiwa and others added 3 commits March 3, 2026 15:18
…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>
@RoshanMadival08

Copy link
Copy Markdown

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 denniskigen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed 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:

  1. Consider retiring the void branch in VisitWithQueueEntriesSaveHandler once this merges. It isn't dead code, which is the problem: whether it fires depends on @Handler order ties resolved by HashMap iteration order, so it runs in some deployments (O3-5594 arose through its saveQueueEntry path) and silently no-ops in others (verified on the RefApp). With this advice in place it's redundant on every voidVisit path, 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. Its shouldVoidQueueEntriesIfVisitIsVoided test would go with it.
  2. unvoidVisit is 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.
  3. 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);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Suggested change
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.

Comment on lines +30 to +33
* 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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>
@dkayiwa dkayiwa changed the title O3-5459: Void/purge queue entries when associated visit is voided or purged O3-5459: Purge queue entries when the associated visit is purged Sep 16, 2026
Integer visitId = visit.getVisitId();
assertTrue(queueEntryService.getQueueEntryById(3).isPresent());

visitService.purgeVisit(visit);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Suggested change
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Suggested change
// 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)) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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 left voided=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>
@dkayiwa dkayiwa changed the title O3-5459: Purge queue entries when the associated visit is purged O3-5459: Void and purge a visit's queue entries when the visit is deleted Sep 16, 2026
* 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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Comment on lines +172 to +174
QueueEntry cascaded = queueEntryService.getQueueEntryById(2).get();
assertTrue(cascaded.getVoided());
assertThat(cascaded.getDateVoided().getTime(), equalTo(voidedVisit.getDateVoided().getTime()));

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Suggested change
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());

dkayiwa and others added 6 commits September 16, 2026 17:56
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
@sonarqubecloud

Copy link
Copy Markdown

Comment on lines +42 to +43
* Entries that were already voided keep the stamp they had, so an unvoid can tell them apart from
* entries taken down with the visit.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Suggested change
* 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

{"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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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.

Suggested change
// 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.

@jayasanka-sack

Copy link
Copy Markdown
Member

This is marked as a nice to have fix for the RefApp 3.8.0.

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.

4 participants