diff --git a/docs/design/kangcheolung-#148-ragops-dashboard-retry.md b/docs/design/kangcheolung-#148-ragops-dashboard-retry.md new file mode 100644 index 0000000..805ac65 --- /dev/null +++ b/docs/design/kangcheolung-#148-ragops-dashboard-retry.md @@ -0,0 +1,269 @@ +# Issue #148 RAGOps Dashboard 관리자 재처리 API 상세 설계 + +closes #148 + +## 1. 배경과 목적 + +이슈#134(집계 조회)와 이슈#137(WebSocket push)로 대시보드가 "지금 FAILED 작업이 27건 있다"는 +걸 실시간으로 보여줄 수 있게 됐다. 이번 작업은 그 화면에서 관리자가 실제로 개입할 수 있는 +마지막 조각 — 재처리 버튼을 누르면 FAILED 작업을 다시 Queue에 넣고, 그 결과를 즉시 대시보드에 +반영하는 API다. + +FAILED 목록 조회(F-OPS-03)는 A 담당자가 이미 `GET /admin/indexing-jobs?status=FAILED`로 +제공하고 있어 재사용하고, 이 이슈는 재처리 트리거(F-OPS-04 단건, F-OPS-05 전체)만 만든다. + +### 1.1 성공 기준 + +- 단건 재처리 클릭 시 FAILED→PENDING 전환과 즉시 대시보드 push가 확인된다. +- 전체 재처리 시 정확한 재처리 건수를 반환한다 (개별 실패는 skip하고 성공 건수만 집계). +- `embedding_jobs`를 B가 직접 update하지 않는다 — 상태 전환은 전부 A의 Service를 경유한다. +- ADMIN이 아닌 사용자 요청은 403. + +## 2. 범위 + +### 2.1 포함 + +- `POST /admin/embedding-jobs/{jobId}/retry` (단건) +- `POST /admin/embedding-jobs/retry-all` (전체) +- 재처리 성공 시 대시보드 WebSocket push +- 단위 테스트 + +### 2.2 제외 + +- FAILED 목록 조회 API — A의 기존 API 재사용, 신규 구현 없음 +- `embedding_jobs` 상태 전환 로직 자체 — A 담당자 소유(`EmbeddingJobManualRetryService`) +- Worker 상태 전이 시점 이벤트 훅 — 별도 이슈 +- 부하테스트 — 관리자 전용 저빈도 수동 트리거라 처리량 요구사항이 없어 범위에서 제외 (4.4절) + +## 3. API 계약 + +``` +POST /admin/embedding-jobs/{jobId}/retry (단건) +POST /admin/embedding-jobs/retry-all (전체) +Authorization: Bearer {JWT} +``` + +- ADMIN 역할만 호출 가능 (`SecurityConfig`의 `/admin/** -> hasRole("ADMIN")` 재사용) +- 단건 응답은 A의 `ManualRetriedIndexingJobResponse`를 그대로 반환한다 (4.1절 참고) +- 전체 응답은 `{ retriedCount, message }` + +### 3.1 응답 예시 + +```json +// POST /admin/embedding-jobs/{jobId}/retry +{ + "jobId": 42, + "status": "PENDING", + "documentId": 3, + "documentVersionId": 5, + "documentVersionStatus": "CHUNKED", + "retryCount": 3, + "maxRetryCount": 3, + "requeuedAt": "2026-08-11T15:00:00" +} + +// POST /admin/embedding-jobs/retry-all +{ + "retriedCount": 27, + "message": "27개 작업 재처리 요청이 완료되었습니다." +} +``` + +## 4. 구현 상세 + +읽는 순서는 DTO(4.1) → Service(4.2~4.4) → Controller(4.5)를 따른다. + +### 4.1 응답 DTO — 새로 만든 건 하나뿐 + +단건 재처리는 새 DTO를 만들지 않고 A의 기존 `ManualRetriedIndexingJobResponse`를 그대로 +재사용한다. 스펙 초안의 응답 예시(`{jobId, status, message}`)보다 A가 이미 documentId· +retryCount·requeuedAt 등 더 풍부한 정보를 주고 있어서, 필드를 깎아낸 새 DTO를 만드는 게 +오히려 손해였다. + +전체 재처리만 A쪽에 대응하는 응답 타입이 없어서 새로 만들었다: + +```java +public record RetryAllJobsResponse( + @Schema(description = "재처리에 성공한 Job 수", example = "27") + int retriedCount, + + @Schema(description = "결과 메시지", example = "27개 작업 재처리 요청이 완료되었습니다.") + String message +) { +} +``` + +### 4.2 `EmbeddingJobRetryService` — 단건 재처리 + +```java +public ManualRetriedIndexingJobResponse retryJob(Long jobId) { + ManualRetriedIndexingJobResponse response = embeddingJobManualRetryService.retry(jobId); + dashboardWebSocketController.sendDashboardUpdate(dashboardQueryService.getSummary()); + return response; +} +``` + +`embeddingJobManualRetryService`는 A 담당자가 만든 서비스를 그대로 주입받아 호출한다. +`retry(jobId)`가 예외 없이 반환하면(=성공) 바로 이슈1의 `DashboardQueryService.getSummary()`로 +최신 집계를 다시 계산하고, 이슈2의 `DashboardWebSocketController.sendDashboardUpdate()`로 +`/topic/dashboard` 구독자에게 push한다. 예외 처리 코드가 하나도 없는데, 이유는 4.4절 참고. + +### 4.3 `EmbeddingJobRetryService` — 전체 재처리 + +```java +public RetryAllJobsResponse retryAllFailedJobs() { + List failedJobs = embeddingJobRepository.findAllByStatus(EmbeddingJobStatus.FAILED); + + int retriedCount = 0; + for (EmbeddingJob failedJob : failedJobs) { + try { + embeddingJobManualRetryService.retry(failedJob.getId()); + retriedCount++; + } catch (Exception e) { + log.warn("전체 재처리 중 Job 건너뜀: jobId={}, reason={}", failedJob.getId(), e.getMessage()); + } + } + + if (retriedCount > 0) { + dashboardWebSocketController.sendDashboardUpdate(dashboardQueryService.getSummary()); + } + + return new RetryAllJobsResponse(retriedCount, RETRY_ALL_MESSAGE_FORMAT.formatted(retriedCount)); +} +``` + +- `embeddingJobRepository.findAllByStatus(FAILED)` — A의 REST 엔드포인트를 내부적으로 또 + 호출하지 않고 Repository를 직접 읽는다. 이슈1에서 이 용도로 미리 추가해둔 메서드를 재사용한다. + 쓰기가 아니라 조회라서 "A 소유 테이블은 쓰기만 A 경유" 원칙과 충돌하지 않는다. +- `for` 루프 안에서 한 건씩 `try-catch`로 감싼다. 한 건이 실패해도 로그만 남기고 나머지는 + 계속 처리한다 — "개별 실패는 skip하고 성공 건수만 집계" 요구사항이 이 catch 하나로 구현된다. +- push는 루프가 끝난 뒤 **한 번만**, `retriedCount > 0`일 때만 한다. 건마다 push하면 27건이면 + 27번 push가 나가 "상태 변경 시점에만 push" 취지와 어긋나고, 성공이 하나도 없으면(전부 실패) + 실제로 바뀐 게 없으니 push 자체를 생략한다. + +### 4.4 왜 클래스 레벨 `@Transactional`이 없는가 + +`service-pattern.md`는 Command Service에 클래스 레벨 `@Transactional`을 필수로 두라고 하는데, +이 클래스는 일부러 뺐다. + +`retryAllFailedJobs()`가 이 메서드 자체에서 트랜잭션을 열면, 안에서 호출하는 A의 +`embeddingJobManualRetryService.retry()`(자신도 `@Transactional`)가 그 바깥 트랜잭션에 +참여(Propagation.REQUIRED, 기본값)하게 된다. 이 상태에서 예를 들어 3건 중 2번째 Job에서 예외가 +나면, 스프링은 그 시점에 트랜잭션 전체를 **rollback-only로 표시**한다. 이후 `catch`로 예외를 +잡아 무시해도 이 표시는 풀리지 않는다 — 결국 메서드가 끝나고 커밋하려는 순간 스프링이 rollback을 +강제하면서, 이미 성공했던 1번째 Job의 재처리까지 전부 날아간다. "개별 실패는 건너뛰고 성공 건수만 +집계한다"는 요구사항 자체가 깨지는 것이다. + +그래서 `EmbeddingJobRetryService`는 트랜잭션을 열지 않는다. 그러면 `retry()`를 호출할 때마다 +바깥에 활성 트랜잭션이 없으므로, A Service 자신의 `@Transactional`이 매번 **새 독립 트랜잭션**을 +만들어 그 자리에서 바로 커밋한다. 한 건의 실패가 다른 건의 성공을 되돌리지 않는다. + +이 근거는 Mockito 단위 테스트로는 증명할 수 없다 — Mock은 실제 스프링 트랜잭션 프록시를 통하지 +않으므로 "독립 커밋"이 실제로 일어나는지 확인이 안 된다. 6.2절에서 이 부분만 실제 Postgres로 +검증하는 통합 테스트를 별도로 추가했다. + +### 4.5 `EmbeddingJobRetryController` + +```java +@RequestMapping("/admin/embedding-jobs") +public class EmbeddingJobRetryController { + + @PostMapping("/{jobId}/retry") + public ResponseEntity> retryJob( + @PathVariable @Positive Long jobId + ) { + return ResponseUtils.ok(embeddingJobRetryService.retryJob(jobId)); + } + + @PostMapping("/retry-all") + public ResponseEntity> retryAllJobs() { + return ResponseUtils.ok(embeddingJobRetryService.retryAllFailedJobs()); + } +} +``` + +`/admin/**`라 `SecurityConfig`의 기존 규칙으로 자동 ADMIN 전용이다. 예외 처리 코드가 컨트롤러에도 +없다 — 5장 참고. + +## 5. 오류 계약 — 별도 코드 없이 해결됨 + +`EmbeddingJobManualRetryService.retry()`(A 소유)가 이미 상황별로 다른 `ErrorCode`를 던진다: + +```java +// jobId 없음 +.orElseThrow(() -> new DocGridException(ErrorCode.EMBEDDING_JOB_NOT_FOUND)); // 404 + +// 상태가 FAILED가 아님 +if (embeddingJob.getStatus() != EmbeddingJobStatus.FAILED) { + throw new DocGridException(ErrorCode.EMBEDDING_JOB_MANUAL_RETRY_NOT_ALLOWED); // 409 +} +``` + +그리고 프로젝트 전역 `GlobalExceptionHandler`가 `DocGridException`을 이미 공통 처리한다: + +```java +@ExceptionHandler(DocGridException.class) +public ResponseEntity handleDocGridException(DocGridException e, HttpServletRequest request) { + return ResponseEntity.status(e.getErrorCode().getHttpStatus()) + .body(ErrorResponse.of(e.getErrorCode(), e.getMessage(), request)); +} +``` + +| 상황 | 처리 | +|---|---| +| jobId 존재하지 않음 | A의 `EMBEDDING_JOB_NOT_FOUND` → 404 (B는 그대로 propagate) | +| FAILED 상태가 아님 | A의 `EMBEDDING_JOB_MANUAL_RETRY_NOT_ALLOWED` → 409 (B는 그대로 propagate) | +| ADMIN이 아닌 사용자 | `SecurityConfig`의 기존 `/admin/**` 정책 → 403 | +| 전체 재처리 시 FAILED 0건 | 루프가 빈 목록을 돌아 `retriedCount: 0`으로 200 정상 응답 | +| 전체 재처리 중 개별 Job 실패 | `catch (Exception e)`로 흡수, `log.warn`만 남기고 계속 진행 | + +B가 사전에 `EmbeddingJobRepository.findById()`로 존재/상태를 확인하고 직접 404/409를 판단할 +필요가 전혀 없다 — A가 이미 구분해서 던지고, 기존 인프라가 이미 처리하기 때문이다. + +## 6. 테스트 설계 + +### 6.1 단위 테스트 — `EmbeddingJobRetryServiceTest` + +전부 Mockito로 A Service·Repository·대시보드 컴포넌트를 mock 처리한다. + +- 단건 재처리 → A 호출 + push 확인 +- 3건 중 1건 실패 → `retriedCount == 2`, push는 됨 +- FAILED 0건 → `retriedCount == 0`, push 안 됨 +- 전부 실패 → `retriedCount == 0`, push 안 됨 + +### 6.2 PostgreSQL 통합 테스트 — 트랜잭션 독립성 검증 (`EmbeddingJobRetryTransactionIsolationIntegrationTest`) + +4.4절의 핵심 주장("한 Job의 실패가 다른 Job의 성공을 롤백하지 않는다")은 Mock으로 증명할 수 +없다는 판단으로, 이슈 To-do에는 없었지만 검증 공백을 메우기 위해 추가했다. + +`@SpringBootTest`로 A의 `EmbeddingJobManualRetryService`를 Mock 없이 실제 빈으로 띄우고, +실제 Postgres에 FAILED 상태 Job 3건을 직접 SQL로 만든다 — 2건은 정상 재처리 대상, 1건은 대상 +문서를 soft-delete(`deleted_at` 설정)해서 A의 `validateRetryTarget()`이 실제 검증 로직으로 +`EMBEDDING_JOB_MANUAL_RETRY_TARGET_INVALID`를 던지도록 유도한다. `retryAllFailedJobs()` 실행 +후: + +- `retriedCount == 2` +- 성공한 2건은 DB에서 직접 조회했을 때 실제로 `status = 'PENDING'`으로 커밋돼 있음 +- 실패한 1건은 `status = 'FAILED'`로 그대로 남아 있음(변경 없음) + +을 확인해서, "일부 실패해도 나머지는 진짜 커밋된다"는 설계 근거를 Mock이 아닌 실제 트랜잭션 +경계로 검증했다. + +## 7. 커밋 분할 + +1. `docs: #148 RAGOps Dashboard 관리자 재처리 API 설계 문서 추가` +2. `feat: #148 전체 재처리 응답 DTO 추가` +3. `feat: #148 관리자 재처리 Command Service 구현` +4. `feat: #148 관리자 재처리 Controller 구현` +5. `test: #148 관리자 재처리 단위 테스트 추가` +6. `test: #148 전체 재처리 트랜잭션 독립성 PostgreSQL 통합 테스트 추가` + +## 8. 완료 조건 + +- 단건 재처리 클릭 시 FAILED→PENDING 전환과 즉시 대시보드 push가 확인된다 +- 전체 재처리 시 정확한 재처리 건수를 반환한다 (개별 실패는 skip하고 성공 건수만 집계) +- 전체 재처리 중 일부 Job이 실패해도 나머지 성공한 Job은 실제 Postgres에 독립적으로 커밋된다 + (Mock이 아닌 통합 테스트로 검증) +- ADMIN이 아닌 사용자 요청은 403 +- `embedding_jobs`를 B가 직접 update하지 않는다 +- 전체 빌드(`./gradlew build`)가 회귀 없이 통과한다 (751개 테스트, failures 0, errors 0) diff --git a/src/main/java/com/opensource/docgrid/domain/dashboard/controller/EmbeddingJobRetryController.java b/src/main/java/com/opensource/docgrid/domain/dashboard/controller/EmbeddingJobRetryController.java new file mode 100644 index 0000000..326743f --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/dashboard/controller/EmbeddingJobRetryController.java @@ -0,0 +1,97 @@ +package com.opensource.docgrid.domain.dashboard.controller; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.opensource.docgrid.domain.dashboard.dto.response.RetryAllJobsResponse; +import com.opensource.docgrid.domain.dashboard.service.command.EmbeddingJobRetryService; +import com.opensource.docgrid.domain.embedding.dto.response.ManualRetriedIndexingJobResponse; +import com.opensource.docgrid.global.common.response.ApiResponse; +import com.opensource.docgrid.global.common.response.ErrorResponse; +import com.opensource.docgrid.global.common.response.ResponseUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; + +/** + * 관리자 FAILED Embedding Job 재처리 트리거의 HTTP 경계. + * + *

요청 검증과 응답 변환만 담당하며, 실제 재처리 위임과 대시보드 push는 + * {@link EmbeddingJobRetryService}에 위임한다. + */ +@Tag(name = "Admin - Dashboard", description = "관리자 전용 RAGOps Dashboard 집계 지표 API") +@Validated +@RestController +@RequestMapping("/admin/embedding-jobs") +@RequiredArgsConstructor +public class EmbeddingJobRetryController { + + private final EmbeddingJobRetryService embeddingJobRetryService; + + @Operation( + summary = "FAILED Job 단건 재처리", + description = "최종 실패한 Embedding Job 하나를 다시 Queue에 넣습니다. " + + "재처리 대상이 아니면(FAILED가 아니거나 대상 조건 불충족) 409, 존재하지 않으면 404를 반환합니다. " + + "재처리 성공 시 최신 대시보드 집계를 WebSocket(/topic/dashboard)으로 즉시 push합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "재처리 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "인증되지 않았거나 ADMIN 권한 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Embedding Job 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "409", + description = "FAILED 상태가 아니거나 재처리 대상 조건 불충족", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + @PostMapping(value = "/{jobId}/retry", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> retryJob( + @PathVariable @Positive Long jobId + ) { + return ResponseUtils.ok(embeddingJobRetryService.retryJob(jobId)); + } + + @Operation( + summary = "FAILED Job 전체 재처리", + description = "FAILED 상태인 모든 Embedding Job을 순차적으로 재처리합니다. " + + "개별 Job 재처리가 실패해도 나머지는 계속 진행하며, 성공한 건수만 반환합니다. " + + "FAILED 작업이 없으면 retriedCount 0으로 정상 응답합니다. " + + "1건 이상 성공하면 최신 대시보드 집계를 WebSocket으로 즉시 push합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "전체 재처리 요청 처리 완료" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "인증되지 않았거나 ADMIN 권한 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + @PostMapping(value = "/retry-all", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> retryAllJobs() { + return ResponseUtils.ok(embeddingJobRetryService.retryAllFailedJobs()); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/RetryAllJobsResponse.java b/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/RetryAllJobsResponse.java new file mode 100644 index 0000000..8c377f2 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/RetryAllJobsResponse.java @@ -0,0 +1,17 @@ +package com.opensource.docgrid.domain.dashboard.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * FAILED 작업 전체 재처리 요청의 결과 응답. + * + *

개별 Job 재처리 실패는 건너뛰고 성공한 건수만 집계하며, 실패 원인별 상세는 포함하지 않는다. + */ +public record RetryAllJobsResponse( + @Schema(description = "재처리에 성공한 Job 수", example = "27") + int retriedCount, + + @Schema(description = "결과 메시지", example = "27개 작업 재처리 요청이 완료되었습니다.") + String message +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryService.java b/src/main/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryService.java new file mode 100644 index 0000000..a3265c8 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryService.java @@ -0,0 +1,77 @@ +package com.opensource.docgrid.domain.dashboard.service.command; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.opensource.docgrid.domain.dashboard.controller.DashboardWebSocketController; +import com.opensource.docgrid.domain.dashboard.dto.response.RetryAllJobsResponse; +import com.opensource.docgrid.domain.dashboard.service.query.DashboardQueryService; +import com.opensource.docgrid.domain.embedding.dto.response.ManualRetriedIndexingJobResponse; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingJob; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingJobRepository; +import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobManualRetryService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 관리자 재처리 버튼 클릭을 A 담당자의 {@link EmbeddingJobManualRetryService}로 위임하고, + * 성공 시 최신 대시보드 집계를 WebSocket으로 push하는 Command Service. + * + *

{@code embedding_jobs}를 직접 update하지 않는다 — 상태 전환은 전부 A의 Service를 경유한다. + * FAILED 목록 조회만 {@link EmbeddingJobRepository}를 직접 읽는다(쓰기가 아니므로 A/B 경계 위반이 + * 아니다). + * + *

다른 Command Service와 달리 클래스 레벨 {@code @Transactional}을 의도적으로 두지 않는다. + * {@link #retryAllFailedJobs()}가 여러 Job에 대해 {@code retry()}를 순회 호출하는데, 이 메서드 + * 자체가 트랜잭션을 열면 각 호출이 그 트랜잭션에 참여(REQUIRED)하게 되어, 중간에 한 Job이 실패해 + * 예외가 전파되는 순간 스프링이 트랜잭션을 rollback-only로 표시한다. 그러면 이후 catch로 예외를 + * 잡아도 커밋 시점에 이전에 성공한 Job들까지 전부 롤백된다 — "개별 실패는 건너뛰고 성공 건수만 + * 집계한다"는 요구사항 자체가 깨진다. 이 클래스가 트랜잭션을 열지 않아야 {@code retry()} 호출마다 + * A Service 자신의 {@code @Transactional}이 매번 독립된 새 트랜잭션을 만들어 각자 커밋된다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class EmbeddingJobRetryService { + + private static final String RETRY_ALL_MESSAGE_FORMAT = "%d개 작업 재처리 요청이 완료되었습니다."; + + private final EmbeddingJobManualRetryService embeddingJobManualRetryService; + private final EmbeddingJobRepository embeddingJobRepository; + private final DashboardQueryService dashboardQueryService; + private final DashboardWebSocketController dashboardWebSocketController; + + public ManualRetriedIndexingJobResponse retryJob(Long jobId) { + // 1. 상태 전환은 A Service에 위임한다 — 여기서 예외가 나면(404/409) 그대로 전파시킨다. + ManualRetriedIndexingJobResponse response = embeddingJobManualRetryService.retry(jobId); + // 2. 재처리 성공 후에만 최신 집계를 다시 계산해서 push한다. + dashboardWebSocketController.sendDashboardUpdate(dashboardQueryService.getSummary()); + return response; + } + + public RetryAllJobsResponse retryAllFailedJobs() { + // 1. A의 REST 엔드포인트를 내부 호출하지 않고 Repository를 직접 읽는다(조회는 A/B 경계 밖). + List failedJobs = embeddingJobRepository.findAllByStatus(EmbeddingJobStatus.FAILED); + + // 2. 한 건씩 독립적으로 재처리한다. 개별 실패는 catch해서 건너뛰고 나머지를 계속 진행한다. + int retriedCount = 0; + for (EmbeddingJob failedJob : failedJobs) { + try { + embeddingJobManualRetryService.retry(failedJob.getId()); + retriedCount++; + } catch (Exception e) { + log.warn("전체 재처리 중 Job 건너뜀: jobId={}, reason={}", failedJob.getId(), e.getMessage()); + } + } + + // 3. 실제로 바뀐 게 있을 때만(1건 이상 성공) push한다 — 전부 실패하면 push할 변경사항이 없다. + if (retriedCount > 0) { + dashboardWebSocketController.sendDashboardUpdate(dashboardQueryService.getSummary()); + } + + return new RetryAllJobsResponse(retriedCount, RETRY_ALL_MESSAGE_FORMAT.formatted(retriedCount)); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryServiceTest.java b/src/test/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryServiceTest.java new file mode 100644 index 0000000..bdfb6f2 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryServiceTest.java @@ -0,0 +1,134 @@ +package com.opensource.docgrid.domain.dashboard.service.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.mock; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willReturn; +import static org.mockito.BDDMockito.willThrow; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import com.opensource.docgrid.domain.dashboard.controller.DashboardWebSocketController; +import com.opensource.docgrid.domain.dashboard.dto.response.DashboardSummaryResponse; +import com.opensource.docgrid.domain.dashboard.dto.response.RetryAllJobsResponse; +import com.opensource.docgrid.domain.dashboard.service.query.DashboardQueryService; +import com.opensource.docgrid.domain.embedding.dto.response.ManualRetriedIndexingJobResponse; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingJob; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingJobRepository; +import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobManualRetryService; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EmbeddingJobRetryService 단위 테스트") +class EmbeddingJobRetryServiceTest { + + @InjectMocks private EmbeddingJobRetryService embeddingJobRetryService; + + @Mock private EmbeddingJobManualRetryService embeddingJobManualRetryService; + @Mock private EmbeddingJobRepository embeddingJobRepository; + @Mock private DashboardQueryService dashboardQueryService; + @Mock private DashboardWebSocketController dashboardWebSocketController; + + @Test + @DisplayName("정상 케이스: 단건 재처리 성공 시 EmbeddingJobManualRetryService를 호출하고 최신 집계를 push한다") + void retryJob_delegatesToAService_andPushesLatestSummary() { + // Given + ManualRetriedIndexingJobResponse retryResponse = mock(ManualRetriedIndexingJobResponse.class); + DashboardSummaryResponse summary = mock(DashboardSummaryResponse.class); + given(embeddingJobManualRetryService.retry(42L)).willReturn(retryResponse); + given(dashboardQueryService.getSummary()).willReturn(summary); + + // When + ManualRetriedIndexingJobResponse result = embeddingJobRetryService.retryJob(42L); + + // Then + assertThat(result).isSameAs(retryResponse); + then(embeddingJobManualRetryService).should().retry(42L); + then(dashboardWebSocketController).should().sendDashboardUpdate(summary); + } + + @Test + @DisplayName("정상 케이스: 개별 Job 실패는 건너뛰고 성공 건수만 집계하며 1건 이상 성공하면 push한다") + void retryAllFailedJobs_countsOnlySuccesses_whenSomeJobsFail() { + // Given + EmbeddingJob succeedingJob1 = failedJobWithId(1L); + EmbeddingJob failingJob = failedJobWithId(2L); + EmbeddingJob succeedingJob2 = failedJobWithId(3L); + given(embeddingJobRepository.findAllByStatus(EmbeddingJobStatus.FAILED)) + .willReturn(List.of(succeedingJob1, failingJob, succeedingJob2)); + + willReturn(mock(ManualRetriedIndexingJobResponse.class)) + .given(embeddingJobManualRetryService).retry(1L); + willThrow(new DocGridException(ErrorCode.EMBEDDING_JOB_MANUAL_RETRY_TARGET_INVALID)) + .given(embeddingJobManualRetryService).retry(2L); + willReturn(mock(ManualRetriedIndexingJobResponse.class)) + .given(embeddingJobManualRetryService).retry(3L); + + DashboardSummaryResponse summary = mock(DashboardSummaryResponse.class); + given(dashboardQueryService.getSummary()).willReturn(summary); + + // When + RetryAllJobsResponse result = embeddingJobRetryService.retryAllFailedJobs(); + + // Then + assertThat(result.retriedCount()).isEqualTo(2); + assertThat(result.message()).isEqualTo("2개 작업 재처리 요청이 완료되었습니다."); + then(embeddingJobManualRetryService).should().retry(1L); + then(embeddingJobManualRetryService).should().retry(2L); + then(embeddingJobManualRetryService).should().retry(3L); + then(dashboardWebSocketController).should().sendDashboardUpdate(summary); + } + + @Test + @DisplayName("예외 케이스: FAILED 작업이 없으면 0건으로 정상 응답하고 push하지 않는다") + void retryAllFailedJobs_returnsZero_whenNoFailedJobs() { + // Given + given(embeddingJobRepository.findAllByStatus(EmbeddingJobStatus.FAILED)).willReturn(List.of()); + + // When + RetryAllJobsResponse result = embeddingJobRetryService.retryAllFailedJobs(); + + // Then + assertThat(result.retriedCount()).isZero(); + assertThat(result.message()).isEqualTo("0개 작업 재처리 요청이 완료되었습니다."); + then(dashboardWebSocketController).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("예외 케이스: 모든 Job 재처리가 실패하면 push하지 않는다") + void retryAllFailedJobs_doesNotPush_whenAllJobsFail() { + // Given + EmbeddingJob failingJob = failedJobWithId(1L); + given(embeddingJobRepository.findAllByStatus(EmbeddingJobStatus.FAILED)) + .willReturn(List.of(failingJob)); + willThrow(new DocGridException(ErrorCode.EMBEDDING_JOB_MANUAL_RETRY_TARGET_INVALID)) + .given(embeddingJobManualRetryService).retry(1L); + + // When + RetryAllJobsResponse result = embeddingJobRetryService.retryAllFailedJobs(); + + // Then + assertThat(result.retriedCount()).isZero(); + then(dashboardWebSocketController).shouldHaveNoInteractions(); + } + + private EmbeddingJob failedJobWithId(Long id) { + EmbeddingJob embeddingJob = EmbeddingJob.builder() + .status(EmbeddingJobStatus.FAILED) + .maxRetryCount(3) + .build(); + ReflectionTestUtils.setField(embeddingJob, "id", id); + return embeddingJob; + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryTransactionIsolationIntegrationTest.java b/src/test/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryTransactionIsolationIntegrationTest.java new file mode 100644 index 0000000..05a3ca4 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/dashboard/service/command/EmbeddingJobRetryTransactionIsolationIntegrationTest.java @@ -0,0 +1,158 @@ +package com.opensource.docgrid.domain.dashboard.service.command; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.UUID; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import com.opensource.docgrid.domain.dashboard.dto.response.RetryAllJobsResponse; + +/** + * 전체 재처리 중 한 Job이 실패해도 다른 Job의 성공한 재처리는 실제로 커밋되는지 실제 PostgreSQL로 + * 검증한다. + * + *

{@link EmbeddingJobRetryService}가 클래스 레벨 {@code @Transactional}을 의도적으로 두지 + * 않는 이유(트랜잭션 독립 커밋)는 Mockito 단위 테스트로는 증명할 수 없다 — Mock은 실제 스프링 + * 트랜잭션 프록시를 거치지 않기 때문이다. 이 테스트는 {@code EmbeddingJobManualRetryService}를 + * Mock 없이 그대로 사용해서 실제 Transaction 경계를 검증한다. + */ +@Tag("integration") +@ActiveProfiles("test") +@SpringBootTest +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@DisplayName("EmbeddingJobRetryService 트랜잭션 독립성 통합 테스트") +class EmbeddingJobRetryTransactionIsolationIntegrationTest { + + private static final String TEST_SCHEMA = "docgrid_embedding_job_retry_isolation_test"; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private EmbeddingJobRetryService embeddingJobRetryService; + + private Long embeddingModelId; + + @DynamicPropertySource + static void configureDatabase(DynamicPropertyRegistry registry) { + registry.add("TEST_DB_SCHEMA", () -> TEST_SCHEMA); + registry.add("jwt.secret", () -> "docgrid-embedding-job-retry-isolation-test-secret-key-2026"); + } + + @BeforeEach + void setUp() { + jdbcTemplate.execute(""" + TRUNCATE TABLE + embedding_jobs, + document_versions, + documents, + users + RESTART IDENTITY CASCADE + """); + + embeddingModelId = jdbcTemplate.queryForObject(""" + SELECT id + FROM embedding_models + WHERE is_active = TRUE AND is_searchable = TRUE + """, Long.class); + } + + @AfterAll + void dropSchema() { + jdbcTemplate.execute("DROP SCHEMA IF EXISTS " + TEST_SCHEMA + " CASCADE"); + } + + @Test + @DisplayName("전체 재처리 중 한 Job이 재처리 대상 검증에 실패해도 나머지 성공한 Job은 실제로 커밋된다") + void retryAllFailedJobs_commitsSucceedingJobsIndependently_whenOneJobFails() { + // Given — 정상 재처리 대상 2건과, 문서가 삭제돼 재처리 대상이 될 수 없는 1건을 섞어 둔다. + String suffix = UUID.randomUUID().toString(); + Long userId = insertUser(suffix); + + Long succeedingJobId1 = insertRetryableFailedJob(userId, "Retryable Document 1", false); + Long failingJobId = insertRetryableFailedJob(userId, "Deleted Document", true); + Long succeedingJobId2 = insertRetryableFailedJob(userId, "Retryable Document 2", false); + + // When + RetryAllJobsResponse result = embeddingJobRetryService.retryAllFailedJobs(); + + // Then — 실패한 1건과 무관하게 성공한 2건은 실제 DB에 PENDING으로 커밋돼 있어야 한다. + assertThat(result.retriedCount()).isEqualTo(2); + assertThat(statusOf(succeedingJobId1)).isEqualTo("PENDING"); + assertThat(statusOf(succeedingJobId2)).isEqualTo("PENDING"); + assertThat(statusOf(failingJobId)).isEqualTo("FAILED"); + } + + private String statusOf(Long jobId) { + return jdbcTemplate.queryForObject( + "SELECT status FROM embedding_jobs WHERE id = ?", String.class, jobId + ); + } + + private Long insertUser(String suffix) { + return jdbcTemplate.queryForObject(""" + INSERT INTO users (email, password_hash, name, status, created_at, updated_at) + VALUES (?, 'password-hash', 'Retry Isolation Test User', 'ACTIVE', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, "embedding-job-retry-isolation-" + suffix + "@example.com"); + } + + /** + * FAILED 상태 Job 하나와, 그 재처리 대상이 되는 FAILED 문서·버전을 만든다. + * + * @param deleted true면 문서를 soft-delete 상태로 만들어, + * {@code EmbeddingJobManualRetryService.validateRetryTarget()}이 + * {@code EMBEDDING_JOB_MANUAL_RETRY_TARGET_INVALID}를 던지도록 유도한다. + */ + private Long insertRetryableFailedJob(Long userId, String title, boolean deleted) { + Timestamp deletedAt = deleted ? Timestamp.valueOf(LocalDateTime.now()) : null; + + Long documentId = jdbcTemplate.queryForObject(""" + INSERT INTO documents ( + owner_user_id, title, document_type, source_type, status, visibility, deleted_at, + created_at, updated_at + ) + VALUES (?, ?, 'TXT', 'UPLOAD', 'FAILED', 'PRIVATE', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, userId, title, deletedAt); + + Long versionId = jdbcTemplate.queryForObject(""" + INSERT INTO document_versions ( + document_id, version_no, title_snapshot, content_type, status, + created_by, created_at, updated_at + ) + VALUES (?, 1, ?, 'text/plain', 'FAILED', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, documentId, title, userId); + + jdbcTemplate.update( + "UPDATE documents SET current_version_id = ? WHERE id = ?", versionId, documentId + ); + + return jdbcTemplate.queryForObject(""" + INSERT INTO embedding_jobs ( + document_version_id, embedding_model_id, status, priority, retry_count, + max_retry_count, created_at, updated_at + ) + VALUES (?, ?, 'FAILED', 0, 3, 3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, versionId, embeddingModelId); + } +}