Fix duplicate patient creation on MPI import - #60
Conversation
9aa07ca to
025993f
Compare
| boolean hasId = false; | ||
| if (id.getIdentifierType() == null) continue; | ||
| for (PatientIdentifier eid : patientRecord.getIdentifiers()) | ||
| hasId |= eid.getIdentifier().equals(id.getIdentifier()) |
There was a problem hiding this comment.
This is slightly too clever. You actually want:
| hasId |= eid.getIdentifier().equals(id.getIdentifier()) | |
| hasId = hasId || eid.getIdentifier().equals(id.getIdentifier()) |
The |= is a bitwise or and assignment operator similar to +=. The thing is that while the bytecode for hasId |= ... is somewhat more efficient byte-code wise, it will always evaluate both operands whereas hasId = hasId || ... will short-circuit, i.e., once hasId has been set to true it will no longer invoke the eid.getIdentifier().equals(id.getIdentifier()) part.
To demonstrate, a simplified version based on:
boolean first = true;
boolean second = true;
first |= second;Results in something like this:
1 iload_1 // load the first "true"
2 iload_2 // load the second "true"
3 ior // bitwise "or" of 1 and 2
Whereas:
boolean first = true;
boolean second = true;
first = first || second;Is much more verbose, but in a key way:
1 iload_1 // load the first "true"
2 ifne 5 // if the first value != 0 jump to line 5
3 iload_2 // load the second "true"
4 ifeq 7 // if the second value = 0 jump to line 7
5 iconst_1 // the value 1, i.e., false
6 return
7 iconst_0 // the value 0, i.e., true
8 return
Which is more bytecodes but in the real example, every where we have iload_2 in the dummy code, we'd actually have something like:
1 aload_0 // load eid
2 invokevirtual // run getIdentifier()
3 astore_2 // save the result to register 2
4 aload_1 // load id
5 invokevirtual // run getIdentifier(), result on stack
6 aload_2 // load result of first identifier call
7 invokevirtual // call equals()
In the second case we can skip those steps. And if none of that makes any sense, the short version is || short-circuits, i.e., only evaluates the left-side if the left-side is true, but | does not, i.e., it always evaluates both sides.
There was a problem hiding this comment.
Nice, thanks! this was just copied from
For now i will revert this!
025993f to
43a2f66
Compare
Add a ThreadLocal SUPPRESS flag to PatientSynchronizationAdvice that callers can set to skip the AOP advice during patient import, preventing the re-entrant loop where PatientUpdateWorker triggers a second import. Add a deduplication lock (HashSet) to PatientUpdateWorker to prevent concurrent exports of the same patient, matching the existing pattern in PatientSyncWorker. Add proxy privileges to PatientUpdateWorker and PatientSyncWorker to prevent APIAuthenticationException in background threads. Fix pre-existing compile errors: remove duplicate getPatientList() definitions, fix parseFhirPatient() call signature. Update CI: replace deprecated GitHub Actions (checkout v4, setup-java v4 with cache: maven, cache v4, setup-maven v5), drop s4u/maven-settings-action in favor of setup-java built-in server config.
43a2f66 to
217902b
Compare
When importMpiPatient() saves a patient via createImportedMpiPatient(), the mpi-client PatientSynchronizationAdvice AOP fires and spawns a PatientUpdateWorker. This worker re-queries the MPI, discovers golden record seealso links, and triggers a second import — creating a duplicate local patient. Wrap the savePatient() call with PatientSynchronizationAdvice.SUPPRESS to skip the AOP during import. The export back to MPI is already handled explicitly by exportPatient() at the end of importMpiPatient(), so the AOP-triggered export is redundant anyway. Depends on IsantePlus/openmrs-module-mpi-client#60 which adds the SUPPRESS ThreadLocal flag.
When importMpiPatient() saves a patient via createImportedMpiPatient(), the mpi-client PatientSynchronizationAdvice AOP fires and spawns a PatientUpdateWorker. This worker re-queries the MPI, discovers golden record seealso links, and triggers a second import — creating a duplicate local patient. Wrap the savePatient() call with PatientSynchronizationAdvice.SUPPRESS to skip the AOP during import. The export back to MPI is already handled explicitly by exportPatient() at the end of importMpiPatient(), so the AOP-triggered export is redundant anyway. Depends on IsantePlus/openmrs-module-mpi-client#60 which adds the SUPPRESS ThreadLocal flag.
…e patient import - santedb-mpiclient-1.1.5-SNAPSHOT.omod: adds SUPPRESS ThreadLocal flag to PatientSynchronizationAdvice, dedup lock on PatientUpdateWorker, and proxy privileges for background threads (IsantePlus/openmrs-module-mpi-client#60) - registrationcore-2.2.0.omod: sets SUPPRESS=true around savePatient() in createImportedMpiPatient() to prevent AOP from spawning PatientUpdateWorker during MPI import (IsantePlus/openmrs-module-registrationcore#49) - Dockerfile: copies patched registrationcore to distribution path to override the base image's unpatched version
…e patient import - santedb-mpiclient-1.1.5-SNAPSHOT.omod: adds SUPPRESS ThreadLocal flag to PatientSynchronizationAdvice, dedup lock on PatientUpdateWorker, and proxy privileges for background threads (IsantePlus/openmrs-module-mpi-client#60) - registrationcore-2.2.0.omod: sets SUPPRESS=true around savePatient() in createImportedMpiPatient() to prevent AOP from spawning PatientUpdateWorker during MPI import (IsantePlus/openmrs-module-registrationcore#49) - Dockerfile: copies patched registrationcore to distribution path to override the base image's unpatched version
- mpi-client: updated to 1.1.5-SNAPSHOT from fix/duplicate-patient-on-mpi-import branch (IsantePlus/openmrs-module-mpi-client#60) — adds SUPPRESS ThreadLocal, dedup lock, proxy privileges, CI fixes - registrationcore: updated from fix/suppress-mpi-sync-on-import branch (IsantePlus/openmrs-module-registrationcore#49) — sets SUPPRESS=true around savePatient() during MPI import, bumps mpiClientVersion to 1.1.5-SNAPSHOT - Fixed dead repo references and github-packages profile in registrationcore POM - Aligned xdsSenderVersion to 2.5.9 in registrationcore
- Add 'Adding a New Module' guide: importing from external repo, creating from scratch, vendoring dependencies - Add 'Contributing' section with workflow - Add 'Downloading OMODs from CI' section - Expand build instructions with install command - Update mpi-client to 1.1.5-SNAPSHOT with SUPPRESS flag, dedup lock, and proxy privileges (IsantePlus/openmrs-module-mpi-client#60) - Update registrationcore with SUPPRESS during MPI import (IsantePlus/openmrs-module-registrationcore#49) - Fix dead repo references in registrationcore POM - Align registrationcore xdsSenderVersion to 2.5.9
- Vendor everest-core 1.1.0 JAR and POM into lib/maven-repo/ - Add .mvn/settings.xml to mirror dead repos (te.marc-hi.ca, santesuite.org) to Maven Central - Copy vendored JARs to ~/.m2 and clear cached failures before build - Remove dependency on github-packages profile (no auth needed) - Upload OMOD as build artifact
The everest-core POM declares te.marc-hi.ca and santesuite.org as repositories. Maven reads it and tries to resolve transitive deps from those dead repos. Shipping only the JAR avoids this — Maven treats it as a local artifact without transitive resolution.
Added !lib/maven-repo/**/*.jar exception to .gitignore so the vendored JAR is tracked by git and available in CI.
4ca6319 to
59f14d1
Compare
| steps: | ||
| # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it | ||
| - uses: actions/checkout@v2 | ||
| - uses: actions/checkout@v4 |
There was a problem hiding this comment.
IIRC, checkout is currently on v6 or something like that...
| mkdir -p ~/.m2/repository | ||
| # Clear any cached everest resolution state | ||
| rm -rf ~/.m2/repository/org/marc/everest | ||
| cp -r lib/maven-repo/* ~/.m2/repository/ | ||
| find ~/.m2/repository -name "*.lastUpdated" -delete 2>/dev/null || true | ||
| echo "Installed vendored JARs:" | ||
| find ~/.m2/repository/org/marc/everest -name "*.jar" |
There was a problem hiding this comment.
Maybe use mvn install:install-file here to install everest-core? This will ensure that the Maven metadata is also updated.
| * When set to true, the advice will skip spawning PatientUpdateWorker, | ||
| * preventing the re-entrant import loop that creates duplicate patients. | ||
| */ | ||
| public static final ThreadLocal<Boolean> SUPPRESS = ThreadLocal.withInitial(() -> false); |
There was a problem hiding this comment.
| Context.removeProxyPrivilege("Get Identifier Types"); | ||
| Context.removeProxyPrivilege("Get Patients"); | ||
| Context.removeProxyPrivilege("Get Patient Identifiers"); | ||
| Context.removeProxyPrivilege("Edit Patient Identifiers"); | ||
| Context.removeProxyPrivilege("Add Patient Identifiers"); |
There was a problem hiding this comment.
I'd wrap these in try...catch blocks to ignore any exceptions...
| synchronized (s_lock) { | ||
| s_lock.remove(patientUuid); | ||
| } |
There was a problem hiding this comment.
I might make this lock removal the first thing we do in the finally block since it's the least likely to fail.
| <settings> | ||
| <mirrors> | ||
| <mirror> | ||
| <id>marc-te-mirror</id> |
There was a problem hiding this comment.
Instead of this, we can use the Maven repo https://oscarmcmaster.sourceforge.net/m2/ to find Everest-Core. However, it's weird to do this in settings instead of just... adding a repo in the POM
Everest-core was vendored as a JAR because its original Maven repos (marc-te, santesuite) are dead. Ian pointed out it is available at https://oscarmcmaster.sourceforge.net/m2/ so we add that repo directly in the POM, remove the vendored JAR, and drop .mvn/settings.xml which was only there to mirror the dead repos. Also wraps proxy privilege setup and teardown in try-catch blocks so a failure in one does not prevent the others from running, and moves lock removal to the top of the finally block since it is the least likely to fail.
Summary
When importing a patient from the MPI (OpenCR) into iSantePlus, two local patient records are created instead of one. This happens because:
importMpiPatient()→savePatient()triggersPatientSynchronizationAdviceAOPPatientUpdateWorkerwhich re-queries the MPIseealsolinks and triggers a secondimportMpiPatient()callChanges
PatientSynchronizationAdvice: AddThreadLocal<Boolean> SUPPRESSflag — when set, the AOP advice skips spawningPatientUpdateWorker, breaking the re-entrant import loopFhirMpiClientServiceImpl.importPatient(): Implement the method (was returningnull), with duplicate detection viamatchWithExistingPatient()and AOP suppression duringsavePatient()HL7MpiClientServiceImpl.importPatient(): Add the same AOP suppression to prevent duplicates on the HL7 code pathPatientUpdateWorker: Add dedup lock (HashSet) to prevent concurrent exports of the same patient (matching existingPatientSyncWorkerpattern)PatientUpdateWorker+PatientSyncWorker: Add proxy privileges to preventAPIAuthenticationExceptionin background threadsMpiClientService/MpiClientWorker/MpiClientServiceImpl: Remove duplicategetPatientList()method definitions and fixparseFhirPatient()call signatureTest plan
PatientUpdateWorkerstill runs normally for non-importsavePatient()calls (e.g. editing a patient in the UI)/CR/fhir/Patientduring import