Skip to content

Commit d731b02

Browse files
committed
Test: Mongo delete outbox 핵심 시나리오(중복/timeout/no-op/배치한도) 보강
1 parent 3ae1fa6 commit d731b02

3 files changed

Lines changed: 209 additions & 0 deletions

File tree

src/main/java/io/ejangs/docsa/global/mongo/deletion/entity/MongoDeleteOutboxFactory.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.ejangs.docsa.global.mongo.deletion.entity;
22

3+
import jakarta.persistence.EntityManager;
34
import io.ejangs.docsa.global.mongo.deletion.dao.mysql.MongoDeleteOutboxRepository;
45
import io.ejangs.docsa.global.mongo.deletion.dto.MongoIdsDto;
56
import io.ejangs.docsa.global.mongo.deletion.entity.MongoDeleteOutbox.DomainType;
@@ -17,6 +18,7 @@
1718
public class MongoDeleteOutboxFactory {
1819

1920
private final MongoDeleteOutboxRepository mongoDeleteOutboxRepository;
21+
private final EntityManager entityManager;
2022

2123
public MongoDeleteOutbox create(
2224
TriggerType triggerType,
@@ -81,6 +83,11 @@ public MongoDeleteOutbox create(
8183
try {
8284
return mongoDeleteOutboxRepository.save(newOutbox);
8385
} catch (DataIntegrityViolationException e) {
86+
// 유니크 키 충돌 시, 실패한 엔티티를 영속성 컨텍스트에서 분리합니다.
87+
// 같은 트랜잭션의 후속 flush에서 발생할 수 있는 부작용을 방지하기 위함
88+
if (entityManager.contains(newOutbox)) {
89+
entityManager.detach(newOutbox);
90+
}
8491
return mongoDeleteOutboxRepository
8592
.findByTriggerTypeAndDomainTypeAndOriginTypeAndOriginId(
8693
triggerType,
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package io.ejangs.docsa.global.mongo.deletion.entity;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.mockito.ArgumentMatchers.any;
5+
import static org.mockito.Mockito.times;
6+
import static org.mockito.Mockito.verify;
7+
import static org.mockito.Mockito.when;
8+
9+
import io.ejangs.docsa.global.mongo.deletion.dao.mysql.MongoDeleteOutboxRepository;
10+
import io.ejangs.docsa.global.mongo.deletion.dto.MongoIdsDto;
11+
import io.ejangs.docsa.global.mongo.deletion.entity.MongoDeleteOutbox.DomainType;
12+
import io.ejangs.docsa.global.mongo.deletion.entity.MongoDeleteOutbox.OriginType;
13+
import io.ejangs.docsa.global.mongo.deletion.entity.MongoDeleteOutbox.TriggerType;
14+
import jakarta.persistence.EntityManager;
15+
import java.util.List;
16+
import org.junit.jupiter.api.DisplayName;
17+
import org.junit.jupiter.api.Test;
18+
import org.junit.jupiter.api.extension.ExtendWith;
19+
import org.mockito.InjectMocks;
20+
import org.mockito.Mock;
21+
import org.mockito.junit.jupiter.MockitoExtension;
22+
import org.springframework.dao.DataIntegrityViolationException;
23+
import org.springframework.test.util.ReflectionTestUtils;
24+
25+
@ExtendWith(MockitoExtension.class)
26+
class MongoDeleteOutboxFactoryTest {
27+
28+
@Mock
29+
private MongoDeleteOutboxRepository mongoDeleteOutboxRepository;
30+
31+
@Mock
32+
private EntityManager entityManager;
33+
34+
@InjectMocks
35+
private MongoDeleteOutboxFactory mongoDeleteOutboxFactory;
36+
37+
@Test
38+
@DisplayName("유니크 충돌 시 재조회하여 기존 outbox를 반환한다")
39+
void createReturnsExistingOutboxWhenUniqueConflictOccurs() {
40+
TriggerType triggerType = TriggerType.COMPENSATE;
41+
DomainType domainType = DomainType.DOC;
42+
OriginType originType = OriginType.DOC_ID;
43+
String originId = "race-origin-1";
44+
MongoIdsDto ids = new MongoIdsDto(List.of("save-1"), List.of(), List.of());
45+
46+
MongoDeleteOutbox existing = MongoDeleteOutbox.open(
47+
triggerType,
48+
domainType,
49+
originType,
50+
originId,
51+
ids.saveContentsIds(),
52+
ids.commitBlockSequenceIds(),
53+
ids.blockIds()
54+
);
55+
ReflectionTestUtils.setField(existing, "id", 99L);
56+
57+
when(mongoDeleteOutboxRepository.findByTriggerTypeAndDomainTypeAndOriginTypeAndOriginId(
58+
triggerType,
59+
domainType,
60+
originType,
61+
originId
62+
)).thenReturn(java.util.Optional.empty(), java.util.Optional.of(existing));
63+
when(entityManager.contains(any(MongoDeleteOutbox.class))).thenReturn(true);
64+
when(mongoDeleteOutboxRepository.save(any(MongoDeleteOutbox.class)))
65+
.thenThrow(new DataIntegrityViolationException("duplicate key"));
66+
67+
MongoDeleteOutbox result = mongoDeleteOutboxFactory.create(
68+
triggerType,
69+
domainType,
70+
originType,
71+
originId,
72+
ids
73+
);
74+
75+
assertThat(result.getId()).isEqualTo(99L);
76+
verify(mongoDeleteOutboxRepository, times(2))
77+
.findByTriggerTypeAndDomainTypeAndOriginTypeAndOriginId(
78+
triggerType,
79+
domainType,
80+
originType,
81+
originId
82+
);
83+
verify(mongoDeleteOutboxRepository).save(any(MongoDeleteOutbox.class));
84+
verify(entityManager).detach(any(MongoDeleteOutbox.class));
85+
}
86+
}

src/test/java/io/ejangs/docsa/mongoDeleteSystem/MongoDeleteOutboxTest.java

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import static org.mockito.ArgumentMatchers.any;
55
import static org.mockito.Mockito.doThrow;
66

7+
import io.ejangs.docsa.global.mongo.deletion.app.MongoDeleteOutboxCompleteService;
78
import io.ejangs.docsa.global.mongo.deletion.app.MongoDeleteOutboxWorker;
89
import io.ejangs.docsa.global.mongo.deletion.app.MongoDeleteService;
910
import io.ejangs.docsa.global.mongo.deletion.dao.mysql.MongoDeleteOutboxRepository;
@@ -14,14 +15,18 @@
1415
import io.ejangs.docsa.global.mongo.deletion.entity.MongoDeleteOutbox.OutboxStatus;
1516
import io.ejangs.docsa.global.mongo.deletion.entity.MongoDeleteOutbox.TriggerType;
1617
import io.ejangs.docsa.global.mongo.deletion.entity.MongoDeleteOutboxFactory;
18+
import java.time.LocalDateTime;
1719
import java.util.List;
1820
import java.util.UUID;
1921
import org.junit.jupiter.api.DisplayName;
22+
import org.junit.jupiter.api.BeforeEach;
2023
import org.junit.jupiter.api.Test;
2124
import org.springframework.beans.factory.annotation.Autowired;
2225
import org.springframework.boot.test.context.SpringBootTest;
26+
import org.springframework.jdbc.core.JdbcTemplate;
2327
import org.springframework.test.context.ActiveProfiles;
2428
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
29+
import org.springframework.test.util.ReflectionTestUtils;
2530

2631
@SpringBootTest
2732
@ActiveProfiles("test")
@@ -36,9 +41,20 @@ class MongoDeleteOutboxTest {
3641
@Autowired
3742
private MongoDeleteOutboxWorker mongoDeleteOutboxWorker;
3843

44+
@Autowired
45+
private MongoDeleteOutboxCompleteService mongoDeleteOutboxCompleteService;
46+
47+
@Autowired
48+
private JdbcTemplate jdbcTemplate;
49+
3950
@MockitoSpyBean
4051
private MongoDeleteService mongoDeleteService;
4152

53+
@BeforeEach
54+
void cleanOutbox() {
55+
mongoDeleteOutboxRepository.deleteAll();
56+
}
57+
4258
@Test
4359
@DisplayName("Outbox 워커가 OPEN 건을 처리하면 DONE으로 완료된다")
4460
void workerDone() {
@@ -84,6 +100,106 @@ void workerFailedAfterMaxRetry() {
84100
assertThat(failed.getLastError()).contains("always fail");
85101
}
86102

103+
@Test
104+
@DisplayName("동일 키 create 중복 호출 시 outbox는 1건만 유지된다")
105+
void factoryDedupeWithSameKey() {
106+
String originId = "dup-" + UUID.randomUUID();
107+
MongoDeleteOutbox first = mongoDeleteOutboxFactory.create(
108+
TriggerType.COMPENSATE,
109+
DomainType.DOC,
110+
OriginType.DOC_ID,
111+
originId,
112+
new MongoIdsDto(List.of("save-" + originId), List.of(), List.of())
113+
);
114+
MongoDeleteOutbox second = mongoDeleteOutboxFactory.create(
115+
TriggerType.COMPENSATE,
116+
DomainType.DOC,
117+
OriginType.DOC_ID,
118+
originId,
119+
new MongoIdsDto(List.of("save-" + originId), List.of(), List.of())
120+
);
121+
122+
assertThat(first.getId()).isEqualTo(second.getId());
123+
assertThat(mongoDeleteOutboxRepository.findAll()).hasSize(1);
124+
}
125+
126+
@Test
127+
@DisplayName("PROCESSING timeout 건은 run() 시작 시 복구되어 재처리된다")
128+
void workerRecoversTimedOutProcessingBeforeDelete() {
129+
MongoDeleteOutbox outbox = createOpenOutbox();
130+
mongoDeleteOutboxCompleteService.claimOpen(outbox.getId());
131+
132+
MongoDeleteOutbox processing = mongoDeleteOutboxRepository.findById(outbox.getId()).orElseThrow();
133+
ReflectionTestUtils.setField(processing, "updatedAt", LocalDateTime.now().minusMinutes(10));
134+
mongoDeleteOutboxRepository.saveAndFlush(processing);
135+
jdbcTemplate.update(
136+
"update mongo_delete_outbox set updated_at = ? where id = ?",
137+
LocalDateTime.now().minusMinutes(10),
138+
outbox.getId()
139+
);
140+
141+
mongoDeleteOutboxWorker.run();
142+
143+
MongoDeleteOutbox done = mongoDeleteOutboxRepository.findById(outbox.getId()).orElseThrow();
144+
assertThat(done.getStatus()).isEqualTo(OutboxStatus.DONE);
145+
assertThat(done.getRetryCount()).isEqualTo(1);
146+
}
147+
148+
@Test
149+
@DisplayName("상태 불일치 호출(claim/done/retry)은 no-op으로 안전하게 무시된다")
150+
void completeServiceNoOpOnStatusMismatch() {
151+
MongoDeleteOutbox outbox = createOpenOutbox();
152+
153+
mongoDeleteOutboxCompleteService.done(outbox.getId());
154+
mongoDeleteOutboxCompleteService.retry(outbox.getId(), "ignored");
155+
156+
MongoDeleteOutbox open = mongoDeleteOutboxRepository.findById(outbox.getId()).orElseThrow();
157+
assertThat(open.getStatus()).isEqualTo(OutboxStatus.OPEN);
158+
assertThat(open.getRetryCount()).isEqualTo(0);
159+
assertThat(open.getLastError()).isNull();
160+
161+
assertThat(mongoDeleteOutboxCompleteService.claimOpen(outbox.getId())).isNotNull();
162+
mongoDeleteOutboxCompleteService.done(outbox.getId());
163+
164+
assertThat(mongoDeleteOutboxCompleteService.claimOpen(outbox.getId())).isNull();
165+
mongoDeleteOutboxCompleteService.retry(outbox.getId(), "ignored2");
166+
167+
MongoDeleteOutbox done = mongoDeleteOutboxRepository.findById(outbox.getId()).orElseThrow();
168+
assertThat(done.getStatus()).isEqualTo(OutboxStatus.DONE);
169+
assertThat(done.getRetryCount()).isEqualTo(0);
170+
}
171+
172+
@Test
173+
@DisplayName("워커는 1회 실행 시 OPEN 최대 100건만 처리한다")
174+
void workerProcessesAtMost100OpenRowsPerRun() {
175+
for (int i = 0; i < 101; i++) {
176+
String originId = "batch-" + i + "-" + UUID.randomUUID();
177+
mongoDeleteOutboxFactory.create(
178+
TriggerType.COMPENSATE,
179+
DomainType.DOC,
180+
OriginType.DOC_ID,
181+
originId,
182+
new MongoIdsDto(List.of("save-" + originId), List.of(), List.of())
183+
);
184+
}
185+
186+
mongoDeleteOutboxWorker.run();
187+
188+
List<MongoDeleteOutbox> firstRun = mongoDeleteOutboxRepository.findAll();
189+
long doneCountAfterFirst = firstRun.stream().filter(o -> o.getStatus() == OutboxStatus.DONE).count();
190+
long openCountAfterFirst = firstRun.stream().filter(o -> o.getStatus() == OutboxStatus.OPEN).count();
191+
assertThat(doneCountAfterFirst).isEqualTo(100);
192+
assertThat(openCountAfterFirst).isEqualTo(1);
193+
194+
mongoDeleteOutboxWorker.run();
195+
196+
List<MongoDeleteOutbox> secondRun = mongoDeleteOutboxRepository.findAll();
197+
long doneCountAfterSecond = secondRun.stream().filter(o -> o.getStatus() == OutboxStatus.DONE).count();
198+
long openCountAfterSecond = secondRun.stream().filter(o -> o.getStatus() == OutboxStatus.OPEN).count();
199+
assertThat(doneCountAfterSecond).isEqualTo(101);
200+
assertThat(openCountAfterSecond).isEqualTo(0);
201+
}
202+
87203
private MongoDeleteOutbox createOpenOutbox() {
88204
String originId = UUID.randomUUID().toString();
89205
return mongoDeleteOutboxFactory.create(

0 commit comments

Comments
 (0)