Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,14 @@ public interface FolderRepository extends JpaRepository<Folder, Long>, FolderRep
List<Folder> findAllByIdInForShare(@Param("folderIds") Collection<Long> folderIds);

/**
* 폴더 삭제 전에 사용자의 폴더 전체를 배타 잠금(FOR UPDATE)으로 잡는다. (#233)
* 전체 폴더 삭제 전에 사용자의 폴더 전체를 배타 잠금(FOR UPDATE)으로 잡는다. (#233)
*
* <p>삭제할 폴더만이 아니라 사용자 폴더 전체를 잡는 이유는 하위 폴더 때문이다. 하위 폴더 목록은
* 잠그기 전에는 알 수 없고, 하위 폴더로 들어오는 등록도 막아야 한다.
* <p><b>폴더를 골라 지우는 경로는 이걸 쓰지 않는다.</b> 사용자 폴더 전체를 잡으면 삭제와 아무 상관 없는
* 폴더로 들어오는 등록까지 전부 줄을 서고, 기다리는 요청이 커넥션을 하나씩 물고 있어서
* 커넥션 풀이 바닥난다. 그러면 다른 사용자의 요청까지 커넥션을 못 받고 죽는다. (#319)
* 그쪽은 {@link #lockAllByIdIn} 과 {@link #lockAllByParentFolderIdIn} 으로 삭제 대상 서브트리만 잡는다.
*
* <p>여기는 어차피 사용자 폴더 전부가 삭제 대상이라 범위를 줄일 것이 없다. 계정 정리 때만 타는 드문 경로다.
*
* <p><b>반드시 트랜잭션의 첫 조회여야 한다.</b> REPEATABLE READ 는 첫 일반 조회 시점에 스냅숏을 만든다.
* 잠금보다 먼저 일반 조회를 하면, 잠금을 기다리는 동안 커밋된 등록이 그 스냅숏에 보이지 않아
Expand All @@ -49,6 +53,28 @@ public interface FolderRepository extends JpaRepository<Folder, Long>, FolderRep
@Query("select f from Folder f where f.userId = :userId")
List<Folder> lockAllByUserId(@Param("userId") Long userId);

/**
* 삭제 대상 폴더를 기본 키로 배타 잠금(FOR UPDATE)한다. (#319)
*
* <p>{@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<Folder> lockAllByIdIn(@Param("folderIds") Collection<Long> folderIds);

/**
* 주어진 폴더들의 바로 아래 하위 폴더를 배타 잠금(FOR UPDATE)으로 읽는다. (#319)
*
* <p>삭제 대상 서브트리를 한 단계씩 내려가며 잠그는 데 쓴다. <b>일반 조회로 내려가면 안 된다.</b>
* 일반 조회는 그 시점의 스냅숏을 고정하므로, 아직 잠그지 못한 하위 폴더에 그 뒤로 커밋된
* 문제나 폴더가 보이지 않아 #233 의 고아 데이터가 그대로 돌아온다. 잠금 조회는 스냅숏이 아니라
* 최신 행을 읽는다.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select f from Folder f where f.parentFolder.id in :parentFolderIds")
List<Folder> lockAllByParentFolderIdIn(@Param("parentFolderIds") Collection<Long> parentFolderIds);

/**
* 훈장 '정리의 신' 판정용. <b>루트 폴더는 빼고</b> 센다.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,8 @@ public void updateFolder(FolderRegisterDto folderRegisterDto, Long userId) {
}

public void deleteFoldersWithProblems(Long userId, List<Long> folderIds) {
// 같은 폴더로 들어오는 문제 등록과 순서를 맞춘다. 이 줄보다 앞에 조회를 두면 안 된다. (#233)
folderRepository.lockAllByUserId(userId);

// 삭제할 모든 폴더의 ID 조회 (하위 폴더 포함)
Set<Long> allFolderIds = getAllFolderIdsIncludingSubFolders(userId, folderIds);
// 삭제 대상 서브트리를 잠그면서 하위 폴더까지 모은다. 이 줄보다 앞에 조회를 두면 안 된다. (#233, #319)
Set<Long> allFolderIds = lockSubtreeAndCollectFolderIds(userId, folderIds);

problemService.deleteAllByFolderIds(userId, allFolderIds);

Expand All @@ -206,40 +203,57 @@ public void deleteAllUserFoldersWithProblems(Long userId) {
deleteAllUserFolders(userId);
}

public Set<Long> getAllFolderIdsIncludingSubFolders(Long userId, List<Long> folderIds) {
Set<Long> allFolderIds = new HashSet<>();
/**
* 삭제 대상 폴더와 그 하위 폴더 전부를 배타 잠금으로 잡으면서 ID 를 모은다. (#233, #319)
*
* <p><b>호출자의 트랜잭션에서 가장 먼저 실행돼야 한다.</b> 여기서 쓰는 조회는 전부 잠금 조회라
* REPEATABLE READ 스냅숏을 고정하지 않는다. 그래서 잠금을 다 잡은 뒤에 일어나는 일반 조회가
* "잠금을 잡은 시점 이후" 를 보게 되고, 잠금을 기다리다 커밋된 등록도 삭제 대상에 들어온다.
* 이 앞에 일반 조회를 한 줄이라도 두면 그 순간 스냅숏이 박혀 #233 의 고아 문제가 되살아난다.
*
* <p>한 단계씩 내려가도 빠지는 폴더는 없다. 어떤 폴더 아래에 새 폴더를 만들거나 옮기려면
* {@link #findParentFolderForShare} 로 그 부모를 공유 잠금해야 하는데, 우리가 배타 잠금을 쥔 뒤에는
* 그쪽이 기다렸다가 삭제된 부모를 보고 거절된다. 아직 안 잠근 단계에서 먼저 들어온 생성은
* 우리가 그 부모를 잠그려고 기다리는 동안 커밋되고, 그다음 잠금 조회가 최신 행을 읽어 잡아낸다.
*
* <p>사용자 폴더 전체를 잡던 예전 방식({@code lockAllByUserId})은 삭제와 무관한 폴더로 들어오는
* 등록까지 줄 세웠고, 기다리는 요청이 커넥션을 문 채로 풀을 바닥내 다른 사용자까지 죽였다. (#319)
*/
private Set<Long> lockSubtreeAndCollectFolderIds(Long userId, List<Long> folderIds) {
if (folderIds == null || folderIds.isEmpty()) {
return Set.of();
}

for (Long folderId : folderIds) {
Folder folder = findFolderEntity(folderId, userId);
Map<Long, Folder> 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<Long> getSubFolderIdsRecursive(Folder folder) {
Set<Long> subFolderIds = new HashSet<>();
collectSubFolderIds(folder, subFolderIds);
return subFolderIds;
}
// 이미 잠근 폴더는 다시 타고 들어가지 않는다. 부모-자식에 순환이 남아 있어도(과거 데이터) 한 번만 훑는다.
Set<Long> allFolderIds = new LinkedHashSet<>(lockedTargets.keySet());
Collection<Long> currentLevel = new ArrayList<>(allFolderIds);

/**
* 이미 방문한 폴더는 다시 타고 들어가지 않는다.
*
* <p>부모-자식 관계에 순환이 남아 있으면(과거 데이터 등) 단순 재귀는 StackOverflowError 로
* 삭제 요청 전체를 500 으로 떨어뜨린다. 방문 집합으로 한 번만 훑는다.
*/
private void collectSubFolderIds(Folder folder, Set<Long> collectedIds) {
for (Folder subFolder : folder.getSubFolderList()) {
if (collectedIds.add(subFolder.getId())) {
collectSubFolderIds(subFolder, collectedIds);
while (!currentLevel.isEmpty()) {
List<Long> nextLevel = new ArrayList<>();
for (Folder subFolder : folderRepository.lockAllByParentFolderIdIn(currentLevel)) {
if (allFolderIds.add(subFolder.getId())) {
nextLevel.add(subFolder.getId());
}
}
currentLevel = nextLevel;
}

return allFolderIds;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProblemReviewReminder, Long> {
Expand Down Expand Up @@ -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<Long> findPendingIdsByProblem(
@Param("problemId") Long problemId,
@Param("canceled") ProblemReviewReminderStatus canceled,
@Param("pendingStatuses") List<ProblemReviewReminderStatus> pendingStatuses
);

/**
* 예약을 <b>기본 키로</b> 취소한다. (#319)
*
* <p>예전에는 {@code WHERE problem_id = ?} 로 한 번에 UPDATE 했다. 그러면 REPEATABLE READ 에서
* {@code uq_problem_review_reminder_seq(problem_id, sequence)} 를 범위로 훑으면서 next-key lock 이
* 마지막 일치 항목 뒤의 <b>갭까지</b> 잡는다. {@code problem_id} 는 계속 커지므로 그 갭은 대개
* supremum(인덱스 끝) 이고, 그러면 <b>그 뒤에 등록되는 모든 문제</b>의 예약 INSERT 가
* 사용자와 무관하게 전부 막힌다. 문제 등록은 커밋 직후 예약을 넣기 때문에
* ({@code scheduleForNewProblems}), 폴더 하나 지우는 동안 다른 계정의 등록까지 잠금 대기에 걸렸다.
*
* <p>기본 키 등치 조회는 {@code REC_NOT_GAP} 이라 갭을 잡지 않는다.
*/
@Modifying
@Query("UPDATE ProblemReviewReminder r SET r.status = :canceled WHERE r.id IN :ids")
int cancelByIdIn(
@Param("ids") Collection<Long> 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,21 @@ public void scheduleForNewProblems(Long userId, List<ProblemCreatedEvent.Problem
}
}

/**
* 문제 삭제로 남은 예약을 취소한다.
*
* <p>취소 대상을 먼저 읽고 기본 키로 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<Long> 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);
}
Expand Down
Loading
Loading