From 848f7333102f5c115665cf200358890f45d981e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 8 Aug 2026 13:57:13 +0900 Subject: [PATCH 1/7] =?UTF-8?q?docs:=20#119=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=9D=B8=EB=8D=B1=EC=8B=B1=20=EC=A1=B0=ED=9A=8C=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...gimin-#119-admin-indexing-observability.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 docs/design/gimin-#119-admin-indexing-observability.md diff --git a/docs/design/gimin-#119-admin-indexing-observability.md b/docs/design/gimin-#119-admin-indexing-observability.md new file mode 100644 index 0000000..ec73d94 --- /dev/null +++ b/docs/design/gimin-#119-admin-indexing-observability.md @@ -0,0 +1,217 @@ +# Issue #119 관리자 인덱싱 Job·Attempt·Event 조회 상세 설계 + +closes #119 + +## 1. 배경과 목적 + +현재 관리자 API는 Worker 목록 조회와 인덱싱 Job의 Claim·Lease·Attempt 시작·파싱·임베딩·완료·실패· +수동 재처리 같은 제어 명령을 제공한다. 그러나 운영자가 Job의 현재 상태와 과거 Attempt, 상태 전이 +Event를 조회할 API는 없어 장애 원인과 재처리 대상을 DB에서 직접 확인해야 한다. + +이번 작업은 기존 상태 전이와 Worker 실행 경로를 변경하지 않고, 관리자에게 필요한 읽기 전용 관측 +API를 추가한다. 기존 `/admin/workers`는 그대로 재사용하며 Job 목록·상세, Attempt 이력, Event +타임라인만 새로 제공한다. + +### 1.1 성공 기준 + +- Job 목록을 상태·문서·현재 소유 Worker 조건으로 필터링한다. +- 모든 다건 응답은 최대 100건의 Pagination을 적용한다. +- Job 상세에서 현재 Queue·Retry·Lease·종결 상태를 확인할 수 있다. +- Attempt와 Event는 각각 독립된 Pagination으로 조회한다. +- Claim Token, 내부 오류 메시지와 Event Metadata를 응답하지 않는다. +- 모든 API는 기존 `/admin/**` ADMIN 권한 정책을 따른다. +- 조회 API가 기존 인덱싱 상태나 감사 이력을 변경하지 않는다. + +## 2. 범위 + +### 2.1 포함 + +- 관리자 Job 목록 조회 +- 관리자 Job 상세 조회 +- Job별 Attempt 이력 조회 +- Job별 Indexing Event 타임라인 조회 +- 공통 페이지 응답 계약 +- Query 전용 Repository·Converter·Service +- 단위·Controller·PostgreSQL 통합 테스트 + +### 2.2 제외 + +- Worker 목록 API 재구현 +- Job 상태 변경 또는 일괄 수동 재처리 +- Claim·Retry·Lease 정책 변경 +- 로그 수집, Metrics, 알림과 Dashboard UI +- Event Metadata 원문 공개 +- Flyway Migration과 DB Index 변경 + +## 3. API 계약 + +### 3.1 Job 목록 + +```text +GET /admin/indexing-jobs + ?status=PENDING + &documentId=1 + &workerId=2 + &page=0 + &size=20 +``` + +- 모든 필터는 선택이다. +- `page` 기본값은 0, `size` 기본값은 20이다. +- `page >= 0`, `1 <= size <= 100`을 검증한다. +- 정렬은 `created_at DESC, id DESC`로 고정한다. +- Worker 필터는 `locked_by_worker_id`인 현재 소유 Worker를 의미한다. + +목록 항목은 다음 정보를 포함한다. + +- Job ID, 상태, 우선순위, Retry Count, Max Retry Count, Next Retry At +- Document ID와 제목 +- Version ID와 Version 번호·상태 +- Embedding Model ID와 이름 +- 현재 Worker ID와 이름 +- 공개 가능한 Error Code +- 생성·시작·완료·실패 시각 + +### 3.2 Job 상세 + +```text +GET /admin/indexing-jobs/{jobId} +``` + +목록 정보에 `locked_at`, `lock_expires_at`을 추가한다. Claim Token과 Error Message는 반환하지 않는다. + +### 3.3 Attempt 이력 + +```text +GET /admin/indexing-jobs/{jobId}/attempts?page=0&size=20 +``` + +- 정렬은 `attempt_no DESC, id DESC`로 고정한다. +- Attempt ID·번호·상태, Worker ID·이름, 시작·종료·소요 시간, Error Code를 반환한다. +- Claim Token과 Error Message는 반환하지 않는다. +- Attempt가 없어도 Job이 존재하면 빈 Page를 반환한다. + +### 3.4 Event 타임라인 + +```text +GET /admin/indexing-jobs/{jobId}/events?page=0&size=20 +``` + +- 정렬은 `occurred_at DESC, id DESC`로 고정한다. +- Event ID·Type, From/To 상태, 고정 메시지, 발생 시각을 반환한다. +- `metadata_json`은 Worker·Attempt 식별자 외에 실패 진단 Snapshot을 포함할 수 있으므로 공개하지 않는다. +- Event가 없어도 Job이 존재하면 빈 Page를 반환한다. + +## 4. 공통 Pagination 응답 + +```json +{ + "content": [], + "page": 0, + "size": 20, + "totalElements": 0, + "totalPages": 0, + "first": true, + "last": true +} +``` + +Spring Data `Page`를 Controller에서 직접 직렬화하지 않는다. `PageResponse`가 응답 구조를 고정하고 +Entity 대신 이미 변환된 DTO만 보유한다. + +## 5. 조회 구조 + +### 5.1 Job 목록 + +`EmbeddingJobRepository`에 관리자 조회 전용 JPQL과 Count Query를 추가한다. DocumentVersion, +Document, EmbeddingModel, 현재 Worker를 Fetch Join해 Page 한 건당 추가 Lazy Query가 발생하지 않게 한다. +필터는 Null이면 생략한다. + +### 5.2 Job 상세 + +Job ID로 같은 연관관계를 Fetch Join한다. 없으면 기존 `EMBEDDING_JOB_NOT_FOUND` 오류를 반환한다. + +### 5.3 Attempt와 Event + +두 Repository는 Job ID 조건과 고정 정렬을 가진 Page Query를 제공한다. Attempt의 Worker는 Fetch +Join하고, Event는 Job 존재를 Query Service에서 먼저 확인한 뒤 이력만 조회한다. + +### 5.4 Query Service + +`IndexingJobAdminQueryService`는 클래스 수준 `@Transactional(readOnly = true)`를 사용한다. + +1. Controller가 검증한 필터와 Pagination 값을 받는다. +2. Repository에서 Entity Page 또는 상세 Snapshot을 조회한다. +3. `IndexingJobAdminConverter`로 공개 DTO를 생성한다. +4. `PageResponse`로 Pagination Metadata를 고정한다. + +## 6. 공개 정보 경계 + +| 저장 필드 | 응답 | 이유 | +|---|---|---| +| Job `claim_token` | 제외 | 현재 소유권 증명 값 | +| Job `error_message` | 제외 | 내부 Provider·Storage 진단 포함 가능 | +| Attempt `claim_token` | 제외 | 과거 소유권 증명 값 | +| Attempt `error_message` | 제외 | 내부 예외 Snapshot 포함 가능 | +| Event `metadata_json` | 제외 | Worker·Attempt와 실패 진단 Metadata 원문 | +| Job·Attempt `error_code` | 포함 | 운영 분류에 필요한 제한된 코드 | +| Event `message` | 포함 | Command 계층에서 생성하는 고정된 안전 메시지 | + +응답 DTO에는 제외 필드 자체를 정의하지 않아 Jackson 설정 변경이나 실수로 노출될 가능성을 줄인다. + +## 7. 오류 계약 + +| 상황 | HTTP | 코드 | +|---|---:|---| +| Job 없음 | 404 | `EMBEDDING-JOB-001` | +| `jobId`, `documentId`, `workerId`가 양수가 아님 | 400 | `COMMON-002` | +| `page < 0` | 400 | `COMMON-002` | +| `size < 1` 또는 `size > 100` | 400 | `COMMON-002` | +| 지원하지 않는 Job 상태 | 400 | 기존 Enum 변환 오류 처리 | +| 미인증 또는 ADMIN 아님 | 403 | 기존 Security 정책 | + +`SecurityConfig`의 `/admin/** -> hasRole("ADMIN")` 규칙을 재사용하므로 Security 설정은 변경하지 않는다. + +## 8. 테스트 설계 + +### 8.1 단위 테스트 + +- Job 목록 필터와 Page 값이 Repository로 전달되는지 검증 +- Job 상세 DTO 변환과 Not Found 검증 +- Attempt·Event 빈 Page와 데이터 Page 변환 검증 +- 민감 필드가 Response Record 구성요소에 존재하지 않는지 검증 + +### 8.2 Controller 테스트 + +- 네 API의 정상 응답 구조 +- 상태·문서·Worker 필터 전달 +- Page·Size·양수 ID Validation +- ADMIN 성공, USER·미인증 403 +- 직렬화 JSON에 Claim Token, Error Message, Metadata JSON이 없는지 검증 + +### 8.3 PostgreSQL 통합 테스트 + +- 상태·문서·Worker 필터 조합 +- `created_at DESC, id DESC` 고정 정렬과 목록 Pagination +- Attempt `attempt_no DESC, id DESC` 정렬 +- Event `occurred_at DESC, id DESC` 정렬 +- 종료 Job처럼 현재 Worker가 Null인 항목 조회 + +## 9. 커밋 분할 + +1. `docs: #119 관리자 인덱싱 조회 설계 문서 추가` +2. `feat: #119 관리자 조회 페이지 응답 계약 추가` +3. `feat: #119 Job·Attempt·Event 조회 쿼리 추가` +4. `feat: #119 관리자 인덱싱 조회 Service 구현` +5. `feat: #119 관리자 인덱싱 조회 API 추가` +6. `test: #119 관리자 인덱싱 조회 단위·Controller 테스트 추가` +7. `test: #119 관리자 인덱싱 조회 PostgreSQL 통합 검증 추가` + +## 10. 완료 조건 + +- Job 목록·상세와 Attempt·Event Page 조회 가능 +- 상태·문서·Worker 필터 및 고정 정렬 동작 +- 민감한 소유권·내부 오류·Metadata 미노출 +- 기존 Worker 조회와 인덱싱 Command API 회귀 없음 +- 관리자 권한과 입력 검증 통과 +- PostgreSQL 통합 테스트와 전체 회귀 테스트 통과 From f65e56ba238e2d13c25e186d2a2bd1d99adb52a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 8 Aug 2026 13:58:44 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20#119=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EA=B3=84=EC=95=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/AdminIndexingEventResponse.java | 33 +++++++ .../AdminIndexingJobAttemptResponse.java | 42 +++++++++ .../response/AdminIndexingJobResponse.java | 86 +++++++++++++++++++ .../global/common/response/PageResponse.java | 52 +++++++++++ 4 files changed, 213 insertions(+) create mode 100644 src/main/java/com/opensource/docgrid/domain/embedding/dto/response/AdminIndexingEventResponse.java create mode 100644 src/main/java/com/opensource/docgrid/domain/embedding/dto/response/AdminIndexingJobAttemptResponse.java create mode 100644 src/main/java/com/opensource/docgrid/domain/embedding/dto/response/AdminIndexingJobResponse.java create mode 100644 src/main/java/com/opensource/docgrid/global/common/response/PageResponse.java diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/AdminIndexingEventResponse.java b/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/AdminIndexingEventResponse.java new file mode 100644 index 0000000..23078af --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/AdminIndexingEventResponse.java @@ -0,0 +1,33 @@ +package com.opensource.docgrid.domain.embedding.dto.response; + +import java.time.LocalDateTime; + +import com.opensource.docgrid.domain.worker.enums.IndexingEventType; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 관리자가 확인할 수 있는 인덱싱 상태 전이 Event 응답이다. + * + *

고정된 공개 메시지와 상태 전이만 제공하며 내부 진단 Snapshot인 Metadata JSON은 제외한다. + */ +public record AdminIndexingEventResponse( + @Schema(description = "Event 식별자", example = "31") + Long eventId, + + @Schema(description = "Event 유형", example = "RETRY") + IndexingEventType eventType, + + @Schema(description = "전이 전 상태") + String fromStatus, + + @Schema(description = "전이 후 상태") + String toStatus, + + @Schema(description = "운영자에게 공개 가능한 Event 메시지") + String message, + + @Schema(description = "Event 발생 시각") + LocalDateTime occurredAt +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/AdminIndexingJobAttemptResponse.java b/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/AdminIndexingJobAttemptResponse.java new file mode 100644 index 0000000..936fac7 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/AdminIndexingJobAttemptResponse.java @@ -0,0 +1,42 @@ +package com.opensource.docgrid.domain.embedding.dto.response; + +import java.time.LocalDateTime; + +import com.opensource.docgrid.domain.worker.enums.AttemptStatus; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 관리자가 확인할 수 있는 Embedding Job Attempt 실행 이력 응답이다. + * + *

실행 Worker와 종료 결과를 제공하되 과거 Claim Token과 내부 오류 메시지는 포함하지 않는다. + */ +public record AdminIndexingJobAttemptResponse( + @Schema(description = "Attempt 식별자", example = "21") + Long attemptId, + + @Schema(description = "Job 내부 Attempt 번호", example = "2") + int attemptNo, + + @Schema(description = "Attempt 상태", example = "FAILED") + AttemptStatus status, + + @Schema(description = "Attempt를 실행한 Worker 식별자") + Long workerId, + + @Schema(description = "Attempt를 실행한 Worker 이름") + String workerName, + + @Schema(description = "Attempt 시작 시각") + LocalDateTime startedAt, + + @Schema(description = "Attempt 종료 시각") + LocalDateTime endedAt, + + @Schema(description = "Attempt 소요 시간(ms)", example = "1250") + Long durationMs, + + @Schema(description = "공개 가능한 오류 코드") + String errorCode +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/AdminIndexingJobResponse.java b/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/AdminIndexingJobResponse.java new file mode 100644 index 0000000..3f1e6c7 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/AdminIndexingJobResponse.java @@ -0,0 +1,86 @@ +package com.opensource.docgrid.domain.embedding.dto.response; + +import java.time.LocalDateTime; + +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 관리자 조회에 필요한 Embedding Job의 현재 상태 Snapshot 응답이다. + * + *

문서·버전·모델·현재 Worker 관계와 운영 시각을 제공하되 Claim Token과 내부 오류 메시지는 + * 소유권 및 진단 정보 보호를 위해 포함하지 않는다. + */ +public record AdminIndexingJobResponse( + @Schema(description = "Embedding Job 식별자", example = "10") + Long jobId, + + @Schema(description = "현재 Job 상태", example = "PROCESSING") + EmbeddingJobStatus status, + + @Schema(description = "Queue 우선순위", example = "0") + int priority, + + @Schema(description = "현재 자동 재시도 횟수", example = "1") + int retryCount, + + @Schema(description = "최대 자동 재시도 횟수", example = "3") + int maxRetryCount, + + @Schema(description = "다음 자동 재시도 가능 시각") + LocalDateTime nextRetryAt, + + @Schema(description = "문서 식별자", example = "3") + Long documentId, + + @Schema(description = "문서 제목", example = "운영 가이드") + String documentTitle, + + @Schema(description = "문서 버전 식별자", example = "5") + Long documentVersionId, + + @Schema(description = "문서 버전 번호", example = "2") + int documentVersionNo, + + @Schema(description = "문서 버전 상태", example = "EMBEDDING") + DocumentVersionStatus documentVersionStatus, + + @Schema(description = "Embedding Model 식별자", example = "1") + Long embeddingModelId, + + @Schema(description = "Embedding Model 이름", example = "BAAI/bge-m3") + String embeddingModelName, + + @Schema(description = "Embedding Model 버전", example = "1") + String embeddingModelVersion, + + @Schema(description = "현재 소유 Worker 식별자") + Long workerId, + + @Schema(description = "현재 소유 Worker 이름") + String workerName, + + @Schema(description = "공개 가능한 최근 오류 코드") + String errorCode, + + @Schema(description = "현재 Claim 잠금 시각") + LocalDateTime lockedAt, + + @Schema(description = "현재 Claim Lease 만료 시각") + LocalDateTime lockExpiresAt, + + @Schema(description = "Job 생성 시각") + LocalDateTime createdAt, + + @Schema(description = "최초 처리 시작 시각") + LocalDateTime startedAt, + + @Schema(description = "처리 완료 시각") + LocalDateTime completedAt, + + @Schema(description = "최종 실패 시각") + LocalDateTime failedAt +) { +} diff --git a/src/main/java/com/opensource/docgrid/global/common/response/PageResponse.java b/src/main/java/com/opensource/docgrid/global/common/response/PageResponse.java new file mode 100644 index 0000000..95e88ce --- /dev/null +++ b/src/main/java/com/opensource/docgrid/global/common/response/PageResponse.java @@ -0,0 +1,52 @@ +package com.opensource.docgrid.global.common.response; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Spring Data의 내부 Page 직렬화 형식에 의존하지 않는 공통 페이지 응답 계약이다. + * + *

이미 DTO로 변환된 Content와 최소 Pagination Metadata만 외부에 노출하며 Entity나 정렬 구현 + * 세부사항은 포함하지 않는다. + */ +public record PageResponse( + @Schema(description = "현재 페이지 데이터") + List content, + + @Schema(description = "0부터 시작하는 현재 페이지 번호", example = "0") + int page, + + @Schema(description = "요청한 페이지 크기", example = "20") + int size, + + @Schema(description = "전체 데이터 수", example = "42") + long totalElements, + + @Schema(description = "전체 페이지 수", example = "3") + int totalPages, + + @Schema(description = "첫 페이지 여부", example = "true") + boolean first, + + @Schema(description = "마지막 페이지 여부", example = "false") + boolean last +) { + + /** + * Entity Page의 Pagination Metadata와 변환 완료된 Content를 고정 응답으로 결합한다. + */ + public static PageResponse from(Page page, List content) { + return new PageResponse<>( + List.copyOf(content), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages(), + page.isFirst(), + page.isLast() + ); + } +} From c08bc6e801c914919972437784a67f5572851e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 8 Aug 2026 13:59:50 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20#119=20Job=C2=B7Attempt=C2=B7Event?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/EmbeddingJobRepository.java | 52 +++++++++++++++++++ .../EmbeddingJobAttemptRepository.java | 25 +++++++++ .../repository/IndexingEventRepository.java | 7 +++ 3 files changed, 84 insertions(+) diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingJobRepository.java b/src/main/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingJobRepository.java index 8136ed8..129a0ef 100644 --- a/src/main/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingJobRepository.java +++ b/src/main/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingJobRepository.java @@ -7,6 +7,8 @@ import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; @@ -23,6 +25,56 @@ */ public interface EmbeddingJobRepository extends JpaRepository { + /** + * 관리자 목록 화면에 필요한 연관관계를 함께 조회하면서 선택 필터와 Pagination을 적용한다. + * + *

정렬은 호출자가 createdAt, id 역순으로 고정한다. 모든 Join은 To-One 관계이므로 Page Content의 + * 행 수를 늘리지 않고 Converter의 Lazy 추가 조회를 방지한다. + */ + @Query( + value = """ + SELECT job + FROM EmbeddingJob job + JOIN FETCH job.documentVersion version + JOIN FETCH version.document document + JOIN FETCH job.embeddingModel model + LEFT JOIN FETCH job.lockedByWorker worker + WHERE (:status IS NULL OR job.status = :status) + AND (:documentId IS NULL OR document.id = :documentId) + AND (:workerId IS NULL OR worker.id = :workerId) + """, + countQuery = """ + SELECT COUNT(job) + FROM EmbeddingJob job + JOIN job.documentVersion version + JOIN version.document document + LEFT JOIN job.lockedByWorker worker + WHERE (:status IS NULL OR job.status = :status) + AND (:documentId IS NULL OR document.id = :documentId) + AND (:workerId IS NULL OR worker.id = :workerId) + """ + ) + Page findAdminJobs( + @Param("status") EmbeddingJobStatus status, + @Param("documentId") Long documentId, + @Param("workerId") Long workerId, + Pageable pageable + ); + + /** + * 관리자 상세 응답에 필요한 Job과 To-One 연관관계를 한 Query로 조회한다. + */ + @Query(""" + SELECT job + FROM EmbeddingJob job + JOIN FETCH job.documentVersion version + JOIN FETCH version.document document + JOIN FETCH job.embeddingModel model + LEFT JOIN FETCH job.lockedByWorker worker + WHERE job.id = :jobId + """) + Optional findAdminDetailById(@Param("jobId") Long jobId); + /** * 같은 Version에 동시에 살아 있는 Job이 하나뿐인지 완료 직전에 확인한다. */ diff --git a/src/main/java/com/opensource/docgrid/domain/worker/repository/EmbeddingJobAttemptRepository.java b/src/main/java/com/opensource/docgrid/domain/worker/repository/EmbeddingJobAttemptRepository.java index b437704..8e6a03e 100644 --- a/src/main/java/com/opensource/docgrid/domain/worker/repository/EmbeddingJobAttemptRepository.java +++ b/src/main/java/com/opensource/docgrid/domain/worker/repository/EmbeddingJobAttemptRepository.java @@ -2,7 +2,11 @@ import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.opensource.docgrid.domain.worker.entity.EmbeddingJobAttempt; @@ -14,6 +18,27 @@ */ public interface EmbeddingJobAttemptRepository extends JpaRepository { + /** + * 지정 Job의 Attempt를 Worker와 함께 조회하고 호출자가 지정한 고정 정렬·Pagination을 적용한다. + */ + @Query( + value = """ + SELECT attempt + FROM EmbeddingJobAttempt attempt + LEFT JOIN FETCH attempt.workerNode worker + WHERE attempt.embeddingJob.id = :jobId + """, + countQuery = """ + SELECT COUNT(attempt) + FROM EmbeddingJobAttempt attempt + WHERE attempt.embeddingJob.id = :jobId + """ + ) + Page findAdminAttemptsByJobId( + @Param("jobId") Long jobId, + Pageable pageable + ); + Optional findByEmbeddingJobIdAndClaimToken(Long embeddingJobId, String claimToken); Optional findTopByEmbeddingJobIdOrderByAttemptNoDesc(Long embeddingJobId); diff --git a/src/main/java/com/opensource/docgrid/domain/worker/repository/IndexingEventRepository.java b/src/main/java/com/opensource/docgrid/domain/worker/repository/IndexingEventRepository.java index 46ba1c2..dbfc6ce 100644 --- a/src/main/java/com/opensource/docgrid/domain/worker/repository/IndexingEventRepository.java +++ b/src/main/java/com/opensource/docgrid/domain/worker/repository/IndexingEventRepository.java @@ -1,5 +1,7 @@ package com.opensource.docgrid.domain.worker.repository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import com.opensource.docgrid.domain.worker.entity.IndexingEvent; @@ -13,5 +15,10 @@ */ public interface IndexingEventRepository extends JpaRepository { + /** + * 지정 Job의 append-only Event를 호출자가 지정한 고정 정렬·Pagination으로 조회한다. + */ + Page findAllByEmbeddingJobId(Long embeddingJobId, Pageable pageable); + long countByEmbeddingJobIdAndEventType(Long embeddingJobId, IndexingEventType eventType); } From 9ff37fbf95ee38e19b609d232a656d0cc1b088ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 8 Aug 2026 14:01:41 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20#119=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=9D=B8=EB=8D=B1=EC=8B=B1=20=EC=A1=B0=ED=9A=8C=20Service?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../converter/IndexingJobAdminConverter.java | 81 ++++++++++++ .../query/IndexingJobAdminQueryService.java | 120 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 src/main/java/com/opensource/docgrid/domain/embedding/converter/IndexingJobAdminConverter.java create mode 100644 src/main/java/com/opensource/docgrid/domain/embedding/service/query/IndexingJobAdminQueryService.java diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/converter/IndexingJobAdminConverter.java b/src/main/java/com/opensource/docgrid/domain/embedding/converter/IndexingJobAdminConverter.java new file mode 100644 index 0000000..36f33da --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/converter/IndexingJobAdminConverter.java @@ -0,0 +1,81 @@ +package com.opensource.docgrid.domain.embedding.converter; + +import org.springframework.stereotype.Component; + +import com.opensource.docgrid.domain.document.entity.DocumentVersion; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingEventResponse; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingJobAttemptResponse; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingJobResponse; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingJob; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.worker.entity.EmbeddingJobAttempt; +import com.opensource.docgrid.domain.worker.entity.IndexingEvent; +import com.opensource.docgrid.domain.worker.entity.WorkerNode; + +/** + * 인덱싱 Job 관리자 조회 Entity를 민감 정보가 제거된 공개 Response DTO로 변환한다. + * + *

Claim Token, 내부 Error Message와 Event Metadata는 이 경계에서 읽지 않아 Controller 응답으로 + * 전달될 수 없게 한다. + */ +@Component +public class IndexingJobAdminConverter { + + public AdminIndexingJobResponse toJobResponse(EmbeddingJob job) { + DocumentVersion version = job.getDocumentVersion(); + EmbeddingModel model = job.getEmbeddingModel(); + WorkerNode worker = job.getLockedByWorker(); + + return new AdminIndexingJobResponse( + job.getId(), + job.getStatus(), + job.getPriority(), + job.getRetryCount(), + job.getMaxRetryCount(), + job.getNextRetryAt(), + version.getDocument().getId(), + version.getDocument().getTitle(), + version.getId(), + version.getVersionNo(), + version.getStatus(), + model.getId(), + model.getModelName(), + model.getModelVersion(), + worker == null ? null : worker.getId(), + worker == null ? null : worker.getWorkerName(), + job.getErrorCode(), + job.getLockedAt(), + job.getLockExpiresAt(), + job.getCreatedAt(), + job.getStartedAt(), + job.getCompletedAt(), + job.getFailedAt() + ); + } + + public AdminIndexingJobAttemptResponse toAttemptResponse(EmbeddingJobAttempt attempt) { + WorkerNode worker = attempt.getWorkerNode(); + return new AdminIndexingJobAttemptResponse( + attempt.getId(), + attempt.getAttemptNo(), + attempt.getStatus(), + worker == null ? null : worker.getId(), + worker == null ? null : worker.getWorkerName(), + attempt.getStartedAt(), + attempt.getEndedAt(), + attempt.getDurationMs(), + attempt.getErrorCode() + ); + } + + public AdminIndexingEventResponse toEventResponse(IndexingEvent event) { + return new AdminIndexingEventResponse( + event.getId(), + event.getEventType(), + event.getFromStatus(), + event.getToStatus(), + event.getMessage(), + event.getOccurredAt() + ); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/service/query/IndexingJobAdminQueryService.java b/src/main/java/com/opensource/docgrid/domain/embedding/service/query/IndexingJobAdminQueryService.java new file mode 100644 index 0000000..127205a --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/service/query/IndexingJobAdminQueryService.java @@ -0,0 +1,120 @@ +package com.opensource.docgrid.domain.embedding.service.query; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.embedding.converter.IndexingJobAdminConverter; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingEventResponse; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingJobAttemptResponse; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingJobResponse; +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.worker.entity.EmbeddingJobAttempt; +import com.opensource.docgrid.domain.worker.entity.IndexingEvent; +import com.opensource.docgrid.domain.worker.repository.EmbeddingJobAttemptRepository; +import com.opensource.docgrid.domain.worker.repository.IndexingEventRepository; +import com.opensource.docgrid.global.common.response.PageResponse; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; + +/** + * 관리자의 인덱싱 Job 목록·상세와 Attempt·Event 이력을 읽기 전용으로 조회한다. + * + *

필터와 고정 정렬을 Repository에 전달하고, Entity가 Transaction 밖으로 나가기 전에 민감 정보가 + * 제외된 DTO와 안정된 Pagination 응답으로 변환한다. 인덱싱 상태 변경은 담당하지 않는다. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class IndexingJobAdminQueryService { + + private static final Sort JOB_SORT = Sort.by( + Sort.Order.desc("createdAt"), + Sort.Order.desc("id") + ); + private static final Sort ATTEMPT_SORT = Sort.by( + Sort.Order.desc("attemptNo"), + Sort.Order.desc("id") + ); + private static final Sort EVENT_SORT = Sort.by( + Sort.Order.desc("occurredAt"), + Sort.Order.desc("id") + ); + + private final EmbeddingJobRepository embeddingJobRepository; + private final EmbeddingJobAttemptRepository embeddingJobAttemptRepository; + private final IndexingEventRepository indexingEventRepository; + private final IndexingJobAdminConverter indexingJobAdminConverter; + + public PageResponse getJobs( + EmbeddingJobStatus status, + Long documentId, + Long workerId, + int page, + int size + ) { + // 1. 외부 Sort 입력을 받지 않고 운영 Queue의 고정 정렬로 Page를 조회한다. + Page jobs = embeddingJobRepository.findAdminJobs( + status, + documentId, + workerId, + PageRequest.of(page, size, JOB_SORT) + ); + + // 2. Transaction 안에서 모든 연관관계를 공개 DTO로 변환해 Entity 노출과 Lazy 조회를 막는다. + List content = jobs.getContent().stream() + .map(indexingJobAdminConverter::toJobResponse) + .toList(); + return PageResponse.from(jobs, content); + } + + public AdminIndexingJobResponse getJob(Long jobId) { + EmbeddingJob job = embeddingJobRepository.findAdminDetailById(jobId) + .orElseThrow(() -> new DocGridException(ErrorCode.EMBEDDING_JOB_NOT_FOUND)); + return indexingJobAdminConverter.toJobResponse(job); + } + + public PageResponse getAttempts(Long jobId, int page, int size) { + // 1. 이력이 비어 있어도 Job 없음과 정상 빈 Page를 구분한다. + validateJobExists(jobId); + Page attempts = embeddingJobAttemptRepository.findAdminAttemptsByJobId( + jobId, + PageRequest.of(page, size, ATTEMPT_SORT) + ); + + // 2. 과거 Claim Token과 내부 오류 메시지를 읽지 않는 공개 DTO만 반환한다. + List content = attempts.getContent().stream() + .map(indexingJobAdminConverter::toAttemptResponse) + .toList(); + return PageResponse.from(attempts, content); + } + + public PageResponse getEvents(Long jobId, int page, int size) { + // 1. Event가 없는 유효 Job과 존재하지 않는 Job을 명확히 구분한다. + validateJobExists(jobId); + Page events = indexingEventRepository.findAllByEmbeddingJobId( + jobId, + PageRequest.of(page, size, EVENT_SORT) + ); + + // 2. 내부 Metadata JSON을 제외한 상태 전이 Snapshot만 공개 응답으로 변환한다. + List content = events.getContent().stream() + .map(indexingJobAdminConverter::toEventResponse) + .toList(); + return PageResponse.from(events, content); + } + + private void validateJobExists(Long jobId) { + if (!embeddingJobRepository.existsById(jobId)) { + throw new DocGridException(ErrorCode.EMBEDDING_JOB_NOT_FOUND); + } + } +} From cb26d3900e2dd46aa594f30efde35edfa3b8498f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 8 Aug 2026 14:02:57 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20#119=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=9D=B8=EB=8D=B1=EC=8B=B1=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IndexingJobAdminController.java | 157 +++++++++++++++++- 1 file changed, 154 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminController.java b/src/main/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminController.java index 8e4ce36..2a6e395 100644 --- a/src/main/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminController.java +++ b/src/main/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminController.java @@ -5,6 +5,7 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -18,6 +19,9 @@ import com.opensource.docgrid.domain.embedding.dto.request.FailDocumentIndexingRequest; import com.opensource.docgrid.domain.embedding.dto.request.RenewEmbeddingJobLeaseRequest; import com.opensource.docgrid.domain.embedding.dto.request.StartEmbeddingJobAttemptRequest; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingEventResponse; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingJobAttemptResponse; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingJobResponse; import com.opensource.docgrid.domain.embedding.dto.response.ClaimedEmbeddingJobResponse; import com.opensource.docgrid.domain.embedding.dto.response.DocumentChunksResponse; import com.opensource.docgrid.domain.embedding.dto.response.DocumentEmbeddingsResponse; @@ -37,8 +41,11 @@ import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobClaimService; import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobLeaseService; import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobManualRetryService; +import com.opensource.docgrid.domain.embedding.service.query.IndexingJobAdminQueryService; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; import com.opensource.docgrid.global.common.response.ApiResponse; import com.opensource.docgrid.global.common.response.ErrorResponse; +import com.opensource.docgrid.global.common.response.PageResponse; import com.opensource.docgrid.global.common.response.ResponseUtils; import io.swagger.v3.oas.annotations.Operation; @@ -47,17 +54,19 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; import lombok.RequiredArgsConstructor; /** - * 관리자용 Embedding Job Claim·Lease 갱신, Attempt 시작과 문서 Chunk·Embedding·인덱싱 완료·실패 - * 실행 및 최종 실패 Job 수동 재처리를 HTTP API로 제공한다. + * 관리자용 Embedding Job 목록·상세·Attempt·Event 조회와 Claim·Lease 갱신, Attempt 시작 및 문서 + * Chunk·Embedding·인덱싱 완료·실패 실행과 최종 실패 Job 수동 재처리를 HTTP API로 제공한다. * *

HTTP 입력 검증과 성공 상태 변환만 담당한다. Job Claim 및 현재 소유권 기반 파이프라인 단계와 * 수동 재처리의 Transaction·외부 호출·동시성 규칙은 각 Service에 위임한다. */ -@Tag(name = "Admin - Indexing Job", description = "관리자 전용 인덱싱 Job 제어 API") +@Tag(name = "Admin - Indexing Job", description = "관리자 전용 인덱싱 Job 조회·제어 API") @Validated @RestController @RequestMapping("/admin/indexing-jobs") @@ -72,6 +81,148 @@ public class IndexingJobAdminController { private final DocumentIndexingCompletionService documentIndexingCompletionService; private final DocumentIndexingFailureService documentIndexingFailureService; private final EmbeddingJobManualRetryService embeddingJobManualRetryService; + private final IndexingJobAdminQueryService indexingJobAdminQueryService; + + @Operation( + summary = "인덱싱 Job 목록 조회", + description = "상태, 문서, 현재 소유 Worker 조건으로 인덱싱 Job을 필터링하고 최신 생성 순으로 " + + "페이지 조회합니다. Claim Token과 내부 오류 메시지는 반환하지 않습니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "인덱싱 Job 목록 조회 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "필터 또는 페이지 입력 형식 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "인증되지 않았거나 ADMIN 권한 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity>> getJobs( + @RequestParam(required = false) EmbeddingJobStatus status, + @RequestParam(required = false) @Positive Long documentId, + @RequestParam(required = false) @Positive Long workerId, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ResponseUtils.ok(indexingJobAdminQueryService.getJobs( + status, + documentId, + workerId, + page, + size + )); + } + + @Operation( + summary = "인덱싱 Job 상세 조회", + description = "지정한 Job의 문서·버전·모델·현재 Worker와 Retry·Lease·종결 상태를 조회합니다. " + + "Claim Token과 내부 오류 메시지는 반환하지 않습니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "인덱싱 Job 상세 조회 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Job ID 형식 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @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)) + ) + }) + @GetMapping(value = "/{jobId}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getJob( + @PathVariable @Positive Long jobId + ) { + return ResponseUtils.ok(indexingJobAdminQueryService.getJob(jobId)); + } + + @Operation( + summary = "인덱싱 Job Attempt 이력 조회", + description = "지정한 Job의 실행 Attempt를 최근 시도 순으로 페이지 조회합니다. 과거 Claim Token과 " + + "내부 오류 메시지는 반환하지 않습니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Attempt 이력 조회 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Job ID 또는 페이지 입력 형식 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @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)) + ) + }) + @GetMapping(value = "/{jobId}/attempts", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity>> getAttempts( + @PathVariable @Positive Long jobId, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ResponseUtils.ok(indexingJobAdminQueryService.getAttempts(jobId, page, size)); + } + + @Operation( + summary = "인덱싱 Job Event 타임라인 조회", + description = "지정한 Job의 상태 전이 Event를 최근 발생 순으로 페이지 조회합니다. 내부 Metadata " + + "JSON은 반환하지 않습니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Event 타임라인 조회 성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Job ID 또는 페이지 입력 형식 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @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)) + ) + }) + @GetMapping(value = "/{jobId}/events", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity>> getEvents( + @PathVariable @Positive Long jobId, + @RequestParam(defaultValue = "0") @Min(0) int page, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size + ) { + return ResponseUtils.ok(indexingJobAdminQueryService.getEvents(jobId, page, size)); + } @Operation( summary = "PENDING Job Claim", From 847e1023736e16dab2df3672b018288530b914cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 8 Aug 2026 14:06:04 +0900 Subject: [PATCH 6/7] =?UTF-8?q?test:=20#119=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=9D=B8=EB=8D=B1=EC=8B=B1=20=EC=A1=B0=ED=9A=8C=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=C2=B7Controller=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../IndexingJobAdminQueryControllerTest.java | 232 ++++++++++++++++++ .../IndexingJobAdminConverterTest.java | 131 ++++++++++ .../IndexingJobAdminQueryServiceTest.java | 166 +++++++++++++ 3 files changed, 529 insertions(+) create mode 100644 src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminQueryControllerTest.java create mode 100644 src/test/java/com/opensource/docgrid/domain/embedding/converter/IndexingJobAdminConverterTest.java create mode 100644 src/test/java/com/opensource/docgrid/domain/embedding/service/query/IndexingJobAdminQueryServiceTest.java diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminQueryControllerTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminQueryControllerTest.java new file mode 100644 index 0000000..e89a362 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminQueryControllerTest.java @@ -0,0 +1,232 @@ +package com.opensource.docgrid.domain.embedding.controller; + +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.cors.CorsConfigurationSource; + +import com.opensource.docgrid.domain.auth.jwt.JwtProvider; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.document.service.DocumentParsingService; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingEventResponse; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingJobAttemptResponse; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingJobResponse; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.embedding.service.DocumentEmbeddingService; +import com.opensource.docgrid.domain.embedding.service.command.DocumentIndexingCompletionService; +import com.opensource.docgrid.domain.embedding.service.command.DocumentIndexingFailureService; +import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobAttemptService; +import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobClaimService; +import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobLeaseService; +import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobManualRetryService; +import com.opensource.docgrid.domain.embedding.service.query.IndexingJobAdminQueryService; +import com.opensource.docgrid.domain.mcp.service.command.McpAccessTokenCommandService; +import com.opensource.docgrid.domain.worker.enums.AttemptStatus; +import com.opensource.docgrid.domain.worker.enums.IndexingEventType; +import com.opensource.docgrid.global.common.response.PageResponse; +import com.opensource.docgrid.global.config.SecurityConfig; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +/** + * 관리자 인덱싱 Job 조회 API의 Pagination, 민감 정보 비노출, Validation과 Security 계약을 검증한다. + */ +@WebMvcTest(IndexingJobAdminController.class) +@Import(SecurityConfig.class) +@DisplayName("IndexingJobAdminController 조회 테스트") +class IndexingJobAdminQueryControllerTest { + + private static final String JOBS_URL = "/admin/indexing-jobs"; + private static final String JOB_URL = "/admin/indexing-jobs/10"; + private static final String ATTEMPTS_URL = "/admin/indexing-jobs/10/attempts"; + private static final String EVENTS_URL = "/admin/indexing-jobs/10/events"; + + @Autowired private MockMvc mockMvc; + + @MockitoBean private IndexingJobAdminQueryService indexingJobAdminQueryService; + @MockitoBean private EmbeddingJobClaimService embeddingJobClaimService; + @MockitoBean private EmbeddingJobLeaseService embeddingJobLeaseService; + @MockitoBean private EmbeddingJobAttemptService embeddingJobAttemptService; + @MockitoBean private DocumentParsingService documentParsingService; + @MockitoBean private DocumentEmbeddingService documentEmbeddingService; + @MockitoBean private DocumentIndexingCompletionService documentIndexingCompletionService; + @MockitoBean private DocumentIndexingFailureService documentIndexingFailureService; + @MockitoBean private EmbeddingJobManualRetryService embeddingJobManualRetryService; + @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; + @MockitoBean private JwtProvider jwtProvider; + @MockitoBean private McpAccessTokenCommandService mcpAccessTokenCommandService; + @MockitoBean private CorsConfigurationSource corsConfigurationSource; + + @Test + @DisplayName("ADMIN 사용자가 필터링한 Job 목록을 페이지 조회한다") + void getJobs_returnsFilteredPage_withoutSensitiveFields() throws Exception { + given(indexingJobAdminQueryService.getJobs(EmbeddingJobStatus.FAILED, 3L, 7L, 0, 20)) + .willReturn(new PageResponse<>(List.of(createJobResponse()), 0, 20, 1, 1, true, true)); + + mockMvc.perform(get(JOBS_URL) + .param("status", "FAILED") + .param("documentId", "3") + .param("workerId", "7") + .with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].jobId").value(10)) + .andExpect(jsonPath("$.data.content[0].status").value("FAILED")) + .andExpect(jsonPath("$.data.totalElements").value(1)) + .andExpect(jsonPath("$.data.content[0].claimToken").doesNotExist()) + .andExpect(jsonPath("$.data.content[0].errorMessage").doesNotExist()); + } + + @Test + @DisplayName("ADMIN 사용자가 Job 상세를 조회한다") + void getJob_returnsSafeDetail() throws Exception { + given(indexingJobAdminQueryService.getJob(10L)).willReturn(createJobResponse()); + + mockMvc.perform(get(JOB_URL).with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.documentId").value(3)) + .andExpect(jsonPath("$.data.embeddingModelName").value("BAAI/bge-m3")) + .andExpect(jsonPath("$.data.claimToken").doesNotExist()) + .andExpect(jsonPath("$.data.errorMessage").doesNotExist()); + } + + @Test + @DisplayName("Attempt 이력은 Claim Token과 내부 오류 메시지를 노출하지 않는다") + void getAttempts_returnsSafeHistory() throws Exception { + AdminIndexingJobAttemptResponse attempt = new AdminIndexingJobAttemptResponse( + 21L, + 2, + AttemptStatus.FAILED, + 7L, + "indexing-worker", + LocalDateTime.of(2026, 8, 8, 10, 0), + LocalDateTime.of(2026, 8, 8, 10, 1), + 60_000L, + "EMBEDDING-PROVIDER-001" + ); + given(indexingJobAdminQueryService.getAttempts(10L, 0, 20)) + .willReturn(new PageResponse<>(List.of(attempt), 0, 20, 1, 1, true, true)); + + mockMvc.perform(get(ATTEMPTS_URL).with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].attemptNo").value(2)) + .andExpect(jsonPath("$.data.content[0].claimToken").doesNotExist()) + .andExpect(jsonPath("$.data.content[0].errorMessage").doesNotExist()); + } + + @Test + @DisplayName("Event 타임라인은 내부 Metadata JSON을 노출하지 않는다") + void getEvents_returnsSafeTimeline() throws Exception { + AdminIndexingEventResponse event = new AdminIndexingEventResponse( + 31L, + IndexingEventType.RETRY, + "PROCESSING", + "PENDING", + "인덱싱 Job 자동 재시도를 예약했습니다.", + LocalDateTime.of(2026, 8, 8, 10, 1) + ); + given(indexingJobAdminQueryService.getEvents(10L, 0, 20)) + .willReturn(new PageResponse<>(List.of(event), 0, 20, 1, 1, true, true)); + + mockMvc.perform(get(EVENTS_URL).with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].eventType").value("RETRY")) + .andExpect(jsonPath("$.data.content[0].metadataJson").doesNotExist()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidQueryRequests") + @DisplayName("조회 입력이 유효하지 않으면 400을 반환한다") + void getQueries_returnBadRequest_whenInputIsInvalid(String description, String url) throws Exception { + mockMvc.perform(get(url).with(user("admin").roles("ADMIN"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON-002")); + } + + @Test + @DisplayName("존재하지 않는 Job 상세는 404를 반환한다") + void getJob_returnsNotFound_whenJobDoesNotExist() throws Exception { + given(indexingJobAdminQueryService.getJob(10L)) + .willThrow(new DocGridException(ErrorCode.EMBEDDING_JOB_NOT_FOUND)); + + mockMvc.perform(get(JOB_URL).with(user("admin").roles("ADMIN"))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("EMBEDDING-JOB-001")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("queryUrls") + @DisplayName("ADMIN이 아닌 사용자는 관리자 조회 API에 접근할 수 없다") + void getQueries_returnForbidden_withoutAdminRole(String description, String url) throws Exception { + mockMvc.perform(get(url).with(user("user").roles("USER"))) + .andExpect(status().isForbidden()); + mockMvc.perform(get(url)).andExpect(status().isForbidden()); + } + + private static Stream invalidQueryRequests() { + return Stream.of( + Arguments.of("문서 ID가 0", JOBS_URL + "?documentId=0"), + Arguments.of("Worker ID가 음수", JOBS_URL + "?workerId=-1"), + Arguments.of("Page가 음수", JOBS_URL + "?page=-1"), + Arguments.of("Size가 0", JOBS_URL + "?size=0"), + Arguments.of("Size가 100 초과", JOBS_URL + "?size=101"), + Arguments.of("Job ID가 0", "/admin/indexing-jobs/0"), + Arguments.of("Attempt Page가 음수", ATTEMPTS_URL + "?page=-1"), + Arguments.of("Event Size가 100 초과", EVENTS_URL + "?size=101") + ); + } + + private static Stream queryUrls() { + return Stream.of( + Arguments.of("목록", JOBS_URL), + Arguments.of("상세", JOB_URL), + Arguments.of("Attempt", ATTEMPTS_URL), + Arguments.of("Event", EVENTS_URL) + ); + } + + private AdminIndexingJobResponse createJobResponse() { + return new AdminIndexingJobResponse( + 10L, + EmbeddingJobStatus.FAILED, + 0, + 3, + 3, + null, + 3L, + "운영 가이드", + 5L, + 2, + DocumentVersionStatus.FAILED, + 1L, + "BAAI/bge-m3", + "1", + 7L, + "indexing-worker", + "EMBEDDING-PROVIDER-001", + LocalDateTime.of(2026, 8, 8, 10, 0), + LocalDateTime.of(2026, 8, 8, 10, 5), + LocalDateTime.of(2026, 8, 8, 9, 59), + LocalDateTime.of(2026, 8, 8, 10, 0), + null, + LocalDateTime.of(2026, 8, 8, 10, 1) + ); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/converter/IndexingJobAdminConverterTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/converter/IndexingJobAdminConverterTest.java new file mode 100644 index 0000000..ed52103 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/converter/IndexingJobAdminConverterTest.java @@ -0,0 +1,131 @@ +package com.opensource.docgrid.domain.embedding.converter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDateTime; +import java.util.Arrays; + +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 com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.document.entity.DocumentVersion; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingEventResponse; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingJobAttemptResponse; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingJobResponse; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingJob; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.worker.entity.EmbeddingJobAttempt; +import com.opensource.docgrid.domain.worker.entity.IndexingEvent; +import com.opensource.docgrid.domain.worker.entity.WorkerNode; +import com.opensource.docgrid.domain.worker.enums.AttemptStatus; +import com.opensource.docgrid.domain.worker.enums.IndexingEventType; + +@ExtendWith(MockitoExtension.class) +@DisplayName("IndexingJobAdminConverter 테스트") +class IndexingJobAdminConverterTest { + + @Mock private EmbeddingJob job; + @Mock private DocumentVersion version; + @Mock private Document document; + @Mock private EmbeddingModel model; + @Mock private WorkerNode worker; + @Mock private EmbeddingJobAttempt attempt; + @Mock private IndexingEvent event; + + @InjectMocks private IndexingJobAdminConverter converter; + + @Test + @DisplayName("Job과 연관 Snapshot을 민감 정보 없는 관리자 응답으로 변환한다") + void toJobResponse_convertsSafeSnapshot() { + LocalDateTime createdAt = LocalDateTime.of(2026, 8, 8, 10, 0); + given(job.getId()).willReturn(10L); + given(job.getStatus()).willReturn(EmbeddingJobStatus.PROCESSING); + given(job.getDocumentVersion()).willReturn(version); + given(job.getEmbeddingModel()).willReturn(model); + given(job.getLockedByWorker()).willReturn(worker); + given(job.getCreatedAt()).willReturn(createdAt); + given(version.getDocument()).willReturn(document); + given(version.getId()).willReturn(5L); + given(version.getVersionNo()).willReturn(2); + given(version.getStatus()).willReturn(DocumentVersionStatus.EMBEDDING); + given(document.getId()).willReturn(3L); + given(document.getTitle()).willReturn("운영 가이드"); + given(model.getId()).willReturn(1L); + given(model.getModelName()).willReturn("BAAI/bge-m3"); + given(model.getModelVersion()).willReturn("1"); + given(worker.getId()).willReturn(7L); + given(worker.getWorkerName()).willReturn("indexing-worker"); + + AdminIndexingJobResponse response = converter.toJobResponse(job); + + assertThat(response.jobId()).isEqualTo(10L); + assertThat(response.documentId()).isEqualTo(3L); + assertThat(response.documentVersionStatus()).isEqualTo(DocumentVersionStatus.EMBEDDING); + assertThat(response.embeddingModelName()).isEqualTo("BAAI/bge-m3"); + assertThat(response.workerId()).isEqualTo(7L); + assertThat(response.createdAt()).isEqualTo(createdAt); + } + + @Test + @DisplayName("종료 Job의 현재 Worker가 없으면 Worker 필드를 null로 변환한다") + void toJobResponse_keepsWorkerFieldsNull_whenOwnershipIsReleased() { + given(job.getDocumentVersion()).willReturn(version); + given(job.getEmbeddingModel()).willReturn(model); + given(job.getLockedByWorker()).willReturn(null); + given(version.getDocument()).willReturn(document); + + AdminIndexingJobResponse response = converter.toJobResponse(job); + + assertThat(response.workerId()).isNull(); + assertThat(response.workerName()).isNull(); + } + + @Test + @DisplayName("Attempt와 Event를 공개 가능한 이력으로 변환한다") + void toHistoryResponses_convertsSafeFields() { + LocalDateTime occurredAt = LocalDateTime.of(2026, 8, 8, 10, 5); + given(attempt.getId()).willReturn(21L); + given(attempt.getAttemptNo()).willReturn(2); + given(attempt.getStatus()).willReturn(AttemptStatus.FAILED); + given(attempt.getWorkerNode()).willReturn(worker); + given(worker.getId()).willReturn(7L); + given(worker.getWorkerName()).willReturn("indexing-worker"); + given(event.getId()).willReturn(31L); + given(event.getEventType()).willReturn(IndexingEventType.RETRY); + given(event.getMessage()).willReturn("인덱싱 Job 자동 재시도를 예약했습니다."); + given(event.getOccurredAt()).willReturn(occurredAt); + + AdminIndexingJobAttemptResponse attemptResponse = converter.toAttemptResponse(attempt); + AdminIndexingEventResponse eventResponse = converter.toEventResponse(event); + + assertThat(attemptResponse.attemptId()).isEqualTo(21L); + assertThat(attemptResponse.workerId()).isEqualTo(7L); + assertThat(eventResponse.eventId()).isEqualTo(31L); + assertThat(eventResponse.occurredAt()).isEqualTo(occurredAt); + } + + @Test + @DisplayName("관리자 응답 계약에는 소유권과 내부 진단 필드가 존재하지 않는다") + void responseContracts_doNotDeclareSensitiveFields() { + assertThat(componentNames(AdminIndexingJobResponse.class)) + .doesNotContain("claimToken", "errorMessage"); + assertThat(componentNames(AdminIndexingJobAttemptResponse.class)) + .doesNotContain("claimToken", "errorMessage"); + assertThat(componentNames(AdminIndexingEventResponse.class)) + .doesNotContain("metadataJson"); + } + + private static String[] componentNames(Class recordType) { + return Arrays.stream(recordType.getRecordComponents()) + .map(component -> component.getName()) + .toArray(String[]::new); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/service/query/IndexingJobAdminQueryServiceTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/service/query/IndexingJobAdminQueryServiceTest.java new file mode 100644 index 0000000..8728f24 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/service/query/IndexingJobAdminQueryServiceTest.java @@ -0,0 +1,166 @@ +package com.opensource.docgrid.domain.embedding.service.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import com.opensource.docgrid.domain.embedding.converter.IndexingJobAdminConverter; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingEventResponse; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingJobAttemptResponse; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingJobResponse; +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.worker.entity.EmbeddingJobAttempt; +import com.opensource.docgrid.domain.worker.entity.IndexingEvent; +import com.opensource.docgrid.domain.worker.repository.EmbeddingJobAttemptRepository; +import com.opensource.docgrid.domain.worker.repository.IndexingEventRepository; +import com.opensource.docgrid.global.common.response.PageResponse; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +@ExtendWith(MockitoExtension.class) +@DisplayName("IndexingJobAdminQueryService 테스트") +class IndexingJobAdminQueryServiceTest { + + private static final Long JOB_ID = 10L; + + @Mock private EmbeddingJobRepository embeddingJobRepository; + @Mock private EmbeddingJobAttemptRepository embeddingJobAttemptRepository; + @Mock private IndexingEventRepository indexingEventRepository; + @Mock private IndexingJobAdminConverter indexingJobAdminConverter; + + @InjectMocks private IndexingJobAdminQueryService indexingJobAdminQueryService; + + @Test + @DisplayName("Job 목록 필터와 고정 최신순 Pagination을 Repository에 전달한다") + void getJobs_passesFiltersAndFixedSort() { + EmbeddingJob job = org.mockito.Mockito.mock(EmbeddingJob.class); + AdminIndexingJobResponse response = org.mockito.Mockito.mock(AdminIndexingJobResponse.class); + given(embeddingJobRepository.findAdminJobs( + org.mockito.ArgumentMatchers.eq(EmbeddingJobStatus.FAILED), + org.mockito.ArgumentMatchers.eq(3L), + org.mockito.ArgumentMatchers.eq(7L), + org.mockito.ArgumentMatchers.any(Pageable.class) + )).willReturn(new PageImpl<>(List.of(job), PageRequest.of(1, 5), 6)); + given(indexingJobAdminConverter.toJobResponse(job)).willReturn(response); + + PageResponse result = indexingJobAdminQueryService.getJobs( + EmbeddingJobStatus.FAILED, + 3L, + 7L, + 1, + 5 + ); + + assertThat(result.content()).containsExactly(response); + assertThat(result.page()).isEqualTo(1); + assertThat(result.totalElements()).isEqualTo(6); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + then(embeddingJobRepository).should().findAdminJobs( + org.mockito.ArgumentMatchers.eq(EmbeddingJobStatus.FAILED), + org.mockito.ArgumentMatchers.eq(3L), + org.mockito.ArgumentMatchers.eq(7L), + pageableCaptor.capture() + ); + assertThat(pageableCaptor.getValue().getSort().getOrderFor("createdAt").isDescending()).isTrue(); + assertThat(pageableCaptor.getValue().getSort().getOrderFor("id").isDescending()).isTrue(); + } + + @Test + @DisplayName("Job 상세를 공개 응답으로 변환한다") + void getJob_convertsAdminDetail() { + EmbeddingJob job = org.mockito.Mockito.mock(EmbeddingJob.class); + AdminIndexingJobResponse expected = org.mockito.Mockito.mock(AdminIndexingJobResponse.class); + given(embeddingJobRepository.findAdminDetailById(JOB_ID)).willReturn(Optional.of(job)); + given(indexingJobAdminConverter.toJobResponse(job)).willReturn(expected); + + assertThat(indexingJobAdminQueryService.getJob(JOB_ID)).isSameAs(expected); + } + + @Test + @DisplayName("존재하지 않는 Job 상세는 404 오류로 변환한다") + void getJob_throws_whenJobDoesNotExist() { + given(embeddingJobRepository.findAdminDetailById(JOB_ID)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> indexingJobAdminQueryService.getJob(JOB_ID)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.EMBEDDING_JOB_NOT_FOUND); + } + + @Test + @DisplayName("Attempt를 최근 번호 순으로 페이지 변환한다") + void getAttempts_returnsConvertedPage() { + EmbeddingJobAttempt attempt = org.mockito.Mockito.mock(EmbeddingJobAttempt.class); + AdminIndexingJobAttemptResponse response = org.mockito.Mockito.mock( + AdminIndexingJobAttemptResponse.class + ); + given(embeddingJobRepository.existsById(JOB_ID)).willReturn(true); + given(embeddingJobAttemptRepository.findAdminAttemptsByJobId( + org.mockito.ArgumentMatchers.eq(JOB_ID), + org.mockito.ArgumentMatchers.any(Pageable.class) + )).willReturn(new PageImpl<>(List.of(attempt), PageRequest.of(0, 20), 1)); + given(indexingJobAdminConverter.toAttemptResponse(attempt)).willReturn(response); + + PageResponse result = + indexingJobAdminQueryService.getAttempts(JOB_ID, 0, 20); + + assertThat(result.content()).containsExactly(response); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + then(embeddingJobAttemptRepository).should().findAdminAttemptsByJobId( + org.mockito.ArgumentMatchers.eq(JOB_ID), + pageableCaptor.capture() + ); + assertThat(pageableCaptor.getValue().getSort().getOrderFor("attemptNo").isDescending()).isTrue(); + } + + @Test + @DisplayName("Event를 최근 발생 순으로 페이지 변환한다") + void getEvents_returnsConvertedPage() { + IndexingEvent event = org.mockito.Mockito.mock(IndexingEvent.class); + AdminIndexingEventResponse response = org.mockito.Mockito.mock(AdminIndexingEventResponse.class); + given(embeddingJobRepository.existsById(JOB_ID)).willReturn(true); + given(indexingEventRepository.findAllByEmbeddingJobId( + org.mockito.ArgumentMatchers.eq(JOB_ID), + org.mockito.ArgumentMatchers.any(Pageable.class) + )).willReturn(new PageImpl<>(List.of(event), PageRequest.of(0, 20), 1)); + given(indexingJobAdminConverter.toEventResponse(event)).willReturn(response); + + PageResponse result = + indexingJobAdminQueryService.getEvents(JOB_ID, 0, 20); + + assertThat(result.content()).containsExactly(response); + ArgumentCaptor pageableCaptor = ArgumentCaptor.forClass(Pageable.class); + then(indexingEventRepository).should().findAllByEmbeddingJobId( + org.mockito.ArgumentMatchers.eq(JOB_ID), + pageableCaptor.capture() + ); + assertThat(pageableCaptor.getValue().getSort().getOrderFor("occurredAt").isDescending()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 Job의 이력은 Repository 조회 전에 거부한다") + void getAttempts_throwsBeforeHistoryQuery_whenJobDoesNotExist() { + given(embeddingJobRepository.existsById(JOB_ID)).willReturn(false); + + assertThatThrownBy(() -> indexingJobAdminQueryService.getAttempts(JOB_ID, 0, 20)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.EMBEDDING_JOB_NOT_FOUND); + then(embeddingJobAttemptRepository).shouldHaveNoInteractions(); + } +} From 829aefceacce2bf954fef7bb0fb5d9d2dee8ac94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EB=AF=BC?= Date: Sat, 8 Aug 2026 14:15:43 +0900 Subject: [PATCH 7/7] =?UTF-8?q?test:=20#119=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=9D=B8=EB=8D=B1=EC=8B=B1=20=EC=A1=B0=ED=9A=8C=20PostgreSQ?= =?UTF-8?q?L=20=ED=86=B5=ED=95=A9=20=EA=B2=80=EC=A6=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...gimin-#119-admin-indexing-observability.md | 84 ++++++ .../IndexingJobAdminControllerTest.java | 2 + .../IndexingJobAdminQueryIntegrationTest.java | 279 ++++++++++++++++++ 3 files changed, 365 insertions(+) create mode 100644 docs/test-results/gimin-#119-admin-indexing-observability.md create mode 100644 src/test/java/com/opensource/docgrid/domain/embedding/integration/IndexingJobAdminQueryIntegrationTest.java diff --git a/docs/test-results/gimin-#119-admin-indexing-observability.md b/docs/test-results/gimin-#119-admin-indexing-observability.md new file mode 100644 index 0000000..79fb1a6 --- /dev/null +++ b/docs/test-results/gimin-#119-admin-indexing-observability.md @@ -0,0 +1,84 @@ +# #119 관리자 인덱싱 Job·Attempt·Event 조회 검증 결과 + +## 1. 검증 정보 + +- 실행일: 2026-08-08 (Asia/Seoul) +- 대상 브랜치: `feature/119` +- 애플리케이션: Spring Boot 3.5.16, Java 17 +- 데이터베이스: 로컬 PostgreSQL 17.8 + pgvector 0.8.1 컨테이너 +- 검증 범위: 관리자 조회 Service·Converter·Controller·PostgreSQL Query와 전체 회귀 Test +- 최종 결과: 706개 통과, 실패·오류·Skip 0개 + +각 PostgreSQL 통합 Test는 독립 Schema와 전체 Flyway Migration을 사용한다. DB 접속 정보와 인증 값은 +실행 Process의 Test 전용 환경변수로만 주입했으며 실제 운영 값은 사용하거나 기록하지 않았다. + +## 2. 전체 회귀 검증 + +실행 명령의 값은 공개 가능한 Test 전용 Placeholder로 대체한다. + +```bash +DB_SSLMODE=disable \ +JWT_SECRET='' \ +MINIO_ENDPOINT='' \ +MINIO_ACCESS_KEY='' \ +MINIO_SECRET_KEY='' \ +MINIO_BUCKET='' \ +./gradlew test +``` + +결과: + +```text +BUILD SUCCESSFUL +tests=706 failures=0 errors=0 skipped=0 +``` + +기존 Command API Controller Test도 새 Query Service 의존성을 Mock으로 보강해 전체 Controller Context가 +정상 기동하는지 함께 검증했다. + +## 3. 단위 검증 + +| Test Class | Test 수 | 검증 범위 | 결과 | +|---|---:|---|---| +| `IndexingJobAdminQueryServiceTest` | 6 | 필터·Pagination 전달, 상세 Not Found, Attempt·Event 조회 | 통과 | +| `IndexingJobAdminConverterTest` | 4 | Job·Attempt·Event 공개 DTO 변환, 민감 필드 계약 제외 | 통과 | +| `IndexingJobAdminQueryControllerTest` | 17 | 네 API, Validation, ADMIN 권한, 오류·JSON 응답 | 통과 | + +## 4. PostgreSQL 통합 검증 + +실행: + +```bash +DB_SSLMODE=disable \ +./gradlew test \ + --tests 'com.opensource.docgrid.domain.embedding.integration.IndexingJobAdminQueryIntegrationTest' +``` + +결과: + +```text +tests=5 failures=0 errors=0 skipped=0 +``` + +| 시나리오 | 확인 항목 | 결과 | +|---|---|---| +| Job 복합 필터 | 상태·문서·현재 소유 Worker 조건이 같은 한 건으로 수렴 | 통과 | +| Job 목록 정렬·Page | `created_at DESC, id DESC`, Page 경계와 전체 건수 보존 | 통과 | +| Attempt 이력 | `attempt_no DESC, id DESC`, 내부 Claim Token·오류 메시지 미노출 | 통과 | +| Event 타임라인 | `occurred_at DESC, id DESC`, Metadata JSON 미노출 | 통과 | +| 종료 Job 상세 | Worker와 Lease가 없는 종료 상태를 Null로 안전하게 반환 | 통과 | + +## 5. 보안·읽기 전용 경계 + +- `/admin/**`의 기존 `ADMIN` 권한 정책을 그대로 적용했다. +- Job·Attempt의 Claim Token과 내부 Error Message를 응답 DTO에 정의하지 않았다. +- Event의 `metadata_json`을 응답 DTO에 정의하지 않았다. +- Query Service는 `@Transactional(readOnly = true)`로만 동작한다. +- 조회 테스트 전후에 Job·Attempt·Event 상태 전이가 발생하지 않는다. + +## 6. 결론 + +- 관리자는 Job 목록·상세와 Attempt·Event 이력을 고정된 Pagination 계약으로 조회할 수 있다. +- 상태·문서·현재 Worker 필터와 재현 가능한 역순 정렬이 실제 PostgreSQL에서 동작한다. +- 운영에 필요한 제한된 Error Code는 제공하면서 소유권 증명 값과 내부 진단 원문은 노출하지 않는다. +- 기존 Worker 조회 및 인덱싱 Command API를 포함한 전체 706개 Test가 실패 없이 통과한다. diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminControllerTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminControllerTest.java index 17e23a1..85cc066 100644 --- a/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminControllerTest.java +++ b/src/test/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminControllerTest.java @@ -50,6 +50,7 @@ import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobClaimService; import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobLeaseService; import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobManualRetryService; +import com.opensource.docgrid.domain.embedding.service.query.IndexingJobAdminQueryService; import com.opensource.docgrid.domain.worker.enums.AttemptStatus; import com.opensource.docgrid.global.config.SecurityConfig; import com.opensource.docgrid.global.exception.DocGridException; @@ -110,6 +111,7 @@ class IndexingJobAdminControllerTest { @MockitoBean private DocumentIndexingCompletionService documentIndexingCompletionService; @MockitoBean private DocumentIndexingFailureService documentIndexingFailureService; @MockitoBean private EmbeddingJobManualRetryService embeddingJobManualRetryService; + @MockitoBean private IndexingJobAdminQueryService indexingJobAdminQueryService; @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; @MockitoBean private JwtProvider jwtProvider; @MockitoBean private McpAccessTokenCommandService mcpAccessTokenCommandService; diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/integration/IndexingJobAdminQueryIntegrationTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/integration/IndexingJobAdminQueryIntegrationTest.java new file mode 100644 index 0000000..ae89412 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/integration/IndexingJobAdminQueryIntegrationTest.java @@ -0,0 +1,279 @@ +package com.opensource.docgrid.domain.embedding.integration; + +import static org.assertj.core.api.Assertions.assertThat; + +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.embedding.dto.response.AdminIndexingEventResponse; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingJobAttemptResponse; +import com.opensource.docgrid.domain.embedding.dto.response.AdminIndexingJobResponse; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.embedding.service.query.IndexingJobAdminQueryService; +import com.opensource.docgrid.global.common.response.PageResponse; + +/** + * 실제 PostgreSQL에서 관리자 Job 필터·고정 정렬과 Attempt·Event Pagination Query를 검증한다. + * + *

격리 Schema에 소유권과 내부 오류·Metadata가 포함된 실행 이력을 구성한 뒤 공개 Query Service가 + * 올바른 행 순서와 안전한 DTO만 반환하는지 확인한다. + */ +@Tag("integration") +@ActiveProfiles("test") +@SpringBootTest +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@DisplayName("관리자 인덱싱 조회 PostgreSQL 통합 테스트") +class IndexingJobAdminQueryIntegrationTest { + + private static final String TEST_SCHEMA = "docgrid_admin_indexing_query_integration_test"; + private static final LocalDateTime BASE_TIME = LocalDateTime.of(2026, 8, 8, 10, 0); + + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private IndexingJobAdminQueryService indexingJobAdminQueryService; + + @DynamicPropertySource + static void configureDatabase(DynamicPropertyRegistry registry) { + registry.add("TEST_DB_SCHEMA", () -> TEST_SCHEMA); + // Spring Context 기동에만 사용하는 공개 가능한 Test 전용 값이며 운영 인증 값이 아니다. + registry.add("jwt.secret", () -> "docgrid-admin-query-integration-test-secret-key-2026"); + } + + @BeforeEach + void resetState() { + jdbcTemplate.execute(""" + TRUNCATE TABLE + indexing_events, + embedding_job_attempts, + embedding_jobs, + document_versions, + documents, + worker_nodes, + users + RESTART IDENTITY CASCADE + """); + } + + @AfterAll + void dropSchema() { + jdbcTemplate.execute("DROP SCHEMA IF EXISTS " + TEST_SCHEMA + " CASCADE"); + } + + @Test + @DisplayName("상태·문서·현재 Worker 필터가 같은 PROCESSING Job 한 건으로 수렴한다") + void getJobs_filtersByStatusDocumentAndWorker() { + Long workerId = insertWorker("filter-worker"); + JobContext matched = insertJob("필터 대상", EmbeddingJobStatus.PROCESSING, workerId, BASE_TIME); + insertJob("다른 상태", EmbeddingJobStatus.PENDING, null, BASE_TIME.plusMinutes(1)); + insertJob("다른 Worker", EmbeddingJobStatus.PROCESSING, insertWorker("other-worker"), + BASE_TIME.plusMinutes(2)); + + PageResponse result = indexingJobAdminQueryService.getJobs( + EmbeddingJobStatus.PROCESSING, + matched.documentId(), + workerId, + 0, + 20 + ); + + assertThat(result.totalElements()).isOne(); + assertThat(result.content()).singleElement().satisfies(job -> { + assertThat(job.jobId()).isEqualTo(matched.jobId()); + assertThat(job.documentId()).isEqualTo(matched.documentId()); + assertThat(job.workerId()).isEqualTo(workerId); + assertThat(job.errorCode()).isEqualTo("TEST-ERROR"); + }); + } + + @Test + @DisplayName("Job 목록은 생성 시각과 ID 역순으로 고정되고 Page 경계를 보존한다") + void getJobs_ordersNewestFirstAndPaginates() { + JobContext oldest = insertJob("가장 오래된 Job", EmbeddingJobStatus.PENDING, null, BASE_TIME); + JobContext middle = insertJob("중간 Job", EmbeddingJobStatus.PENDING, null, BASE_TIME.plusMinutes(1)); + JobContext newest = insertJob("최신 Job", EmbeddingJobStatus.PENDING, null, BASE_TIME.plusMinutes(2)); + + PageResponse first = indexingJobAdminQueryService.getJobs( + null, null, null, 0, 2 + ); + PageResponse second = indexingJobAdminQueryService.getJobs( + null, null, null, 1, 2 + ); + + assertThat(first.content()).extracting(AdminIndexingJobResponse::jobId) + .containsExactly(newest.jobId(), middle.jobId()); + assertThat(first.totalElements()).isEqualTo(3); + assertThat(first.totalPages()).isEqualTo(2); + assertThat(first.first()).isTrue(); + assertThat(first.last()).isFalse(); + assertThat(second.content()).extracting(AdminIndexingJobResponse::jobId) + .containsExactly(oldest.jobId()); + assertThat(second.last()).isTrue(); + } + + @Test + @DisplayName("Attempt는 번호 역순으로 조회하고 과거 소유권·오류 메시지는 응답 계약에 없다") + void getAttempts_ordersByAttemptNumberAndHidesInternalFields() { + Long workerId = insertWorker("attempt-worker"); + JobContext context = insertJob("Attempt 이력", EmbeddingJobStatus.FAILED, null, BASE_TIME); + insertAttempt(context.jobId(), workerId, 1, "FAILED", BASE_TIME, BASE_TIME.plusSeconds(10)); + insertAttempt(context.jobId(), workerId, 2, "SUCCESS", BASE_TIME.plusMinutes(1), + BASE_TIME.plusMinutes(1).plusSeconds(5)); + + PageResponse result = + indexingJobAdminQueryService.getAttempts(context.jobId(), 0, 1); + + assertThat(result.totalElements()).isEqualTo(2); + assertThat(result.content()).singleElement().satisfies(attempt -> { + assertThat(attempt.attemptNo()).isEqualTo(2); + assertThat(attempt.workerId()).isEqualTo(workerId); + assertThat(attempt.errorCode()).isEqualTo("TEST-ATTEMPT-ERROR"); + }); + } + + @Test + @DisplayName("Event는 발생 시각 역순으로 조회하고 Metadata 대신 공개 메시지만 반환한다") + void getEvents_ordersByOccurredAtAndHidesMetadata() { + JobContext context = insertJob("Event 이력", EmbeddingJobStatus.PENDING, null, BASE_TIME); + insertEvent(context.jobId(), "JOB_CREATED", null, "PENDING", "Job 생성", BASE_TIME); + insertEvent(context.jobId(), "RETRY", "PROCESSING", "PENDING", "Retry 예약", + BASE_TIME.plusMinutes(1)); + + PageResponse result = + indexingJobAdminQueryService.getEvents(context.jobId(), 0, 20); + + assertThat(result.content()).extracting(AdminIndexingEventResponse::eventType) + .extracting(Enum::name) + .containsExactly("RETRY", "JOB_CREATED"); + assertThat(result.content().get(0).message()).isEqualTo("Retry 예약"); + } + + @Test + @DisplayName("소유권이 없는 종료 Job 상세은 Worker와 Lease를 null로 반환한다") + void getJob_returnsNullOwnershipForTerminalJob() { + JobContext context = insertJob("종료 Job", EmbeddingJobStatus.INDEXED, null, BASE_TIME); + + AdminIndexingJobResponse result = indexingJobAdminQueryService.getJob(context.jobId()); + + assertThat(result.workerId()).isNull(); + assertThat(result.workerName()).isNull(); + assertThat(result.lockedAt()).isNull(); + assertThat(result.lockExpiresAt()).isNull(); + } + + private JobContext insertJob( + String title, + EmbeddingJobStatus status, + Long workerId, + LocalDateTime createdAt + ) { + String suffix = UUID.randomUUID().toString(); + Long userId = jdbcTemplate.queryForObject(""" + INSERT INTO users (email, password_hash, name, status) + VALUES (?, 'test-password-hash', '관리자 조회 테스트', 'ACTIVE') + RETURNING id + """, Long.class, "admin-query-" + suffix + "@example.com"); + Long documentId = jdbcTemplate.queryForObject(""" + INSERT INTO documents ( + owner_user_id, title, document_type, source_type, status, visibility, created_at + ) VALUES (?, ?, 'TXT', 'UPLOAD', 'INDEXING', 'PRIVATE', ?) + RETURNING id + """, Long.class, userId, title, createdAt); + String versionStatus = status == EmbeddingJobStatus.INDEXED ? "INDEXED" : "UPLOADED"; + Long versionId = jdbcTemplate.queryForObject(""" + INSERT INTO document_versions ( + document_id, version_no, title_snapshot, file_hash, status, created_by, created_at + ) VALUES (?, 1, ?, ?, ?, ?, ?) + RETURNING id + """, Long.class, documentId, title, "hash-" + suffix, versionStatus, userId, createdAt); + jdbcTemplate.update( + "UPDATE documents SET current_version_id = ?, status = ? WHERE id = ?", + versionId, + status == EmbeddingJobStatus.INDEXED ? "INDEXED" : "INDEXING", + documentId + ); + Long modelId = jdbcTemplate.queryForObject( + "SELECT id FROM embedding_models WHERE is_active = TRUE AND is_searchable = TRUE", + Long.class + ); + Long jobId = jdbcTemplate.queryForObject(""" + INSERT INTO embedding_jobs ( + document_version_id, embedding_model_id, status, priority, retry_count, + max_retry_count, locked_by_worker_id, locked_at, lock_expires_at, claim_token, + started_at, completed_at, error_code, error_message, created_at + ) VALUES (?, ?, ?, 0, 1, 3, ?, ?, ?, ?, ?, ?, 'TEST-ERROR', 'internal-test-message', ?) + RETURNING id + """, + Long.class, + versionId, + modelId, + status.name(), + workerId, + workerId == null ? null : createdAt, + workerId == null ? null : createdAt.plusMinutes(5), + workerId == null ? null : UUID.randomUUID().toString(), + status == EmbeddingJobStatus.PENDING ? null : createdAt, + status == EmbeddingJobStatus.INDEXED ? createdAt.plusMinutes(2) : null, + createdAt + ); + return new JobContext(documentId, versionId, jobId); + } + + private Long insertWorker(String name) { + String suffix = UUID.randomUUID().toString(); + return jdbcTemplate.queryForObject(""" + INSERT INTO worker_nodes ( + worker_name, instance_id, host_name, ip_address, status, last_heartbeat_at, started_at + ) VALUES (?, ?, 'test-host', '127.0.0.1', 'ACTIVE', ?, ?) + RETURNING id + """, Long.class, name, suffix, BASE_TIME, BASE_TIME.minusMinutes(1)); + } + + private void insertAttempt( + Long jobId, + Long workerId, + int attemptNo, + String status, + LocalDateTime startedAt, + LocalDateTime endedAt + ) { + jdbcTemplate.update(""" + INSERT INTO embedding_job_attempts ( + embedding_job_id, worker_node_id, attempt_no, claim_token, status, + started_at, ended_at, duration_ms, error_code, error_message + ) VALUES (?, ?, ?, ?, ?, ?, ?, 1000, 'TEST-ATTEMPT-ERROR', 'internal-attempt-message') + """, jobId, workerId, attemptNo, UUID.randomUUID().toString(), status, startedAt, endedAt); + } + + private void insertEvent( + Long jobId, + String eventType, + String fromStatus, + String toStatus, + String message, + LocalDateTime occurredAt + ) { + jdbcTemplate.update(""" + INSERT INTO indexing_events ( + embedding_job_id, event_type, from_status, to_status, message, metadata_json, occurred_at + ) VALUES (?, ?, ?, ?, ?, '{"internal":"metadata"}', ?) + """, jobId, eventType, fromStatus, toStatus, message, occurredAt); + } + + /** 한 검증 시나리오에서 생성한 Document·Version·Job 식별자를 함께 전달한다. */ + private record JobContext(Long documentId, Long versionId, Long jobId) { + } +}