diff --git a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java index 07debf20..389c6fb8 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java +++ b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java @@ -36,10 +36,14 @@ public interface FolderRepository extends JpaRepository, FolderRep List findAllByIdInForShare(@Param("folderIds") Collection folderIds); /** - * 폴더 삭제 전에 사용자의 폴더 전체를 배타 잠금(FOR UPDATE)으로 잡는다. (#233) + * 전체 폴더 삭제 전에 사용자의 폴더 전체를 배타 잠금(FOR UPDATE)으로 잡는다. (#233) * - *

삭제할 폴더만이 아니라 사용자 폴더 전체를 잡는 이유는 하위 폴더 때문이다. 하위 폴더 목록은 - * 잠그기 전에는 알 수 없고, 하위 폴더로 들어오는 등록도 막아야 한다. + *

폴더를 골라 지우는 경로는 이걸 쓰지 않는다. 사용자 폴더 전체를 잡으면 삭제와 아무 상관 없는 + * 폴더로 들어오는 등록까지 전부 줄을 서고, 기다리는 요청이 커넥션을 하나씩 물고 있어서 + * 커넥션 풀이 바닥난다. 그러면 다른 사용자의 요청까지 커넥션을 못 받고 죽는다. (#319) + * 그쪽은 {@link #lockAllByIdIn} 과 {@link #lockAllByParentFolderIdIn} 으로 삭제 대상 서브트리만 잡는다. + * + *

여기는 어차피 사용자 폴더 전부가 삭제 대상이라 범위를 줄일 것이 없다. 계정 정리 때만 타는 드문 경로다. * *

반드시 트랜잭션의 첫 조회여야 한다. REPEATABLE READ 는 첫 일반 조회 시점에 스냅숏을 만든다. * 잠금보다 먼저 일반 조회를 하면, 잠금을 기다리는 동안 커밋된 등록이 그 스냅숏에 보이지 않아 @@ -49,6 +53,28 @@ public interface FolderRepository extends JpaRepository, FolderRep @Query("select f from Folder f where f.userId = :userId") List lockAllByUserId(@Param("userId") Long userId); + /** + * 삭제 대상 폴더를 기본 키로 배타 잠금(FOR UPDATE)한다. (#319) + * + *

{@link #lockAllByUserId} 와 달리 {@code idx_folder_user_id} 등치 스캔을 타지 않아 + * next-key lock 의 갭이 인접 사용자 구간까지 덮지 않는다. 기본 키 등치 조회는 {@code REC_NOT_GAP} 이다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select f from Folder f where f.id in :folderIds") + List lockAllByIdIn(@Param("folderIds") Collection folderIds); + + /** + * 주어진 폴더들의 바로 아래 하위 폴더를 배타 잠금(FOR UPDATE)으로 읽는다. (#319) + * + *

삭제 대상 서브트리를 한 단계씩 내려가며 잠그는 데 쓴다. 일반 조회로 내려가면 안 된다. + * 일반 조회는 그 시점의 스냅숏을 고정하므로, 아직 잠그지 못한 하위 폴더에 그 뒤로 커밋된 + * 문제나 폴더가 보이지 않아 #233 의 고아 데이터가 그대로 돌아온다. 잠금 조회는 스냅숏이 아니라 + * 최신 행을 읽는다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select f from Folder f where f.parentFolder.id in :parentFolderIds") + List lockAllByParentFolderIdIn(@Param("parentFolderIds") Collection parentFolderIds); + /** * 훈장 '정리의 신' 판정용. 루트 폴더는 빼고 센다. * diff --git a/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java b/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java index 2f1645b5..8e7a26f9 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java +++ b/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java @@ -186,11 +186,8 @@ public void updateFolder(FolderRegisterDto folderRegisterDto, Long userId) { } public void deleteFoldersWithProblems(Long userId, List folderIds) { - // 같은 폴더로 들어오는 문제 등록과 순서를 맞춘다. 이 줄보다 앞에 조회를 두면 안 된다. (#233) - folderRepository.lockAllByUserId(userId); - - // 삭제할 모든 폴더의 ID 조회 (하위 폴더 포함) - Set allFolderIds = getAllFolderIdsIncludingSubFolders(userId, folderIds); + // 삭제 대상 서브트리를 잠그면서 하위 폴더까지 모은다. 이 줄보다 앞에 조회를 두면 안 된다. (#233, #319) + Set allFolderIds = lockSubtreeAndCollectFolderIds(userId, folderIds); problemService.deleteAllByFolderIds(userId, allFolderIds); @@ -206,40 +203,57 @@ public void deleteAllUserFoldersWithProblems(Long userId) { deleteAllUserFolders(userId); } - public Set getAllFolderIdsIncludingSubFolders(Long userId, List folderIds) { - Set allFolderIds = new HashSet<>(); + /** + * 삭제 대상 폴더와 그 하위 폴더 전부를 배타 잠금으로 잡으면서 ID 를 모은다. (#233, #319) + * + *

호출자의 트랜잭션에서 가장 먼저 실행돼야 한다. 여기서 쓰는 조회는 전부 잠금 조회라 + * REPEATABLE READ 스냅숏을 고정하지 않는다. 그래서 잠금을 다 잡은 뒤에 일어나는 일반 조회가 + * "잠금을 잡은 시점 이후" 를 보게 되고, 잠금을 기다리다 커밋된 등록도 삭제 대상에 들어온다. + * 이 앞에 일반 조회를 한 줄이라도 두면 그 순간 스냅숏이 박혀 #233 의 고아 문제가 되살아난다. + * + *

한 단계씩 내려가도 빠지는 폴더는 없다. 어떤 폴더 아래에 새 폴더를 만들거나 옮기려면 + * {@link #findParentFolderForShare} 로 그 부모를 공유 잠금해야 하는데, 우리가 배타 잠금을 쥔 뒤에는 + * 그쪽이 기다렸다가 삭제된 부모를 보고 거절된다. 아직 안 잠근 단계에서 먼저 들어온 생성은 + * 우리가 그 부모를 잠그려고 기다리는 동안 커밋되고, 그다음 잠금 조회가 최신 행을 읽어 잡아낸다. + * + *

사용자 폴더 전체를 잡던 예전 방식({@code lockAllByUserId})은 삭제와 무관한 폴더로 들어오는 + * 등록까지 줄 세웠고, 기다리는 요청이 커넥션을 문 채로 풀을 바닥내 다른 사용자까지 죽였다. (#319) + */ + private Set lockSubtreeAndCollectFolderIds(Long userId, List folderIds) { + if (folderIds == null || folderIds.isEmpty()) { + return Set.of(); + } - for (Long folderId : folderIds) { - Folder folder = findFolderEntity(folderId, userId); + Map lockedTargets = folderRepository.lockAllByIdIn(new LinkedHashSet<>(folderIds)).stream() + .collect(Collectors.toMap(Folder::getId, folder -> folder)); + // 검증 순서는 예전과 같게 요청받은 순서대로 본다. 없음 → 소유자 불일치 → 루트 순이다. + for (Long folderId : folderIds) { + Folder folder = lockedTargets.get(folderId); + if (folder == null) { + throw new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND); + } + validateFolderOwner(folder, userId); if (folder.getParentFolder() == null) { throw new ApplicationException(FolderErrorCase.ROOT_FOLDER_CANNOT_REMOVE); } - allFolderIds.add(folder.getId()); - allFolderIds.addAll(getSubFolderIdsRecursive(folder)); } - return allFolderIds; - } - - private Set getSubFolderIdsRecursive(Folder folder) { - Set subFolderIds = new HashSet<>(); - collectSubFolderIds(folder, subFolderIds); - return subFolderIds; - } + // 이미 잠근 폴더는 다시 타고 들어가지 않는다. 부모-자식에 순환이 남아 있어도(과거 데이터) 한 번만 훑는다. + Set allFolderIds = new LinkedHashSet<>(lockedTargets.keySet()); + Collection currentLevel = new ArrayList<>(allFolderIds); - /** - * 이미 방문한 폴더는 다시 타고 들어가지 않는다. - * - *

부모-자식 관계에 순환이 남아 있으면(과거 데이터 등) 단순 재귀는 StackOverflowError 로 - * 삭제 요청 전체를 500 으로 떨어뜨린다. 방문 집합으로 한 번만 훑는다. - */ - private void collectSubFolderIds(Folder folder, Set collectedIds) { - for (Folder subFolder : folder.getSubFolderList()) { - if (collectedIds.add(subFolder.getId())) { - collectSubFolderIds(subFolder, collectedIds); + while (!currentLevel.isEmpty()) { + List nextLevel = new ArrayList<>(); + for (Folder subFolder : folderRepository.lockAllByParentFolderIdIn(currentLevel)) { + if (allFolderIds.add(subFolder.getId())) { + nextLevel.add(subFolder.getId()); + } } + currentLevel = nextLevel; } + + return allFolderIds; } /** diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java index 16177889..cadc3a96 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java @@ -8,6 +8,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; public interface ProblemReviewReminderRepository extends JpaRepository { @@ -107,14 +108,36 @@ int recoverStuckRows( @Param("stuckBefore") LocalDateTime stuckBefore ); - @Modifying - @Query("UPDATE ProblemReviewReminder r SET r.status = :canceled WHERE r.problemId = :problemId AND r.status IN :pendingStatuses AND r.deletedAt IS NULL") - int cancelByProblem( + /** + * 취소할 예약의 ID 만 먼저 읽는다. 잠금을 잡지 않는 일반 조회다. (#319) + * + * @see #cancelByIdIn + */ + @Query("SELECT r.id FROM ProblemReviewReminder r WHERE r.problemId = :problemId AND r.status IN :pendingStatuses AND r.deletedAt IS NULL") + List findPendingIdsByProblem( @Param("problemId") Long problemId, - @Param("canceled") ProblemReviewReminderStatus canceled, @Param("pendingStatuses") List pendingStatuses ); + /** + * 예약을 기본 키로 취소한다. (#319) + * + *

예전에는 {@code WHERE problem_id = ?} 로 한 번에 UPDATE 했다. 그러면 REPEATABLE READ 에서 + * {@code uq_problem_review_reminder_seq(problem_id, sequence)} 를 범위로 훑으면서 next-key lock 이 + * 마지막 일치 항목 뒤의 갭까지 잡는다. {@code problem_id} 는 계속 커지므로 그 갭은 대개 + * supremum(인덱스 끝) 이고, 그러면 그 뒤에 등록되는 모든 문제의 예약 INSERT 가 + * 사용자와 무관하게 전부 막힌다. 문제 등록은 커밋 직후 예약을 넣기 때문에 + * ({@code scheduleForNewProblems}), 폴더 하나 지우는 동안 다른 계정의 등록까지 잠금 대기에 걸렸다. + * + *

기본 키 등치 조회는 {@code REC_NOT_GAP} 이라 갭을 잡지 않는다. + */ + @Modifying + @Query("UPDATE ProblemReviewReminder r SET r.status = :canceled WHERE r.id IN :ids") + int cancelByIdIn( + @Param("ids") Collection ids, + @Param("canceled") ProblemReviewReminderStatus canceled + ); + @Modifying @Query("UPDATE ProblemReviewReminder r SET r.problemMemoSnapshot = :memo, r.problemReferenceSnapshot = :reference WHERE r.problemId = :problemId AND r.status = :scheduled AND r.deletedAt IS NULL") int refreshSnapshot( diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java index c0ce8340..c821e971 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java @@ -74,9 +74,21 @@ public void scheduleForNewProblems(Long userId, List취소 대상을 먼저 읽고 기본 키로 UPDATE 한다. {@code problem_id} 조건으로 바로 UPDATE 하면 + * next-key lock 이 인덱스 끝의 갭까지 잡아서, 폴더 삭제가 커밋될 때까지 다른 계정의 문제 등록이 + * 전부 예약 INSERT 에서 막혔다. (#319, {@link ProblemReviewReminderRepository#cancelByIdIn}) + */ @Transactional public void cancelPendingByProblem(Long problemId) { - int count = repository.cancelByProblem(problemId, CANCELED, PENDING_STATUSES); + List pendingIds = repository.findPendingIdsByProblem(problemId, PENDING_STATUSES); + if (pendingIds.isEmpty()) { + return; + } + + int count = repository.cancelByIdIn(pendingIds, CANCELED); if (count > 0) { log.info("[ReviewReminder] 문제 삭제로 알림 취소 - problemId: {}, {}건", problemId, count); } diff --git a/src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteLockBlastRadiusTest.java b/src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteLockBlastRadiusTest.java new file mode 100644 index 00000000..f304145e --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteLockBlastRadiusTest.java @@ -0,0 +1,337 @@ +package com.aisip.OnO.backend.concurrency; + +import com.aisip.OnO.backend.folder.entity.Folder; +import com.aisip.OnO.backend.folder.service.FolderService; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2Dto; +import com.aisip.OnO.backend.problem.service.ProblemService; +import com.aisip.OnO.backend.problem.support.ProblemTestSupport; +import com.aisip.OnO.backend.support.TestContainers; +import com.aisip.OnO.backend.user.entity.User; +import com.github.gavlyukovskiy.boot.jdbc.decorator.DecoratedDataSource; +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.AfterEach; +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 javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * 폴더 삭제 잠금이 삭제 대상 밖으로 번지지 않는지 본다. (#319) + * + *

폴더 하나를 지우는 동안 두 군데서 잠금이 새어 나갔다. + *

    + *
  • #233 을 막으려고 넣은 폴더 잠금이 삭제 대상 서브트리가 아니라 사용자 폴더 전체를 잡았다. + * 삭제와 아무 상관 없는 폴더로 들어오는 등록까지 삭제가 끝날 때까지 기다렸다.
  • + *
  • 문제마다 도는 복습 알림 취소가 {@code problem_id} 범위 UPDATE 라 + * {@code uq_problem_review_reminder_seq} 인덱스 끝의 갭까지 next-key lock 으로 잡았다. + * {@code problem_id} 는 계속 커지므로 그 뒤에 등록되는 모든 문제의 예약 INSERT 가 + * 사용자와 무관하게 막혔다.
  • + *
+ * + *

기다리는 요청은 하나씩 커넥션을 물고 있다. 그래서 대기가 길어지면 Hikari 풀(기본 10개)이 바닥나고, + * 폴더와도 그 사용자와도 무관한 요청까지 {@code connection-timeout}(기본 30초) 뒤에 실패한다. + * dev 에서 잰 30.2초가 이 값이다. + * + *

그래서 여기서는 고아 데이터가 아니라 영향 범위를 본다. + *

    + *
  1. 삭제 트랜잭션이 잠그는 {@code folder} 행이 삭제 대상 서브트리뿐인지 ({@code performance_schema.data_locks})
  2. + *
  3. 삭제와 무관한 폴더로 들어오는 등록이 막히지 않는지
  4. + *
  5. 다른 계정의 등록이 막히지 않는지
  6. + *
  7. 등록이 몰려 들어와도 전부 막히지 않는지
  8. + *
+ * + *

고아 데이터 쪽 계약은 {@link FolderDeleteProblemRegisterRaceTest} 가 그대로 들고 있다. + * 잠금 범위를 줄이는 변경은 두 파일이 같이 통과해야 한다. + */ +@DisplayName("동시성 - 폴더 삭제 잠금의 영향 범위") +class FolderDeleteLockBlastRadiusTest extends ProblemTestSupport { + + private static final long STEP_TIMEOUT_SECONDS = 20; + + /** + * 잠금이 번지지 않으면 이 안에 끝난다. + * + *

번지면 잠금 대기({@code innodb_lock_wait_timeout} 기본 50초)나 + * 커넥션 대기(Hikari {@code connection-timeout} 기본 30초)로 넘어가므로, 몇 초짜리 여유로 갈린다. + */ + private static final long NO_BLOCKING_TIMEOUT_SECONDS = 8; + + @Autowired + private ProblemService problemService; + + @Autowired + private FolderService folderService; + + @Autowired + private DataSource dataSource; + + private ExecutorService executor; + private final CountDownLatch release = new CountDownLatch(1); + + private User owner; + private Folder ownerRoot; + private Folder target; + private Folder targetChild; + private Folder untouched; + + private User other; + private Folder otherRoot; + + private int poolSize; + + @BeforeEach + void setUpFoldersAndPool() { + owner = fixtures.createUser(); + ownerRoot = fixtures.createRootFolder(owner.getId()); + target = fixtures.createFolder(owner.getId(), "삭제 대상", ownerRoot); + targetChild = fixtures.createFolder(owner.getId(), "삭제 대상 하위", target); + untouched = fixtures.createFolder(owner.getId(), "삭제와 무관한 폴더", ownerRoot); + saveProblem(owner.getId(), target); + + other = fixtures.createUser(); + otherRoot = fixtures.createRootFolder(other.getId()); + + poolSize = maximumPoolSize(); + executor = Executors.newFixedThreadPool(poolSize + 4); + } + + /** + * 한 번에 몰아 넣을 등록 수. 커넥션 풀의 절반까지만 쓴다. + * + *

문제 등록은 커밋 직후 복습 알림 예약을 {@code AFTER_COMMIT} + {@code REQUIRES_NEW} 로 넣는다. + * 그동안 바깥 트랜잭션의 커넥션이 아직 반납되기 전이라, 요청 하나가 커넥션 두 개를 동시에 쥔다. + * 그래서 동시 등록이 풀 크기에 닿으면 폴더 삭제가 없어도 풀이 서로를 기다리며 멈추고, + * 전부 {@code connection-timeout} 을 채운다. 실제로 이 테스트에서 8건을 몰아 넣으면 + * "사용중 10, 유휴 0, 커넥션 대기중인 스레드 8" 로 멈춘다. + * + *

그건 폴더 삭제 잠금과는 다른 원인이라 여기서 고정하지 않는다. 이 테스트가 보는 것은 + * "폴더 삭제가 무관한 등록을 붙잡고 있는가" 뿐이므로, 풀이 문제가 되지 않는 선까지만 몰아 넣는다. + */ + private int concurrentRegistrationCount() { + return Math.max(2, poolSize / 2); + } + + @AfterEach + void releaseHeldTransaction() throws InterruptedException { + // 단언이 중간에 실패해도 세워 둔 트랜잭션이 커밋되고 스레드가 끝나야 다음 테스트의 DB 정리가 막히지 않는다. + release.countDown(); + executor.shutdown(); + assertThat(executor.awaitTermination(STEP_TIMEOUT_SECONDS * 4, TimeUnit.SECONDS)).isTrue(); + } + + @Test + @DisplayName("삭제 트랜잭션은 삭제 대상 서브트리 밖의 폴더 행을 잠그지 않는다") + void deleteLocksOnlyTargetSubtree() throws Exception { + holdUntilReleased(() -> folderService.deleteFoldersWithProblems(owner.getId(), List.of(target.getId()))); + + Set lockedFolderIds = lockedFolderRowIds(); + Set lockedIndexNames = lockedFolderIndexNames(); + + assertThat(lockedFolderIds) + .as("사용자 폴더 전체를 잡으면 루트와 무관한 폴더까지 잠겨서, 그 폴더로 들어오는 등록이 다 줄을 선다") + .containsExactlyInAnyOrder(target.getId(), targetChild.getId()); + assertThat(lockedIndexNames) + .as("idx_folder_user_id 등치 스캔은 next-key lock 이라 갭이 인접 사용자 구간까지 덮는다 (#291 댓글 실측)") + .doesNotContain("idx_folder_user_id"); + } + + @Test + @DisplayName("삭제와 무관한 폴더로 들어오는 등록은 삭제를 기다리지 않는다") + void registrationIntoUnrelatedFolderIsNotBlocked() throws Exception { + holdUntilReleased(() -> folderService.deleteFoldersWithProblems(owner.getId(), List.of(target.getId()))); + + Future registration = executor.submit(() -> problemService.registerProblemV2( + new ProblemRegisterV2Dto(null, "무관한 폴더 등록", null, untouched.getId(), null, null, null), + owner.getId())); + + assertCompletesWithoutBlocking(registration, "삭제 대상이 아닌 폴더로 들어오는 등록"); + } + + @Test + @DisplayName("다른 계정의 문제 등록은 남의 폴더 삭제를 기다리지 않는다") + void registrationByAnotherUserIsNotBlocked() throws Exception { + holdUntilReleased(() -> folderService.deleteFoldersWithProblems(owner.getId(), List.of(target.getId()))); + + Future registration = executor.submit(() -> problemService.registerProblemV2( + new ProblemRegisterV2Dto(null, "다른 계정 등록", null, otherRoot.getId(), null, null, null), + other.getId())); + + assertCompletesWithoutBlocking(registration, "폴더를 지운 사용자와 아무 관계도 없는 다른 계정의 문제 등록"); + } + + @Test + @DisplayName("삭제와 무관한 폴더로 등록이 몰려도 전부 삭제를 기다리지 않는다") + void concurrentRegistrationsIntoUnrelatedFolderAreNotBlocked() throws Exception { + holdUntilReleased(() -> folderService.deleteFoldersWithProblems(owner.getId(), List.of(target.getId()))); + + List> registrations = new ArrayList<>(); + for (int i = 0; i < concurrentRegistrationCount(); i++) { + String memo = "동시 등록 " + i; + registrations.add(executor.submit(() -> problemService.registerProblemV2( + new ProblemRegisterV2Dto(null, memo, null, untouched.getId(), null, null, null), + owner.getId()))); + } + + for (int i = 0; i < registrations.size(); i++) { + assertCompletesWithoutBlocking(registrations.get(i), (i + 1) + "번째 동시 등록"); + } + } + + /** 작업을 트랜잭션 안에서 실행하고, 커밋하기 직전에 {@link #release} 가 풀릴 때까지 붙잡아 둔다. */ + private void holdUntilReleased(Runnable work) throws Exception { + CountDownLatch workDone = new CountDownLatch(1); + Future future = executor.submit(() -> transactionTemplate.executeWithoutResult(status -> { + work.run(); + workDone.countDown(); + awaitRelease(); + })); + + if (!workDone.await(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + release.countDown(); + future.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + fail("먼저 잡아야 할 삭제 트랜잭션이 %d초 안에 잠금을 잡지 못했다", STEP_TIMEOUT_SECONDS); + } + } + + private void awaitRelease() { + try { + if (!release.await(STEP_TIMEOUT_SECONDS * 4, TimeUnit.SECONDS)) { + throw new IllegalStateException("release 신호를 받지 못했다"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private void assertCompletesWithoutBlocking(Future future, String what) { + try { + future.get(NO_BLOCKING_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (java.util.concurrent.TimeoutException e) { + fail("%s 가 폴더 삭제 때문에 %d초 안에 끝나지 않았다.%n커넥션 풀: %s%n대기 사슬:%n%s", + what, NO_BLOCKING_TIMEOUT_SECONDS, poolStats(), lockWaitChain()); + } catch (Exception e) { + throw new AssertionError(what + " 가 실패했다", e); + } + } + + /** 커넥션을 못 받아 막힌 것인지 바로 보이게 찍는다. */ + private String poolStats() { + var pool = hikariDataSource().getHikariPoolMXBean(); + return "최대 %d, 사용중 %d, 유휴 %d, 커넥션 대기중인 스레드 %d".formatted( + poolSize, pool.getActiveConnections(), pool.getIdleConnections(), + pool.getThreadsAwaitingConnection()); + } + + /** 왜 막혔는지 바로 보이도록 {@code performance_schema} 의 대기 사슬을 그대로 찍는다. */ + private String lockWaitChain() { + StringBuilder chain = new StringBuilder(); + try (Connection root = rootConnection(); + PreparedStatement statement = root.prepareStatement(""" + SELECT r.OBJECT_NAME, r.INDEX_NAME, r.LOCK_TYPE, r.LOCK_MODE, r.LOCK_DATA, + b.LOCK_MODE, b.LOCK_DATA + FROM performance_schema.data_lock_waits w + JOIN performance_schema.data_locks r ON r.ENGINE_LOCK_ID = w.REQUESTING_ENGINE_LOCK_ID + JOIN performance_schema.data_locks b ON b.ENGINE_LOCK_ID = w.BLOCKING_ENGINE_LOCK_ID + """); + ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + chain.append(" 요청 %s.%s %s %s [%s] <- 보유 %s [%s]%n".formatted( + resultSet.getString(1), resultSet.getString(2), resultSet.getString(3), + resultSet.getString(4), resultSet.getString(5), + resultSet.getString(6), resultSet.getString(7))); + } + } catch (SQLException e) { + chain.append(" (대기 사슬을 읽지 못했다: ").append(e.getMessage()).append(')'); + } + return chain.isEmpty() ? " (잠금 대기 없음 - 커넥션 풀 대기일 수 있다)" : chain.toString(); + } + + /** + * 지금 잠겨 있는 {@code folder} 행의 기본 키 집합. + * + *

{@code X,GAP} 처럼 갭만 잡은 잠금은 뺀다. 갭 잠금은 그 행을 잡은 것이 아니라 앞의 빈 구간을 잡은 것이다. + * {@code X,REC_NOT_GAP} 도 'GAP' 으로 끝나므로 {@code LIKE '%GAP'} 로 거르면 전부 빠진다. + */ + private Set lockedFolderRowIds() throws Exception { + Set lockedIds = new LinkedHashSet<>(); + try (Connection root = rootConnection(); + PreparedStatement statement = root.prepareStatement(""" + SELECT DISTINCT LOCK_DATA + FROM performance_schema.data_locks + WHERE OBJECT_SCHEMA = DATABASE() + AND OBJECT_NAME = 'folder' + AND INDEX_NAME = 'PRIMARY' + AND LOCK_TYPE = 'RECORD' + AND LOCK_MODE NOT LIKE '%,GAP' + """); + ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + String lockData = resultSet.getString(1); + if (lockData != null && lockData.chars().allMatch(Character::isDigit)) { + lockedIds.add(Long.parseLong(lockData)); + } + } + } + return lockedIds; + } + + /** 지금 {@code folder} 테이블에서 잠금이 잡힌 인덱스 이름들. */ + private Set lockedFolderIndexNames() throws Exception { + Set indexNames = new LinkedHashSet<>(); + try (Connection root = rootConnection(); + PreparedStatement statement = root.prepareStatement(""" + SELECT DISTINCT INDEX_NAME + FROM performance_schema.data_locks + WHERE OBJECT_SCHEMA = DATABASE() + AND OBJECT_NAME = 'folder' + AND LOCK_TYPE = 'RECORD' + """); + ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + indexNames.add(resultSet.getString(1)); + } + } + return indexNames; + } + + /** performance_schema 는 테스트 계정 권한으로 볼 수 없어 root 로 붙는다. */ + private Connection rootConnection() throws SQLException { + return DriverManager.getConnection( + TestContainers.mysql().getJdbcUrl(), "root", TestContainers.mysql().getPassword()); + } + + private int maximumPoolSize() { + return hikariDataSource().getMaximumPoolSize(); + } + + /** p6spy 가 DataSource 를 감싸고 있어서 원본을 한 겹 벗겨야 Hikari 가 나온다. */ + private HikariDataSource hikariDataSource() { + DataSource candidate = dataSource instanceof DecoratedDataSource decorated + ? decorated.getRealDataSource() + : dataSource; + if (candidate instanceof HikariDataSource hikari) { + return hikari; + } + throw new IllegalStateException("HikariDataSource 를 꺼내지 못했다: " + dataSource.getClass()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSenderTest.java b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSenderTest.java index a5d48856..09a0dcd6 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSenderTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSenderTest.java @@ -87,7 +87,7 @@ void doesNotSendWhenClaimFails() { @DisplayName("취소된 행도 선점되지 않는다") void doesNotSendCanceledRow() { ProblemReviewReminder reminder = saveReminder(LocalDateTime.now().minusHours(1)); - inTransaction(() -> reminderRepository.cancelByProblem(reminder.getProblemId(), CANCELED, List.of(SCHEDULED))); + inTransaction(() -> reminderRepository.cancelByIdIn(List.of(reminder.getId()), CANCELED)); sender.send(reminder, LocalDateTime.now());