Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE] 자동 예약 및 자동 업데이트 동시성 제어(#586) #604

Merged
merged 9 commits into from
Oct 16, 2024
Original file line number Diff line number Diff line change
@@ -1,71 +1,24 @@
package corea.scheduler.service;

import corea.exception.CoreaException;
import corea.exception.ExceptionType;
import corea.matching.domain.PullRequestInfo;
import corea.matching.service.MatchingService;
import corea.matching.service.PullRequestProvider;
import corea.room.domain.Room;
import corea.room.repository.RoomRepository;
import corea.scheduler.domain.ScheduleStatus;
import corea.scheduler.repository.AutomaticMatchingRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Component
@RequiredArgsConstructor
public class AutomaticMatchingExecutor {

private final PlatformTransactionManager transactionManager;
private final MatchingService matchingService;
private final PullRequestProvider pullRequestProvider;
private final RoomRepository roomRepository;
private final MatchingExecutor matchingExecutor;
private final AutomaticMatchingRepository automaticMatchingRepository;

@Async
@Transactional
Copy link
Contributor

Choose a reason for hiding this comment

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

의도에 맞게 잘 변경된거 같아요 👍

public void execute(long roomId) {
//TODO: 트랜잭션 분리
TransactionTemplate template = new TransactionTemplate(transactionManager);

try {
template.execute(status -> {
startMatching(roomId);
return null;
});
} catch (CoreaException e) {
log.warn("매칭 실행 중 에러 발생: {}", e.getMessage(), e);
updateRoomStatusToFail(roomId);
}
}

private void startMatching(long roomId) {
automaticMatchingRepository.findByRoomIdAndStatusForUpdate(roomId, ScheduleStatus.PENDING)
.ifPresent(automaticMatching -> {
Room room = getRoom(roomId);
PullRequestInfo pullRequestInfo = pullRequestProvider.getUntilDeadline(room.getRepositoryLink(), room.getRecruitmentDeadline());
matchingService.match(roomId, pullRequestInfo);

matchingExecutor.match(roomId);
automaticMatching.updateStatusToDone();
});
}

private void updateRoomStatusToFail(long roomId) {
//TODO: 위와 동일
TransactionTemplate template = new TransactionTemplate(transactionManager);
template.execute(status -> {
Room room = getRoom(roomId);
room.updateStatusToFail();
return null;
});
}

private Room getRoom(long roomId) {
return roomRepository.findById(roomId)
.orElseThrow(() -> new CoreaException(ExceptionType.ROOM_NOT_FOUND));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package corea.scheduler.service;

import corea.exception.CoreaException;
import corea.exception.ExceptionType;
import corea.matching.domain.PullRequestInfo;
import corea.matching.service.MatchingService;
import corea.matching.service.PullRequestProvider;
import corea.room.domain.Room;
import corea.room.repository.RoomRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;

@Slf4j
@Component
@RequiredArgsConstructor
public class MatchingExecutor {

private final PlatformTransactionManager transactionManager;
private final PullRequestProvider pullRequestProvider;
private final MatchingService matchingService;
private final RoomRepository roomRepository;

@Async
@Transactional
public void match(long roomId) {
//TODO: 트랜잭션 분리
Copy link
Contributor

Choose a reason for hiding this comment

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

트랜잭션 분리라는 게 정확히 어떤 태스크를 뜻하는 건가요? ㅇ.ㅇ
이번 PR에서 수행된 TODO인가요?

Copy link
Contributor

Choose a reason for hiding this comment

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

제가 적어둔 "트랜잭션 분리" TODO는 매칭 실패 시 예외가 발생했을 때 데이터를 저장하기 위해 명시적으로 트랜잭션을 선언한 부분입니다.

현재는 코드에서 직접 TransactionTemplate을 사용해 트랜잭션을 관리하고 있지만, 이를 더 효율적으로 관리하기 위해 트랜잭션 전파 속성을 활용해 별도의 클래스로 분리할 계획입니다.

TransactionTemplate template = new TransactionTemplate(transactionManager);

try {
template.execute(status -> {
startMatching(roomId);
return null;
});
} catch (CoreaException e) {
log.warn("매칭 실행 중 에러 발생: {}", e.getMessage(), e);
updateRoomStatusToFail(roomId);
}
}

private void startMatching(long roomId) {
Room room = getRoom(roomId);
PullRequestInfo pullRequestInfo = pullRequestProvider.getUntilDeadline(room.getRepositoryLink(), room.getRecruitmentDeadline());

matchingService.match(roomId, pullRequestInfo);
}

Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change


private void updateRoomStatusToFail(long roomId) {
//TODO: 위와 동일
TransactionTemplate template = new TransactionTemplate(transactionManager);
template.execute(status -> {
Room room = getRoom(roomId);
room.updateStatusToFail();
return null;
});
}

private Room getRoom(long roomId) {
return roomRepository.findById(roomId)
.orElseThrow(() -> new CoreaException(ExceptionType.ROOM_NOT_FOUND));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,12 @@
import corea.matching.infrastructure.dto.GithubUserResponse;
import corea.matching.infrastructure.dto.PullRequestResponse;
import corea.matching.service.PullRequestProvider;
import corea.matchresult.domain.MatchResult;
import corea.matchresult.repository.MatchResultRepository;
import corea.member.domain.Member;
import corea.member.domain.MemberRole;
import corea.member.repository.MemberRepository;
import corea.participation.domain.Participation;
import corea.participation.repository.ParticipationRepository;
import corea.room.domain.Room;
import corea.room.domain.RoomStatus;
import corea.room.repository.RoomRepository;
import corea.scheduler.domain.AutomaticMatching;
import corea.scheduler.repository.AutomaticMatchingRepository;
Expand All @@ -26,10 +23,8 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
Expand All @@ -56,17 +51,13 @@ class AutomaticMatchingExecutorTest {
@Autowired
private MemberRepository memberRepository;

@Autowired
private MatchResultRepository matchResultRepository;

@Autowired
private ParticipationRepository participationRepository;

@MockBean
private PullRequestProvider pullRequestProvider;

private Room room;
private Room emptyParticipantRoom;
private Member pororo;
private Member ash;
private Member joysun;
Expand All @@ -84,7 +75,6 @@ void setUp() {
cho = memberRepository.save(MemberFixture.MEMBER_CHOCO());

room = roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo, LocalDateTime.now().plusSeconds(3)));
emptyParticipantRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN(ash, LocalDateTime.now().plusSeconds(3)));

participationRepository.save(new Participation(room, pororo, MemberRole.BOTH, room.getMatchingSize()));
participationRepository.save(new Participation(room, ash, MemberRole.BOTH, room.getMatchingSize()));
Expand Down Expand Up @@ -121,17 +111,6 @@ private PullRequestInfo getPullRequestInfo(Member pororo, Member ash, Member joy
));
}

@Test
@DisplayName("매칭을 진행한다.")
void execute() {
AutomaticMatching automaticMatching = automaticMatchingRepository.save(new AutomaticMatching(room.getId(), room.getRecruitmentDeadline()));

automaticMatchingExecutor.execute(automaticMatching.getRoomId());

List<MatchResult> matchResults = matchResultRepository.findAll();
assertThat(matchResults).isNotEmpty();
}

@Test
@DisplayName("동시에 10개의 자동 매칭을 실행해도 PESSIMISTIC_WRITE 락을 통해 동시성을 제어할 수 있다.")
void startMatchingWithLock() throws InterruptedException {
Expand Down Expand Up @@ -161,15 +140,4 @@ void startMatchingWithLock() throws InterruptedException {

assertThat(successCount.get()).isEqualTo(1);
}

@Transactional
@Test
@DisplayName("매칭 시도 중 예외가 발생했다면 방 상태를 FAIL로 변경한다.")
void matchFail() {
AutomaticMatching automaticMatching = automaticMatchingRepository.save(new AutomaticMatching(emptyParticipantRoom.getId(), emptyParticipantRoom.getRecruitmentDeadline()));

automaticMatchingExecutor.execute(automaticMatching.getRoomId());

assertThat(emptyParticipantRoom.getStatus()).isEqualTo(RoomStatus.FAIL);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package corea.scheduler.service;

import config.ServiceTest;
import config.TestAsyncConfig;
import corea.fixture.MemberFixture;
import corea.fixture.RoomFixture;
import corea.matching.domain.PullRequestInfo;
import corea.matching.infrastructure.dto.GithubUserResponse;
import corea.matching.infrastructure.dto.PullRequestResponse;
import corea.matching.service.PullRequestProvider;
import corea.matchresult.domain.MatchResult;
import corea.matchresult.repository.MatchResultRepository;
import corea.member.domain.Member;
import corea.member.domain.MemberRole;
import corea.member.repository.MemberRepository;
import corea.participation.domain.Participation;
import corea.participation.repository.ParticipationRepository;
import corea.room.domain.Room;
import corea.room.domain.RoomStatus;
import corea.room.repository.RoomRepository;
import corea.scheduler.domain.AutomaticMatching;
import corea.scheduler.repository.AutomaticMatchingRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;

@ServiceTest
@Import(TestAsyncConfig.class)
class MatchingExecutorTest {

@Autowired
private MatchingExecutor matchingExecutor;

@Autowired
private RoomRepository roomRepository;

@Autowired
private AutomaticMatchingRepository automaticMatchingRepository;

@Autowired
private MatchResultRepository matchResultRepository;

@Autowired
private MemberRepository memberRepository;

@Autowired
private ParticipationRepository participationRepository;

@MockBean
private PullRequestProvider pullRequestProvider;

private Room room;
private Room emptyParticipantRoom;
private Member pororo;
private Member ash;
private Member joysun;
private Member movin;
private Member ten;
private Member cho;

@BeforeEach
void setUp() {
pororo = memberRepository.save(MemberFixture.MEMBER_PORORO());
ash = memberRepository.save(MemberFixture.MEMBER_ASH());
joysun = memberRepository.save(MemberFixture.MEMBER_YOUNGSU());
movin = memberRepository.save(MemberFixture.MEMBER_MOVIN());
ten = memberRepository.save(MemberFixture.MEMBER_TENTEN());
cho = memberRepository.save(MemberFixture.MEMBER_CHOCO());

room = roomRepository.save(RoomFixture.ROOM_DOMAIN(pororo, LocalDateTime.now().plusSeconds(3)));
emptyParticipantRoom = roomRepository.save(RoomFixture.ROOM_DOMAIN(ash, LocalDateTime.now().plusSeconds(3)));

participationRepository.save(new Participation(room, pororo, MemberRole.BOTH, room.getMatchingSize()));
participationRepository.save(new Participation(room, ash, MemberRole.BOTH, room.getMatchingSize()));
participationRepository.save(new Participation(room, joysun, MemberRole.BOTH, room.getMatchingSize()));
participationRepository.save(new Participation(room, movin, MemberRole.BOTH, room.getMatchingSize()));
participationRepository.save(new Participation(room, ten, MemberRole.BOTH, room.getMatchingSize()));
participationRepository.save(new Participation(room, cho, MemberRole.BOTH, room.getMatchingSize()));

when(pullRequestProvider.getUntilDeadline(any(), any()))
.thenReturn(getPullRequestInfo(pororo, ash, joysun, movin, ten, cho));
}

private PullRequestInfo getPullRequestInfo(Member pororo, Member ash, Member joysun, Member movin, Member ten, Member cho) {
return new PullRequestInfo(Map.of(
pororo.getGithubUserId(),
new PullRequestResponse("link", new GithubUserResponse(pororo.getGithubUserId()),
LocalDateTime.of(2024, 10, 12, 18, 00)),
ash.getGithubUserId(),
new PullRequestResponse("link", new GithubUserResponse(ash.getGithubUserId()),
LocalDateTime.of(2024, 10, 12, 18, 20)),
joysun.getGithubUserId(),
new PullRequestResponse("link", new GithubUserResponse(joysun.getGithubUserId()),
LocalDateTime.of(2024, 10, 12, 18, 30)),
movin.getGithubUserId(),
new PullRequestResponse("link", new GithubUserResponse(movin.getGithubUserId()),
LocalDateTime.of(2024, 10, 12, 18, 10)),
ten.getGithubUserId(),
new PullRequestResponse("link", new GithubUserResponse(ten.getGithubUserId()),
LocalDateTime.of(2024, 10, 12, 18, 01)),
cho.getGithubUserId(),
new PullRequestResponse("link", new GithubUserResponse(cho.getGithubUserId()),
LocalDateTime.of(2024, 10, 12, 18, 01)
)
));
}

@Test
@DisplayName("매칭을 진행한다.")
void match() {
AutomaticMatching automaticMatching = automaticMatchingRepository.save(new AutomaticMatching(room.getId(), room.getRecruitmentDeadline()));

matchingExecutor.match(automaticMatching.getRoomId());

List<MatchResult> matchResults = matchResultRepository.findAll();
assertThat(matchResults).isNotEmpty();
}

@Transactional
@Test
@DisplayName("매칭 시도 중 예외가 발생했다면 방 상태를 FAIL로 변경한다.")
void matchFail() {
AutomaticMatching automaticMatching = automaticMatchingRepository.save(new AutomaticMatching(emptyParticipantRoom.getId(), emptyParticipantRoom.getRecruitmentDeadline()));

matchingExecutor.match(automaticMatching.getRoomId());

assertThat(emptyParticipantRoom.getStatus()).isEqualTo(RoomStatus.FAIL);
}
}