diff --git a/docs/design/kangcheolung-#134-ragops-dashboard-summary.md b/docs/design/kangcheolung-#134-ragops-dashboard-summary.md new file mode 100644 index 0000000..161616c --- /dev/null +++ b/docs/design/kangcheolung-#134-ragops-dashboard-summary.md @@ -0,0 +1,151 @@ +# Issue #134 RAGOps Dashboard 집계 지표 조회 상세 설계 + +closes #134 + +## 1. 배경과 목적 + +관리자가 DocGrid 시스템 전체 상태(문서·인덱싱 작업·Worker·검색)를 확인할 화면이 없어, 실패 작업이 +쌓이거나 Worker가 죽어도 운영자가 즉시 알아챌 방법이 없다. 이번 작업은 RAGOps Dashboard의 첫 조각으로, +관리자가 호출하면 현재 시스템 현황을 하나의 응답으로 집계해서 보여주는 조회 전용 API를 추가한다. + +대시보드는 자체 테이블을 소유하지 않는다. `documents`, `document_versions`, `embedding_jobs`, +`worker_nodes`, `search_queries`는 모두 다른 담당자가 소유한 테이블이며, 이번 작업은 그 데이터를 +읽기 전용으로 집계만 한다. + +WebSocket 실시간 push, 재처리 트리거 API, Worker 상태 전이 이벤트 훅은 이 Issue 범위 밖이며 +후속 Issue에서 이 집계 로직을 재사용한다. + +### 1.1 성공 기준 + +- 문서/작업/Worker/검색 4개 카테고리 지표를 정확히 집계해서 반환한다. +- ADMIN 역할만 접근 가능하다. +- 모든 조회가 SELECT 전용이며 A 담당자 소유 데이터에 쓰기가 없다. +- Worker Heartbeat 판정 로직을 새로 만들지 않고 기존 `WorkerNodeQueryService`를 재사용한다. +- 신규 테이블·컬럼이 없어 Flyway Migration이 필요 없다. + +## 2. 범위 + +### 2.1 포함 + +- `GET /admin/dashboard/summary` API +- `DashboardQueryService` 및 응답 DTO +- `DocumentRepository`, `EmbeddingJobRepository`, `SearchQueryRepository` 집계 쿼리 추가 +- 단위 테스트, 평균 처리 시간 Native Query에 대한 PostgreSQL Repository 테스트 + +### 2.2 제외 + +- WebSocket 실시간 push (`/topic/dashboard`) +- FAILED 작업 목록 조회 — 기존 `GET /admin/indexing-jobs?status=FAILED` 재사용, 신규 구현 없음 +- 관리자 재처리(단건·전체) API +- Worker 상태 전이 시점 이벤트 발행 훅 +- Flyway Migration, DB Index 변경 +- Dashboard 화면(Frontend) + +## 3. API 계약 + +```text +GET /admin/dashboard/summary +Authorization: Bearer {JWT} +``` + +- 요청 파라미터 없음 +- ADMIN 역할만 호출 가능 (`SecurityConfig`의 `/admin/** -> hasRole("ADMIN")` 재사용, 별도 Security + 설정 추가 없음) +- 응답은 조회 시점 기준 Snapshot이며 캐시하지 않는다 + +### 3.1 응답 예시 + +```json +{ + "documents": { "total": 25368, "searchable": 21742, "pendingIndex": 132 }, + "jobs": { "pending": 132, "processing": 8, "failed": 27, "avgProcessMs": 3200 }, + "workers": { "activeCount": 5, "totalCount": 6 }, + "search": { "recent24hCount": 342 } +} +``` + +### 3.2 필드 정의 + +| 필드 | 정의 | 비고 | +|---|---|---| +| `documents.total` | `Document.deletedAt IS NULL` 카운트 | Soft-delete 제외 전체 문서 | +| `documents.searchable` | `Document.status = INDEXED` 카운트 | `DocumentIndexingCompletionService.transitionAndRecordEvent()`가 `document.activateIndexedVersion()`으로 같은 Transaction에서 원자적으로 동기화하므로 신뢰 가능 | +| `documents.pendingIndex` | `Document.status IN (UPLOADED, INDEXING)` | `FAILED`는 `jobs.failed`가 별도로 이미 노출하므로 포함하지 않음 | +| `jobs.pending` / `processing` / `failed` | `EmbeddingJobRepository.countByStatus(...)` | 상태별 단순 카운트 | +| `jobs.avgProcessMs` | `AVG(completed_at - started_at)` (ms) | Queue 대기 시간(`created_at`)은 제외한 순수 처리 시간. 완료 Job이 없으면 `null` | +| `workers.activeCount` | `WorkerNodeQueryService.getWorkers()` 결과 중 `status IN (ACTIVE, IDLE)` | Heartbeat 판정 로직 재사용, 재구현 없음 | +| `workers.totalCount` | `WorkerNodeQueryService.getWorkers()` 결과 전체 개수 | | +| `search.recent24hCount` | `SearchQueryRepository.countByCreatedAtAfter(now - 24h)` | | + +## 4. 조회 구조 + +### 4.1 Repository + +- `DocumentRepository`: `countByDeletedAtIsNull()`, `countByStatus(DocumentStatus)`, + `countByStatusIn(Collection)` — 메서드 이름 기반 자동 쿼리 +- `EmbeddingJobRepository`: `countByStatus(EmbeddingJobStatus)`, `findAllByStatus(EmbeddingJobStatus)` + (후속 전체 재처리 Issue에서 재사용 예정), `findAverageProcessingMillis()` — PostgreSQL + `EXTRACT(EPOCH FROM (completed_at - started_at)) * 1000` Native Query, 완료 Job이 없으면 `null` 반환 +- `SearchQueryRepository`: `countByCreatedAtAfter(LocalDateTime)` + +### 4.2 DashboardQueryService + +`@Transactional(readOnly = true)` 클래스이며 자체 Repository 3개와 `WorkerNodeQueryService`에 +의존한다. 각 카테고리를 독립적으로 조회해 `DashboardSummaryResponse`로 조합한다. + +- Worker 집계는 `resolveEffectiveStatus()`를 다시 구현하지 않고 `WorkerNodeQueryService.getWorkers()` + 호출 결과의 `status` 필드(이미 Heartbeat 기준으로 계산됨)를 그대로 센다. Heartbeat 판정 기준이 + 바뀌어도 이 Service는 수정할 필요가 없다. +- `avgProcessMs`는 Native Query가 `null`을 반환하면 그대로 `null`을 응답하고, 값이 있으면 반올림해 + `Long`으로 변환한다. 0으로 기본값을 채우지 않는다 — 완료 Job이 없는 상태에서 "평균 0ms"는 사실과 + 다른 정보이기 때문이다. +- 최근 24시간 기준 시각은 주입받은 `Clock`으로 계산해 테스트 시 고정 가능하게 한다. + +### 4.3 DTO + +`DashboardSummaryResponse`가 `DocumentsSummaryResponse` / `JobsSummaryResponse` / +`WorkersSummaryResponse` / `SearchSummaryResponse` 4개를 필드로 갖는다. 각 필드는 `@Schema`로 +Swagger 설명을 붙인다. + +## 5. 오류 계약 + +| 상황 | HTTP | 처리 | +|---|---:|---| +| 미인증 또는 ADMIN 아님 | 403 | 기존 `SecurityConfig`의 `/admin/**` 정책 | + +요청 파라미터가 없어 입력 검증 오류 케이스는 없다. + +## 6. 테스트 설계 + +### 6.1 단위 테스트 (`DashboardQueryServiceTest`) + +- 4개 카테고리 지표가 각 Repository/Service 응답으로부터 정확히 조합되는지 +- 완료 Job이 없어 평균 처리 시간이 없을 때 `avgProcessMs`가 `null`인지 +- `STOPPED`·`DEAD` Worker가 `activeCount`에서 제외되는지 + +### 6.2 PostgreSQL Repository 테스트 (`EmbeddingJobDashboardRepositoryTest`, `@DataJpaTest`) + +- `created_at`이 `started_at`보다 훨씬 이전이어도 평균 계산이 대기 시간을 섞지 않는지 +- 완료 Job이 없으면 `null`을 반환하는지 +- 여러 완료 Job의 처리 시간이 올바르게 평균나는지 + +### 6.3 범위에서 제외한 테스트 + +Controller에는 요청 파라미터가 없고 권한 정책은 `SecurityConfig` 레벨에서 이미 다른 `/admin/**` +API들로 검증되므로, 별도 Controller 계층 테스트는 추가하지 않았다. + +## 7. 커밋 분할 + +1. `docs: #134 RAGOps Dashboard 집계 설계 문서 추가` +2. `feat: #134 대시보드 집계용 Repository 쿼리 추가` +3. `feat: #134 대시보드 집계 지표 DTO 및 Query Service 구현` +4. `feat: #134 대시보드 집계 지표 조회 Controller 구현` +5. `test: #134 대시보드 집계 지표 단위·Repository 테스트 추가` + +## 8. 완료 조건 + +- `GET /admin/dashboard/summary` 호출 시 문서/작업/Worker/검색 4개 카테고리 지표가 정확히 반환된다 +- ADMIN이 아닌 사용자 접근 시 403 +- 모든 쿼리가 SELECT 전용이며 Flyway Migration 변경이 없다 +- 단위 테스트와 PostgreSQL Repository 테스트가 통과한다 +- 전체 빌드(`./gradlew build`)가 회귀 없이 통과한다 diff --git a/src/main/java/com/opensource/docgrid/domain/dashboard/controller/DashboardController.java b/src/main/java/com/opensource/docgrid/domain/dashboard/controller/DashboardController.java new file mode 100644 index 0000000..a1ad846 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/dashboard/controller/DashboardController.java @@ -0,0 +1,55 @@ +package com.opensource.docgrid.domain.dashboard.controller; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.opensource.docgrid.domain.dashboard.dto.response.DashboardSummaryResponse; +import com.opensource.docgrid.domain.dashboard.service.query.DashboardQueryService; +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 lombok.RequiredArgsConstructor; + +/** + * 관리자 RAGOps Dashboard 집계 지표 조회의 HTTP 경계. + * + *

요청 검증과 응답 변환만 담당하며, 실제 집계는 {@link DashboardQueryService}에 위임한다. + */ +@Tag(name = "Admin - Dashboard", description = "관리자 전용 RAGOps Dashboard 집계 지표 API") +@RestController +@RequestMapping("/admin/dashboard") +@RequiredArgsConstructor +public class DashboardController { + + private final DashboardQueryService dashboardQueryService; + + @Operation( + summary = "대시보드 집계 지표 조회", + description = "문서·인덱싱 작업·Worker·검색 현황을 하나의 응답으로 집계해서 반환합니다. " + + "모든 지표는 조회 시점 기준 Snapshot이며, 실시간 WebSocket push는 이 API의 범위가 아닙니다." + ) + @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)) + ) + }) + @GetMapping(value = "/summary", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getSummary() { + return ResponseUtils.ok(dashboardQueryService.getSummary()); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/DashboardSummaryResponse.java b/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/DashboardSummaryResponse.java new file mode 100644 index 0000000..c074f9d --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/DashboardSummaryResponse.java @@ -0,0 +1,25 @@ +package com.opensource.docgrid.domain.dashboard.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * RAGOps Dashboard 집계 지표 조회의 최상위 응답 DTO. + * + *

문서·작업·Worker·검색 4개 하위 요약을 하나의 조회 시점 Snapshot으로 조합하는 경계이며, + * 각 하위 요약의 집계 책임은 {@link DocumentsSummaryResponse}, {@link JobsSummaryResponse}, + * {@link WorkersSummaryResponse}, {@link SearchSummaryResponse}에 있다. + */ +public record DashboardSummaryResponse( + @Schema(description = "문서 현황") + DocumentsSummaryResponse documents, + + @Schema(description = "인덱싱 작업 현황") + JobsSummaryResponse jobs, + + @Schema(description = "Worker 현황") + WorkersSummaryResponse workers, + + @Schema(description = "검색 현황") + SearchSummaryResponse search +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/DocumentsSummaryResponse.java b/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/DocumentsSummaryResponse.java new file mode 100644 index 0000000..c6bc154 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/DocumentsSummaryResponse.java @@ -0,0 +1,21 @@ +package com.opensource.docgrid.domain.dashboard.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 대시보드 응답 중 문서 현황 집계 결과. + * + *

{@code Document}의 상태·Soft-delete 여부를 기준으로 계산한 값만 담으며, 실제 집계는 + * {@code DashboardQueryService}가 수행한다. + */ +public record DocumentsSummaryResponse( + @Schema(description = "전체 문서 수 (Soft-delete 제외)", example = "25368") + long total, + + @Schema(description = "검색 가능 문서 수 (INDEXED 상태)", example = "21742") + long searchable, + + @Schema(description = "인덱싱 대기 중인 문서 수 (UPLOADED, INDEXING 상태)", example = "132") + long pendingIndex +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/JobsSummaryResponse.java b/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/JobsSummaryResponse.java new file mode 100644 index 0000000..a1e3852 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/JobsSummaryResponse.java @@ -0,0 +1,25 @@ +package com.opensource.docgrid.domain.dashboard.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 대시보드 응답 중 인덱싱 작업 현황 집계 결과. + * + *

{@code embedding_jobs}의 상태별 개수와 평균 처리 시간을 담으며, 실제 집계는 + * {@code DashboardQueryService}가 수행한다. + */ +public record JobsSummaryResponse( + @Schema(description = "인덱싱 대기 작업 수 (PENDING)", example = "132") + long pending, + + @Schema(description = "처리 중인 작업 수 (PROCESSING)", example = "8") + long processing, + + @Schema(description = "실패 작업 수 (FAILED)", example = "27") + long failed, + + @Schema(description = "평균 임베딩 처리 시간(ms). Queue 대기 시간은 제외한 순수 처리 시간이며, " + + "완료된 Job이 없으면 null", example = "3200") + Long avgProcessMs +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/SearchSummaryResponse.java b/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/SearchSummaryResponse.java new file mode 100644 index 0000000..b35a43a --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/SearchSummaryResponse.java @@ -0,0 +1,14 @@ +package com.opensource.docgrid.domain.dashboard.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 대시보드 응답 중 검색 현황 집계 결과. + * + *

최근 24시간 검색 요청 수만 담으며, 실제 집계는 {@code DashboardQueryService}가 수행한다. + */ +public record SearchSummaryResponse( + @Schema(description = "최근 24시간 검색 요청 수", example = "342") + long recent24hCount +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/WorkersSummaryResponse.java b/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/WorkersSummaryResponse.java new file mode 100644 index 0000000..82eb35d --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/dashboard/dto/response/WorkersSummaryResponse.java @@ -0,0 +1,18 @@ +package com.opensource.docgrid.domain.dashboard.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 대시보드 응답 중 Worker 현황 집계 결과. + * + *

Heartbeat 기준 실시간 상태 계산은 {@code WorkerNodeQueryService}에 위임하고, 이 DTO는 + * 그 결과를 센 개수만 담는다. + */ +public record WorkersSummaryResponse( + @Schema(description = "정상(ACTIVE·IDLE) Worker 수", example = "5") + long activeCount, + + @Schema(description = "전체 등록 Worker 수", example = "6") + long totalCount +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/dashboard/service/query/DashboardQueryService.java b/src/main/java/com/opensource/docgrid/domain/dashboard/service/query/DashboardQueryService.java new file mode 100644 index 0000000..8c02e98 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/dashboard/service/query/DashboardQueryService.java @@ -0,0 +1,91 @@ +package com.opensource.docgrid.domain.dashboard.service.query; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.dashboard.dto.response.DashboardSummaryResponse; +import com.opensource.docgrid.domain.dashboard.dto.response.DocumentsSummaryResponse; +import com.opensource.docgrid.domain.dashboard.dto.response.JobsSummaryResponse; +import com.opensource.docgrid.domain.dashboard.dto.response.SearchSummaryResponse; +import com.opensource.docgrid.domain.dashboard.dto.response.WorkersSummaryResponse; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.repository.DocumentRepository; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingJobRepository; +import com.opensource.docgrid.domain.search.repository.SearchQueryRepository; +import com.opensource.docgrid.domain.worker.dto.response.WorkerNodeResponse; +import com.opensource.docgrid.domain.worker.enums.WorkerStatus; +import com.opensource.docgrid.domain.worker.service.query.WorkerNodeQueryService; + +import lombok.RequiredArgsConstructor; + +/** + * RAGOps Dashboard 집계 지표를 조회한다. + * + *

자체 테이블은 소유하지 않으며 A 담당자가 소유한 문서·작업·검색 Repository를 읽기 전용으로 집계하고, + * Worker 현황은 Heartbeat 기준 실시간 상태 계산 로직을 새로 만들지 않고 {@link WorkerNodeQueryService}를 + * 그대로 재사용한다. + */ +@Transactional(readOnly = true) +@Service +@RequiredArgsConstructor +public class DashboardQueryService { + + private static final List PENDING_INDEX_STATUSES = + List.of(DocumentStatus.UPLOADED, DocumentStatus.INDEXING); + + private final DocumentRepository documentRepository; + private final EmbeddingJobRepository embeddingJobRepository; + private final SearchQueryRepository searchQueryRepository; + private final WorkerNodeQueryService workerNodeQueryService; + private final Clock clock; + + // 대시보드 요약 지표 조회 + public DashboardSummaryResponse getSummary() { + return new DashboardSummaryResponse( + getDocumentsSummary(), + getJobsSummary(), + getWorkersSummary(), + getSearchSummary() + ); + } + + // 문서 현황 집계 + private DocumentsSummaryResponse getDocumentsSummary() { + return new DocumentsSummaryResponse( + documentRepository.countByDeletedAtIsNull(), + documentRepository.countByStatus(DocumentStatus.INDEXED), + documentRepository.countByStatusIn(PENDING_INDEX_STATUSES) + ); + } + + // 인덱싱 작업 현황 집계 + private JobsSummaryResponse getJobsSummary() { + Double averageMillis = embeddingJobRepository.findAverageProcessingMillis(); + return new JobsSummaryResponse( + embeddingJobRepository.countByStatus(EmbeddingJobStatus.PENDING), + embeddingJobRepository.countByStatus(EmbeddingJobStatus.PROCESSING), + embeddingJobRepository.countByStatus(EmbeddingJobStatus.FAILED), + averageMillis == null ? null : Math.round(averageMillis) + ); + } + + // Worker 현황 집계 + private WorkersSummaryResponse getWorkersSummary() { + List workers = workerNodeQueryService.getWorkers(); + long activeCount = workers.stream() + .filter(worker -> worker.status() == WorkerStatus.ACTIVE || worker.status() == WorkerStatus.IDLE) + .count(); + return new WorkersSummaryResponse(activeCount, workers.size()); + } + + // 최근 24시간 검색 쿼리 수 집계 + private SearchSummaryResponse getSearchSummary() { + LocalDateTime since = LocalDateTime.now(clock).minusHours(24); + return new SearchSummaryResponse(searchQueryRepository.countByCreatedAtAfter(since)); + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java b/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java index 3f268f3..7fec04f 100644 --- a/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java +++ b/src/main/java/com/opensource/docgrid/domain/document/repository/DocumentRepository.java @@ -12,12 +12,28 @@ import jakarta.persistence.LockModeType; import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; // A담당자 영역 — B담당자는 존재 확인 등 읽기 전용으로만 사용 public interface DocumentRepository extends JpaRepository { + /** + * 대시보드 집계 카드의 전체 문서 수. Soft-delete된 문서는 제외한다. + */ + long countByDeletedAtIsNull(); + + /** + * 대시보드 집계 카드에서 특정 상태 하나에 속하는 문서 수를 센다 (예: 검색 가능 문서 수). + */ + long countByStatus(DocumentStatus status); + + /** + * 대시보드 집계 카드에서 여러 상태에 걸친 문서 수를 센다 (예: 인덱싱 대기 중 문서 수). + */ + long countByStatusIn(Collection statuses); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT d FROM Document d WHERE d.id = :documentId") Optional findByIdForUpdate(@Param("documentId") Long documentId); 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 129a0ef..aa5ea30 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 @@ -158,4 +158,32 @@ Optional findExpiredByIdForUpdateSkipLocked( @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT job FROM EmbeddingJob job WHERE job.id = :jobId") Optional findByIdForUpdate(@Param("jobId") Long jobId); + + /** + * 대시보드 집계 카드(대기/처리 중/실패 작업 수)에 사용하는 상태별 Job 수를 센다. + */ + long countByStatus(EmbeddingJobStatus status); + + /** + * 관리자 전체 재처리 대상인 FAILED Job 전체를 조회한다. + */ + List findAllByStatus(EmbeddingJobStatus status); + + /** + * 완료된 Job의 평균 처리 시간을 밀리초 단위로 계산한다. + * + *

Queue 대기 시간({@code created_at})은 제외하고 Worker가 실제로 처리한 구간({@code started_at} + * ~ {@code completed_at})만 반영한다. 완료된 Job이 없으면 {@code null}을 반환한다. + */ + @Query( + value = """ + SELECT AVG(EXTRACT(EPOCH FROM (completed_at - started_at)) * 1000) + FROM embedding_jobs + WHERE status = 'INDEXED' + AND started_at IS NOT NULL + AND completed_at IS NOT NULL + """, + nativeQuery = true + ) + Double findAverageProcessingMillis(); } diff --git a/src/main/java/com/opensource/docgrid/domain/search/repository/SearchQueryRepository.java b/src/main/java/com/opensource/docgrid/domain/search/repository/SearchQueryRepository.java index df24792..f4e9ca9 100644 --- a/src/main/java/com/opensource/docgrid/domain/search/repository/SearchQueryRepository.java +++ b/src/main/java/com/opensource/docgrid/domain/search/repository/SearchQueryRepository.java @@ -1,8 +1,15 @@ package com.opensource.docgrid.domain.search.repository; +import java.time.LocalDateTime; + import org.springframework.data.jpa.repository.JpaRepository; import com.opensource.docgrid.domain.search.entity.SearchQuery; public interface SearchQueryRepository extends JpaRepository { + + /** + * 대시보드 집계 카드의 최근 검색 요청 수. 기준 시각 이후 생성된 검색 Query를 센다. + */ + long countByCreatedAtAfter(LocalDateTime since); } diff --git a/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java b/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java index c67bdff..1aeab5d 100644 --- a/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java +++ b/src/main/java/com/opensource/docgrid/global/config/SecurityConfig.java @@ -44,7 +44,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { ) /* * UsernamePasswordAuthenticationFilter는 위치 기준점(앵커)일 뿐이며, - * 실제 목적은 두 필터가 최종 인증 판정(authorizeHttpRequests)보다 먼저 + * 실제 목적은 두 필터(JwtAuthenticationFilter, McpApiKeyAuthFilter)가 최종 인증 판정(authorizeHttpRequests)보다 먼저 실행됨 * SecurityContext를 채워두는 것이다. 각자 다른 경로만 처리하고 나머지는 스킵: * - JwtAuthenticationFilter → 웹 로그인(JWT), /mcp/tokens 등 일반 API 담당 * - McpApiKeyAuthFilter → Claude Desktop API 키, /mcp 경로만 담당 diff --git a/src/test/java/com/opensource/docgrid/domain/dashboard/service/query/DashboardQueryServiceTest.java b/src/test/java/com/opensource/docgrid/domain/dashboard/service/query/DashboardQueryServiceTest.java new file mode 100644 index 0000000..a28dcc2 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/dashboard/service/query/DashboardQueryServiceTest.java @@ -0,0 +1,155 @@ +package com.opensource.docgrid.domain.dashboard.service.query; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.opensource.docgrid.domain.dashboard.dto.response.DashboardSummaryResponse; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.repository.DocumentRepository; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingJobRepository; +import com.opensource.docgrid.domain.search.repository.SearchQueryRepository; +import com.opensource.docgrid.domain.worker.dto.response.WorkerNodeResponse; +import com.opensource.docgrid.domain.worker.enums.WorkerStatus; +import com.opensource.docgrid.domain.worker.service.query.WorkerNodeQueryService; + +@ExtendWith(MockitoExtension.class) +@DisplayName("DashboardQueryService 단위 테스트") +class DashboardQueryServiceTest { + + private static final Clock FIXED_CLOCK = Clock.fixed( + Instant.parse("2026-08-10T06:00:00Z"), + ZoneId.of("Asia/Seoul") + ); + + @Mock private DocumentRepository documentRepository; + @Mock private EmbeddingJobRepository embeddingJobRepository; + @Mock private SearchQueryRepository searchQueryRepository; + @Mock private WorkerNodeQueryService workerNodeQueryService; + + private DashboardQueryService dashboardQueryService; + + @BeforeEach + void setUp() { + dashboardQueryService = new DashboardQueryService( + documentRepository, + embeddingJobRepository, + searchQueryRepository, + workerNodeQueryService, + FIXED_CLOCK + ); + } + + @Test + @DisplayName("정상 케이스: 4개 카테고리 지표를 정확히 집계해서 조합한다") + void getSummary_aggregatesAllCategories() { + // Given + given(documentRepository.countByDeletedAtIsNull()).willReturn(25368L); + given(documentRepository.countByStatus(DocumentStatus.INDEXED)).willReturn(21742L); + given(documentRepository.countByStatusIn(any())).willReturn(132L); + + given(embeddingJobRepository.countByStatus(EmbeddingJobStatus.PENDING)).willReturn(132L); + given(embeddingJobRepository.countByStatus(EmbeddingJobStatus.PROCESSING)).willReturn(8L); + given(embeddingJobRepository.countByStatus(EmbeddingJobStatus.FAILED)).willReturn(27L); + given(embeddingJobRepository.findAverageProcessingMillis()).willReturn(3200.4); + + given(workerNodeQueryService.getWorkers()).willReturn(List.of( + createWorker(WorkerStatus.ACTIVE), + createWorker(WorkerStatus.IDLE), + createWorker(WorkerStatus.DEAD) + )); + + given(searchQueryRepository.countByCreatedAtAfter( + LocalDateTime.of(2026, 8, 9, 15, 0, 0) + )).willReturn(342L); + + // When + DashboardSummaryResponse result = dashboardQueryService.getSummary(); + + // Then + assertThat(result.documents().total()).isEqualTo(25368L); + assertThat(result.documents().searchable()).isEqualTo(21742L); + assertThat(result.documents().pendingIndex()).isEqualTo(132L); + + assertThat(result.jobs().pending()).isEqualTo(132L); + assertThat(result.jobs().processing()).isEqualTo(8L); + assertThat(result.jobs().failed()).isEqualTo(27L); + assertThat(result.jobs().avgProcessMs()).isEqualTo(3200L); + + assertThat(result.workers().activeCount()).isEqualTo(2L); + assertThat(result.workers().totalCount()).isEqualTo(3L); + + assertThat(result.search().recent24hCount()).isEqualTo(342L); + } + + @Test + @DisplayName("완료된 Job이 없어 평균 처리 시간이 없으면 avgProcessMs는 null이다") + void getSummary_returnsNullAvgProcessMs_whenNoCompletedJobs() { + // Given + given(documentRepository.countByDeletedAtIsNull()).willReturn(0L); + given(documentRepository.countByStatus(any())).willReturn(0L); + given(documentRepository.countByStatusIn(any())).willReturn(0L); + given(embeddingJobRepository.countByStatus(any())).willReturn(0L); + given(embeddingJobRepository.findAverageProcessingMillis()).willReturn(null); + given(workerNodeQueryService.getWorkers()).willReturn(List.of()); + given(searchQueryRepository.countByCreatedAtAfter(any())).willReturn(0L); + + // When + DashboardSummaryResponse result = dashboardQueryService.getSummary(); + + // Then + assertThat(result.jobs().avgProcessMs()).isNull(); + } + + @Test + @DisplayName("STOPPED·DEAD Worker는 activeCount에서 제외한다") + void getSummary_excludesStoppedAndDeadWorkersFromActiveCount() { + // Given + given(documentRepository.countByDeletedAtIsNull()).willReturn(0L); + given(documentRepository.countByStatus(any())).willReturn(0L); + given(documentRepository.countByStatusIn(any())).willReturn(0L); + given(embeddingJobRepository.countByStatus(any())).willReturn(0L); + given(embeddingJobRepository.findAverageProcessingMillis()).willReturn(null); + given(searchQueryRepository.countByCreatedAtAfter(any())).willReturn(0L); + + given(workerNodeQueryService.getWorkers()).willReturn(List.of( + createWorker(WorkerStatus.STOPPED), + createWorker(WorkerStatus.DEAD) + )); + + // When + DashboardSummaryResponse result = dashboardQueryService.getSummary(); + + // Then + assertThat(result.workers().activeCount()).isZero(); + assertThat(result.workers().totalCount()).isEqualTo(2L); + } + + private WorkerNodeResponse createWorker(WorkerStatus status) { + return new WorkerNodeResponse( + 1L, + "indexing-worker", + "instance-1", + "docgrid-api-01", + "10.0.0.12", + status, + LocalDateTime.of(2026, 8, 10, 5, 59, 0), + LocalDateTime.of(2026, 8, 10, 5, 0, 0), + null + ); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingJobDashboardRepositoryTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingJobDashboardRepositoryTest.java new file mode 100644 index 0000000..31015ff --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingJobDashboardRepositoryTest.java @@ -0,0 +1,164 @@ +package com.opensource.docgrid.domain.embedding.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +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.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +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.enums.EmbeddingJobStatus; + +/** + * 대시보드 집계에 쓰이는 평균 처리 시간 Native Query를 실제 OpenSQL에서 검증한다. + * + *

Queue 대기 시간({@code created_at})이 아니라 Worker 처리 구간({@code started_at}~{@code completed_at})만 + * 반영하는지, 완료된 Job이 없을 때 {@code null}을 반환하는지를 확인한다. + */ +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@DisplayName("EmbeddingJob 대시보드 집계 Repository 테스트") +class EmbeddingJobDashboardRepositoryTest { + + private static final String TEST_SCHEMA = "docgrid_embedding_job_dashboard_repository_test"; + + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private EmbeddingJobRepository embeddingJobRepository; + + private Long documentId; + private Long versionId; + private Long embeddingModelId; + + @DynamicPropertySource + static void configureDatabase(DynamicPropertyRegistry registry) { + registry.add("TEST_DB_SCHEMA", () -> TEST_SCHEMA); + registry.add("jwt.secret", () -> "docgrid-embedding-job-dashboard-repository-test-secret-key-2026"); + } + + @BeforeEach + void setUp() { + jdbcTemplate.execute(""" + TRUNCATE TABLE + embedding_jobs, + document_versions, + documents, + users + RESTART IDENTITY CASCADE + """); + + String suffix = UUID.randomUUID().toString(); + Long userId = insertUser(suffix); + documentId = insertDocument(userId); + versionId = insertVersion(documentId, userId); + 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("Queue 대기 시간은 제외하고 started_at~completed_at 구간만 평균에 반영한다") + void findAverageProcessingMillis_excludesQueueWaitTime() { + // created_at을 started_at보다 훨씬 이전으로 둬서, 평균 계산이 대기 시간을 섞지 않는지 확인한다. + insertIndexedJob("2026-08-01 00:00:00", "2026-08-10 00:00:00", "2026-08-10 00:00:02"); + + Double averageMillis = embeddingJobRepository.findAverageProcessingMillis(); + + assertThat(averageMillis).isCloseTo(2000.0, within(1.0)); + } + + @Test + @DisplayName("완료된 Job이 없으면 null을 반환한다") + void findAverageProcessingMillis_returnsNull_whenNoCompletedJobs() { + insertPendingJob(); + + Double averageMillis = embeddingJobRepository.findAverageProcessingMillis(); + + assertThat(averageMillis).isNull(); + } + + @Test + @DisplayName("여러 완료 Job의 처리 시간을 평균낸다") + void findAverageProcessingMillis_averagesMultipleJobs() { + insertIndexedJob("2026-08-10 00:00:00", "2026-08-10 00:00:00", "2026-08-10 00:00:01"); + insertIndexedJob("2026-08-10 00:00:00", "2026-08-10 00:00:00", "2026-08-10 00:00:03"); + + Double averageMillis = embeddingJobRepository.findAverageProcessingMillis(); + + assertThat(averageMillis).isCloseTo(2000.0, within(1.0)); + } + + private Long insertUser(String suffix) { + return jdbcTemplate.queryForObject(""" + INSERT INTO users (email, password_hash, name, status, created_at, updated_at) + VALUES (?, 'password-hash', 'Dashboard Repository Test User', 'ACTIVE', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, "embedding-job-dashboard-repository-" + suffix + "@example.com"); + } + + private Long insertDocument(Long userId) { + return jdbcTemplate.queryForObject(""" + INSERT INTO documents ( + owner_user_id, title, document_type, source_type, status, visibility, + created_at, updated_at + ) + VALUES (?, 'Dashboard Repository Test Document', 'TXT', 'UPLOAD', 'INDEXING', 'PRIVATE', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, userId); + } + + private Long insertVersion(Long targetDocumentId, Long userId) { + return jdbcTemplate.queryForObject(""" + INSERT INTO document_versions ( + document_id, version_no, title_snapshot, content_type, status, + created_by, created_at, updated_at + ) + VALUES (?, 1, 'Dashboard Repository Test Version', 'text/plain', 'EMBEDDING', ?, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, targetDocumentId, userId); + } + + private void insertIndexedJob(String createdAt, String startedAt, String completedAt) { + jdbcTemplate.update(""" + INSERT INTO embedding_jobs ( + document_version_id, embedding_model_id, status, priority, retry_count, + max_retry_count, started_at, completed_at, created_at, updated_at + ) + VALUES (?, ?, 'INDEXED', 0, 0, 3, ?::timestamp, ?::timestamp, ?::timestamp, ?::timestamp) + """, versionId, embeddingModelId, startedAt, completedAt, createdAt, createdAt); + } + + private void insertPendingJob() { + jdbcTemplate.update(""" + INSERT INTO embedding_jobs ( + document_version_id, embedding_model_id, status, priority, retry_count, + max_retry_count, created_at, updated_at + ) + VALUES (?, ?, 'PENDING', 0, 0, 3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, versionId, embeddingModelId); + } +}