Skip to content

Commit 86749ed

Browse files
authored
Merge pull request #173 from prgrms-be-devcourse/refactor/150-matching-expire-처리-및-redis-전환-테스트-보강
[refactor] matching expire 조회를 Redis deadline index로 전환
2 parents 5802586 + 32cff63 commit 86749ed

6 files changed

Lines changed: 225 additions & 39 deletions

File tree

src/main/java/com/back/domain/matching/queue/adapter/QueueProblemPicker.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public Long pick(QueueKey queueKey, List<Long> participantIds) {
2424
if (queueKey == null) {
2525
throw new IllegalArgumentException("queueKey는 필수입니다.");
2626
}
27+
2728
return problemPickService.pickProblemId(
2829
queueKey.category(), toDifficultyLevel(queueKey.difficulty()), participantIds);
2930
}

src/main/java/com/back/domain/matching/queue/store/redis/MatchingRedisKeys.java

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,27 +54,13 @@ public static String match(Long matchId) {
5454
return MATCH_PREFIX + ":" + requireId(matchId, "matchId");
5555
}
5656

57-
/**
58-
* ready-check session key 를 훑을 때 사용하는 임시 패턴이다.
59-
*/
60-
public static String matchPattern() {
61-
return MATCH_PREFIX + ":*";
62-
}
63-
6457
/**
6558
* user:match 인덱스 전체를 훑을 때 사용하는 패턴이다.
6659
*/
6760
public static String userMatchPattern() {
6861
return USER_MATCH_PREFIX + ":*";
6962
}
7063

71-
/**
72-
* match session key prefix 를 외부 helper 에서 재사용할 수 있게 노출한다.
73-
*/
74-
public static String matchPrefix() {
75-
return MATCH_PREFIX + ":";
76-
}
77-
7864
/**
7965
* deadline index 는 단일 sorted set key 로 유지한다.
8066
*/

src/main/java/com/back/domain/matching/queue/store/redis/RedisMatchStateStore.java

Lines changed: 70 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.back.domain.matching.queue.store.redis;
22

33
import java.time.LocalDateTime;
4+
import java.time.ZoneId;
45
import java.util.ArrayList;
56
import java.util.LinkedHashMap;
67
import java.util.List;
@@ -105,12 +106,13 @@ public class RedisMatchStateStore implements MatchStateStore {
105106
-- MATCHING:MATCH_MARK_ACCEPT_PENDING
106107
local participantCount = tonumber(ARGV[3])
107108
redis.call('SET', KEYS[1], ARGV[1])
109+
redis.call('ZADD', KEYS[2], ARGV[4], ARGV[2])
108110
109111
for i = 1, participantCount do
110-
redis.call('SET', KEYS[i + 1], ARGV[2])
112+
redis.call('SET', KEYS[i + 2], ARGV[2])
111113
end
112114
113-
for i = participantCount + 2, #KEYS do
115+
for i = participantCount + 3, #KEYS do
114116
redis.call('DEL', KEYS[i])
115117
end
116118
@@ -268,6 +270,7 @@ public MatchSession markAcceptPending(QueueKey queueKey, List<WaitingUser> match
268270

269271
List<String> keys = new ArrayList<>();
270272
keys.add(MatchingRedisKeys.match(matchId));
273+
keys.add(MatchingRedisKeys.matchDeadline());
271274
participantIds.forEach(participantId -> keys.add(MatchingRedisKeys.userMatch(participantId)));
272275
participantIds.forEach(participantId -> keys.add(MatchingRedisKeys.userQueue(participantId)));
273276

@@ -276,7 +279,8 @@ public MatchSession markAcceptPending(QueueKey queueKey, List<WaitingUser> match
276279
keys,
277280
sessionJson,
278281
String.valueOf(matchId),
279-
String.valueOf(participantIds.size()));
282+
String.valueOf(participantIds.size()),
283+
String.valueOf(deadlineScore(deadline)));
280284

281285
if (storedJson == null || storedJson.isBlank()) {
282286
throw new IllegalStateException("Redis ready-check 세션 저장 결과를 확인할 수 없습니다.");
@@ -351,6 +355,7 @@ public RoomCreationAttempt tryBeginRoomCreation(Long matchId) {
351355
String updatedJson = serializer.writeMatchSession(roomCreatingSession);
352356

353357
if (compareAndSet(matchKey, currentJson, updatedJson)) {
358+
removeDeadlineIndex(matchId);
354359
return new RoomCreationAttempt(roomCreatingSession, true);
355360
}
356361
}
@@ -378,7 +383,7 @@ public MatchSession markRoomReady(Long matchId, Long roomId) {
378383
public MatchSession expire(Long matchId) {
379384
return updateMatchSession(matchId, currentSession -> {
380385
if (currentSession.status() != MatchSessionStatus.ACCEPT_PENDING) {
381-
return currentSession;
386+
throw new IllegalStateException("이미 만료 처리 대상이 아닌 매치 세션입니다.");
382387
}
383388

384389
return currentSession.expired();
@@ -461,31 +466,46 @@ public MatchSession findMatchSessionByUserId(Long userId) {
461466

462467
@Override
463468
public List<Long> findExpiredAcceptPendingMatchIds(LocalDateTime now) {
464-
Set<String> keys = redisTemplate.keys(MatchingRedisKeys.matchPattern());
469+
Set<String> candidates = redisTemplate
470+
.opsForZSet()
471+
.rangeByScore(MatchingRedisKeys.matchDeadline(), Double.NEGATIVE_INFINITY, deadlineScore(now));
465472

466-
if (keys == null || keys.isEmpty()) {
473+
if (candidates == null || candidates.isEmpty()) {
467474
return List.of();
468475
}
469476

470477
List<Long> expiredMatchIds = new ArrayList<>();
471478

472-
// deadline index 도입 전까지는 match 세션 key 들을 임시로 pattern scan 하며 만료 대상을 찾는다.
473-
for (String key : keys) {
474-
Long matchId = extractMatchIdFromKey(key);
479+
// Redis ZSET deadline index 는 후보만 좁혀주고, 최종 만료 여부는 세션 문서를 다시 읽어 확정한다.
480+
for (String candidate : candidates) {
481+
Long matchId = parseDeadlineMember(candidate);
475482
if (matchId == null) {
483+
redisTemplate.opsForZSet().remove(MatchingRedisKeys.matchDeadline(), candidate);
476484
continue;
477485
}
478486

487+
String key = MatchingRedisKeys.match(matchId);
479488
String sessionJson = redisTemplate.opsForValue().get(key);
480489
if (sessionJson == null || sessionJson.isBlank()) {
490+
removeDeadlineIndex(matchId);
481491
continue;
482492
}
483493

484494
MatchSession matchSession = serializer.readMatchSession(sessionJson);
485-
if (matchSession.status() == MatchSessionStatus.ACCEPT_PENDING
486-
&& matchSession.deadline() != null
487-
&& !matchSession.deadline().isAfter(now)) {
495+
if (matchSession.status() != MatchSessionStatus.ACCEPT_PENDING) {
496+
removeDeadlineIndex(matchId);
497+
continue;
498+
}
499+
500+
if (matchSession.deadline() == null) {
501+
removeDeadlineIndex(matchId);
502+
continue;
503+
}
504+
505+
if (!matchSession.deadline().isAfter(now)) {
488506
expiredMatchIds.add(matchSession.matchId());
507+
} else {
508+
addDeadlineIndex(matchSession);
489509
}
490510
}
491511

@@ -500,6 +520,7 @@ public void clearTerminalMatch(Long matchId) {
500520

501521
if (matchJson == null || matchJson.isBlank()) {
502522
removeUserMatchLinksByScan(matchId);
523+
removeDeadlineIndex(matchId);
503524
return;
504525
}
505526

@@ -509,6 +530,7 @@ public void clearTerminalMatch(Long matchId) {
509530
matchSession.participantIds().forEach(participantId -> keys.add(MatchingRedisKeys.userMatch(participantId)));
510531

511532
redisTemplate.execute(CLEAR_TERMINAL_SCRIPT, keys, String.valueOf(matchId));
533+
removeDeadlineIndex(matchId);
512534
}
513535

514536
@Override
@@ -547,6 +569,7 @@ public void clearMatchedRoom(Long userId, Long roomId) {
547569

548570
if (!hasRemainingReference) {
549571
compareAndDelete(matchKey, matchJson);
572+
removeDeadlineIndex(matchId);
550573
}
551574
}
552575

@@ -564,6 +587,7 @@ private MatchSession updateMatchSession(Long matchId, Function<MatchSession, Mat
564587

565588
String updatedJson = serializer.writeMatchSession(updatedSession);
566589
if (compareAndSet(matchKey, currentJson, updatedJson)) {
590+
syncDeadlineIndexAfterUpdate(currentSession, updatedSession);
567591
return updatedSession;
568592
}
569593
}
@@ -652,17 +676,45 @@ private Long parseMatchIdValue(String value) {
652676
}
653677
}
654678

655-
private Long extractMatchIdFromKey(String key) {
656-
if (key == null || !key.startsWith(MatchingRedisKeys.matchPrefix())) {
679+
private Long parseDeadlineMember(String value) {
680+
try {
681+
return Long.parseLong(value);
682+
} catch (NumberFormatException e) {
657683
return null;
658684
}
685+
}
659686

660-
String suffix = key.substring(MatchingRedisKeys.matchPrefix().length());
661-
if (suffix.isBlank() || !suffix.chars().allMatch(Character::isDigit)) {
662-
return null;
687+
private void syncDeadlineIndexAfterUpdate(MatchSession previousSession, MatchSession updatedSession) {
688+
if (updatedSession.status() == MatchSessionStatus.ACCEPT_PENDING && updatedSession.deadline() != null) {
689+
addDeadlineIndex(updatedSession);
690+
return;
691+
}
692+
693+
// ACCEPT_PENDING 을 벗어난 세션은 더 이상 스케줄러 만료 후보가 아니므로 deadline index 에서 제거한다.
694+
if (previousSession.status() == MatchSessionStatus.ACCEPT_PENDING) {
695+
removeDeadlineIndex(updatedSession.matchId());
696+
}
697+
}
698+
699+
private void addDeadlineIndex(MatchSession matchSession) {
700+
if (matchSession.deadline() == null) {
701+
return;
663702
}
664703

665-
return Long.parseLong(suffix);
704+
redisTemplate
705+
.opsForZSet()
706+
.add(
707+
MatchingRedisKeys.matchDeadline(),
708+
String.valueOf(matchSession.matchId()),
709+
deadlineScore(matchSession.deadline()));
710+
}
711+
712+
private void removeDeadlineIndex(Long matchId) {
713+
redisTemplate.opsForZSet().remove(MatchingRedisKeys.matchDeadline(), String.valueOf(matchId));
714+
}
715+
716+
private double deadlineScore(LocalDateTime deadline) {
717+
return deadline.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
666718
}
667719

668720
@SuppressWarnings("unchecked")

src/test/java/com/back/domain/matching/queue/service/RedisQueueReadyCheckServiceTest.java

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import com.back.domain.matching.queue.model.WaitingUser;
4040
import com.back.domain.matching.queue.store.MatchingStoreProperties;
4141
import com.back.domain.matching.queue.store.redis.FakeStringRedisTemplate;
42+
import com.back.domain.matching.queue.store.redis.MatchingRedisKeys;
4243
import com.back.domain.matching.queue.store.redis.MatchingRedisSerializer;
4344
import com.back.domain.matching.queue.store.redis.RedisMatchStateStore;
4445
import com.fasterxml.jackson.databind.json.JsonMapper;
@@ -193,6 +194,10 @@ void acceptMatch_returnsRoomReady_whenAllUsersAccepted() {
193194
assertThat(response.room()).isNotNull();
194195
assertThat(response.room().roomId()).isEqualTo(100L);
195196
assertThat(response.readyCheck().acceptedCount()).isEqualTo(4);
197+
assertThat(redisTemplate.opsForZSet().score(MatchingRedisKeys.matchDeadline(), String.valueOf(matchId)))
198+
.isNull();
199+
assertThat(redisTemplate.opsForValue().get(MatchingRedisKeys.match(matchId)))
200+
.isNotNull();
196201
verify(battleRoomService, times(1)).createRoom(any(CreateRoomRequest.class));
197202
verify(matchingEventPublisher, times(4)).publishRoomReady(any(), any());
198203
}
@@ -213,6 +218,10 @@ void declineMatch_returnsCancelled() {
213218
assertThat(response.status()).isEqualTo(MatchStatus.CANCELLED);
214219
assertThat(response.message()).isNotNull();
215220
assertThat(readyCheckService.getMyMatchStateV2(1L).status()).isEqualTo(MatchStatus.IDLE);
221+
assertThat(redisTemplate.opsForZSet().score(MatchingRedisKeys.matchDeadline(), String.valueOf(matchId)))
222+
.isNull();
223+
assertThat(redisTemplate.opsForValue().get(MatchingRedisKeys.match(matchId)))
224+
.isNull();
216225
verify(matchingEventPublisher, times(4)).publishMatchCancelled(any(), any());
217226
}
218227

@@ -226,15 +235,40 @@ void expireTimedOutMatches_publishesExpiredAndClearsSession() {
226235
new WaitingUser(3L, "m3", queueKey),
227236
new WaitingUser(4L, "m4", queueKey));
228237

229-
store.markAcceptPending(queueKey, users, LocalDateTime.now().minusSeconds(1));
238+
var matchSession =
239+
store.markAcceptPending(queueKey, users, LocalDateTime.now().minusSeconds(1));
230240
clearInvocations(matchingEventPublisher);
231241

232242
readyCheckService.expireTimedOutMatches();
233243

234244
assertThat(readyCheckService.getMyMatchStateV2(1L).status()).isEqualTo(MatchStatus.IDLE);
245+
assertThat(readyCheckService.getMyQueueStateV2(1L).inQueue()).isFalse();
246+
assertThat(redisTemplate
247+
.opsForZSet()
248+
.score(MatchingRedisKeys.matchDeadline(), String.valueOf(matchSession.matchId())))
249+
.isNull();
235250
verify(matchingEventPublisher, times(4)).publishMatchExpired(any(), any());
236251
}
237252

253+
@Test
254+
@DisplayName("decline 후 expire 스케줄러는 같은 match 를 중복 만료 처리하지 않는다")
255+
void expireTimedOutMatches_doesNotPublishExpiredAgain_afterDecline() {
256+
joinUser(1L);
257+
joinUser(2L);
258+
joinUser(3L);
259+
joinUser(4L);
260+
261+
Long matchId = readyCheckService.getMyMatchStateV2(1L).readyCheck().matchId();
262+
readyCheckService.declineMatch(2L, matchId);
263+
clearInvocations(matchingEventPublisher);
264+
265+
readyCheckService.expireTimedOutMatches();
266+
267+
assertThat(redisTemplate.opsForZSet().score(MatchingRedisKeys.matchDeadline(), String.valueOf(matchId)))
268+
.isNull();
269+
verify(matchingEventPublisher, times(0)).publishMatchExpired(any(), any());
270+
}
271+
238272
@Test
239273
@DisplayName("마지막 accept 경쟁에서도 room 은 한 번만 생성된다")
240274
void acceptMatch_createsRoomOnlyOnce_whenLastAcceptsRace() throws Exception {

0 commit comments

Comments
 (0)