Skip to content

Commit 375abbe

Browse files
committed
TRUNK-6516: Follow-up: fix tests
1 parent b8a9017 commit 375abbe

6 files changed

Lines changed: 153 additions & 4 deletions

File tree

api/src/main/java/org/openmrs/event/outbox/OutboxEvent.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,25 @@ public void setCompletedListeners(String completedListeners) {
139139
this.completedListeners = completedListeners;
140140
}
141141

142+
/**
143+
* Copies the persisted (column) state of the given event onto this instance. The identity and the
144+
* {@code creator}/{@code changedBy} associations are intentionally left untouched: the id never
145+
* changes for the same row, and copying lazy proxies across sessions risks
146+
* {@link org.hibernate.LazyInitializationException}. Add any newly persisted scalar column here.
147+
*
148+
* @param other the event whose state should be copied onto this instance
149+
*/
150+
public void updateFrom(OutboxEvent other) {
151+
this.eventType = other.eventType;
152+
this.payload = other.payload;
153+
this.dateCreated = other.dateCreated;
154+
this.dateChanged = other.dateChanged;
155+
this.status = other.status;
156+
this.errorCount = other.errorCount;
157+
this.errorMessage = other.errorMessage;
158+
this.completedListeners = other.completedListeners;
159+
}
160+
142161
// --- Auditable Interface Implementation ---
143162

144163
@Override

api/src/main/java/org/openmrs/event/outbox/OutboxEventService.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,15 @@ public boolean lockEventForProcessing(OutboxEvent outboxEvent) {
128128
claimQuery.setParameter("now", new Date());
129129

130130
int updatedCount = claimQuery.executeUpdate();
131-
return updatedCount == 1;
131+
if (updatedCount != 1) {
132+
return false;
133+
}
134+
135+
// The instance itself isn't updated, so we update it here, but first we need to re-read from the database
136+
OutboxEvent claimed = sessionFactory.getCurrentSession().get(OutboxEvent.class, outboxEvent.getId());
137+
sessionFactory.getCurrentSession().refresh(claimed);
138+
outboxEvent.updateFrom(claimed);
139+
return true;
132140
}
133141

134142
@Transactional

api/src/main/resources/hibernate.cfg.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
<!-- These mappings are required because of references in Obs & Concept -->
4949
<mapping class="org.openmrs.ObsReferenceRange"/>
5050
<mapping class="org.openmrs.ConceptReferenceRange"/>
51+
<mapping class="org.openmrs.event.outbox.OutboxEvent"/>
5152
</session-factory>
5253

5354
</hibernate-configuration>

api/src/test/java/org/openmrs/event/outbox/OutboxEventIT.java

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
import java.util.ArrayList;
1313
import java.util.List;
1414
import java.util.UUID;
15+
import java.util.stream.Collectors;
1516

1617
import org.hibernate.SessionFactory;
18+
import org.junit.jupiter.api.AfterEach;
1719
import org.junit.jupiter.api.BeforeEach;
1820
import org.junit.jupiter.api.Test;
1921
import org.openmrs.GlobalProperty;
@@ -62,6 +64,11 @@ public void setUp() throws Exception {
6264
outboxTaskSchedulerInitializer.schedule();
6365
}
6466

67+
@AfterEach
68+
public void tearDown() {
69+
outboxTaskSchedulerInitializer.deleteScheduledTasks();
70+
}
71+
6572
@Test
6673
public void interceptAndSaveToOutbox_shouldSaveEventWhenListenerExists() throws InterruptedException {
6774
// Arrange: A patient event is created, and the registry has a listener.
@@ -104,7 +111,7 @@ public void interceptAndSaveToOutbox_shouldSaveEventWhenListenerExists() throws
104111
capturedEvents.remove(afterCommitEvent);
105112

106113
//Assert the ordering of captured outbox events is correct
107-
assertThat(capturedEvents, contains(
114+
assertThat(describeState(capturedEvents), capturedEvents, contains(
108115
allOf(hasProperty("method", is("onPatientCreated")),
109116
hasProperty("event", hasProperty("uuid", equalTo(event.getUuid())))),
110117
hasProperty("method", is("onPatientCreatedFailingFailed")),
@@ -171,8 +178,8 @@ public void interceptAndSaveToOutbox_shouldSaveEventWhenNoTransaction() {
171178
assertThat(adminService.getGlobalProperty("eventPublished", "false"), equalTo("true"));
172179

173180
// Assert: The outbox table should have just one event
174-
List<OutboxEvent> outboxEvents = (List<OutboxEvent>) sessionFactory.getCurrentSession()
175-
.createQuery("from OutboxEvent").list();
181+
List<OutboxEvent> outboxEvents = sessionFactory.getCurrentSession()
182+
.createQuery("from OutboxEvent", OutboxEvent.class).list();
176183

177184
assertThat(outboxEvents,
178185
contains(
@@ -249,6 +256,24 @@ public Object getNonSerializableObject() {
249256
}
250257
}
251258

259+
/**
260+
* Builds a snapshot of the captured events (in order) and the current outbox rows, attached as the
261+
* reason on the ordering assertion so a CI failure shows the actual asynchronous, timing-dependent
262+
* sequence.
263+
*/
264+
@SuppressWarnings("unchecked")
265+
private String describeState(List<TestOutboxEventListener.TestEvent> capturedEvents) {
266+
String captured = capturedEvents.stream().map(TestOutboxEventListener.TestEvent::getMethod)
267+
.collect(Collectors.joining(", "));
268+
Context.clearSession();
269+
String outbox = ((List<OutboxEvent>) sessionFactory.getCurrentSession().createQuery("from OutboxEvent order by id")
270+
.list()).stream()
271+
.map(e -> e.getEventType() + "[status=" + e.getStatus() + ", errorCount=" + e.getErrorCount()
272+
+ ", completedListeners=" + e.getCompletedListeners() + "]")
273+
.collect(Collectors.joining("; "));
274+
return "\nCaptured (in order): [" + captured + "]\nOutbox rows: [" + outbox + "]\n";
275+
}
276+
252277
private void waitForCapturedEvents(int count) throws InterruptedException {
253278
long start = System.currentTimeMillis();
254279
int capturedCount = 0;
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* This Source Code Form is subject to the terms of the Mozilla Public License,
3+
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
4+
* obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
5+
* the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
6+
*
7+
* Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
8+
* graphic logo is a trademark of OpenMRS Inc.
9+
*/
10+
package org.openmrs.event.outbox;
11+
12+
import java.util.ArrayList;
13+
import java.util.Collections;
14+
import java.util.List;
15+
import java.util.UUID;
16+
17+
import org.hibernate.SessionFactory;
18+
import org.junit.jupiter.api.Test;
19+
import org.openmrs.api.context.Context;
20+
import org.openmrs.test.jupiter.BaseContextSensitiveNonTransactionalTest;
21+
import org.springframework.beans.factory.annotation.Autowired;
22+
23+
import static org.junit.jupiter.api.Assertions.assertEquals;
24+
import static org.junit.jupiter.api.Assertions.assertNull;
25+
import static org.junit.jupiter.api.Assertions.assertTrue;
26+
27+
/**
28+
* Guards the transactional outbox against a stale-read race: a poller reads a pending event
29+
* (populating the Hibernate first-level cache) before claiming it, while another poller partially
30+
* processes and releases the same event in between. The claiming poller must resume from the
31+
* committed progress rather than its stale cached read, otherwise it re-runs listeners that were
32+
* already recorded as completed.
33+
*/
34+
public class OutboxEventServiceConcurrencyIT extends BaseContextSensitiveNonTransactionalTest {
35+
36+
@Autowired
37+
private OutboxEventService service;
38+
39+
@Autowired
40+
private SessionFactory sessionFactory;
41+
42+
@Test
43+
public void lockEventForProcessing_shouldObserveProgressCommittedByAnotherHandlerAfterThisHandlersRead()
44+
throws Exception {
45+
// Given a PENDING event with no recorded progress
46+
OutboxEvent seed = new OutboxEvent();
47+
seed.setUuid(UUID.randomUUID().toString());
48+
seed.setEventType("TEST_EVENT");
49+
seed.setPayload("{\"test\":\"data\"}");
50+
seed.setStatus(OutboxEvent.Status.PENDING);
51+
service.saveOutboxEvent(seed);
52+
final Integer id = seed.getId();
53+
Context.clearSession();
54+
55+
// And this handler has already read it before claiming it (exactly as the poller does via
56+
// getProcessingAndPendingEvents()), which puts the null-progress state into the first-level cache
57+
OutboxEvent handle = sessionFactory.getCurrentSession().get(OutboxEvent.class, id);
58+
assertNull(handle.getCompletedListeners());
59+
60+
// When another handler commits progress on the same row from a separate session
61+
final String progress = "testOutboxEventListener.onSomething";
62+
runInSeparateSession(() -> {
63+
OutboxEvent inOtherSession = sessionFactory.getCurrentSession().get(OutboxEvent.class, id);
64+
inOtherSession.setCompletedListeners(progress);
65+
service.saveOutboxEvent(inOtherSession);
66+
});
67+
68+
// Then claiming the event must reflect the committed progress, not this handler's stale cached read
69+
boolean claimed = service.lockEventForProcessing(handle);
70+
assertTrue(claimed, "the PENDING event should be claimable");
71+
assertEquals(progress, handle.getCompletedListeners(),
72+
"lockEventForProcessing must refresh the committed progress, not return the stale cached read");
73+
}
74+
75+
private void runInSeparateSession(Runnable action) throws InterruptedException {
76+
List<Throwable> errors = Collections.synchronizedList(new ArrayList<>());
77+
Thread thread = new Thread(() -> {
78+
Context.openSession();
79+
try {
80+
Context.authenticate(getCredentials());
81+
action.run();
82+
} catch (Throwable t) {
83+
errors.add(t);
84+
} finally {
85+
Context.closeSession();
86+
}
87+
});
88+
thread.start();
89+
thread.join();
90+
if (!errors.isEmpty()) {
91+
throw new RuntimeException("action in separate session failed", errors.get(0));
92+
}
93+
}
94+
}

api/src/test/java/org/openmrs/event/outbox/OutboxEventServiceTest.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ public void lockEventForProcessing_shouldClaimPendingEventAndReturnTrue() {
150150

151151
boolean locked = service.lockEventForProcessing(event);
152152
assertTrue(locked, "Should successfully lock the PENDING event");
153+
assertEquals(OutboxEvent.Status.PROCESSING, event.getStatus(),
154+
"The passed instance should reflect the claimed status");
153155

154156
OutboxEvent updated = sessionFactory.getCurrentSession().find(OutboxEvent.class, event.getId());
155157
assertEquals(OutboxEvent.Status.PROCESSING, updated.getStatus());

0 commit comments

Comments
 (0)