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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,198 changes: 1,198 additions & 0 deletions docs/design/Gimini-3-#84-document-indexing-completion.md

Large diffs are not rendered by default.

100 changes: 100 additions & 0 deletions docs/test-results/Gimini-3-#84-document-indexing-completion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# 문서 인덱싱 완료 구현 검증 결과 (#84)

## 1. 검증 개요

- 실행일: 2026-07-31 (Asia/Seoul)
- 대상 브랜치: `feature/84`
- 데이터베이스: 격리된 OpenSQL/PostgreSQL 14 + pgvector
- Vector Schema: `vector(1024)`
- 결과: 전체 빌드와 432개 테스트 성공, 실패 0건

기존 개발 데이터 볼륨은 사용하거나 변경하지 않았다. 검증 전용 컨테이너와 전용 볼륨에서 Flyway
Migration 및 Seed를 적용하고, 테스트 Class별 격리 Schema를 사용했다.

## 2. 실행 결과

### 2.1 Domain 전이와 완료 Service 단위 테스트

다음 계약의 성공·거부 경로가 통과했다.

- `EmbeddingJob`: `PROCESSING → INDEXED`
- `EmbeddingJobAttempt`: `STARTED → SUCCESS`
- `DocumentVersion`: `EMBEDDING → INDEXED`
- `Document`: 같은 Document의 완료 Version 활성화
- 최초 완료의 최신 Version, 전체 Embedding Set, Model, 이벤트 사전 상태 검증
- 완료 재생의 Worker·Token·Attempt 및 저장 완료 상태 검증
- Lease 만료와 후속 Version 활성화 뒤에도 최초 완료 결과 재생
- 관리자 API의 요청 Validation, ADMIN 권한, 오류 응답과 민감 필드 비노출

실행한 대표 Test Class:

```text
DocumentTest
DocumentVersionTest
EmbeddingJobTest
EmbeddingJobAttemptTest
DocumentIndexingCompletionServiceTest
IndexingJobAdminControllerTest
```

### 2.2 Repository와 OpenSQL 검색 전환

실제 OpenSQL에서 다음 Test Class가 통과했다.

```text
IndexingCompletionRepositoryTest
DocumentIndexingCompletionIntegrationTest
```

검증한 내용:

1. Version 전체·Model별·ACTIVE Embedding 개수 집계
2. Vector를 JVM으로 읽지 않는 `vector_dims`·관계·Hash 불일치 집계
3. 이전 Version ACTIVE Embedding의 STALE bulk update
4. 최초 Version 완료 전 권한 pre-filter·Vector Search 제외
5. 최초 Version 완료 후 current ACTIVE Version 검색 노출
6. 새 Version 완료 전 이전 본문만 검색
7. 새 Version 완료 후 이전 STALE·새 ACTIVE 및 새 본문만 검색
8. Attempt·Job·Version·INDEXED 이벤트의 동일 완료 시각

### 2.3 동시 완료와 실패 Rollback

- 같은 Job·Attempt·Worker·Token의 두 Thread를 Barrier로 동시에 시작했다.
- 두 요청은 Job Pessimistic Lock으로 직렬화돼 같은 완료 응답으로 수렴했다.
- `INDEXED` 이벤트는 한 건만 저장됐다.
- PostgreSQL `TIMESTAMP` 정밀도에 맞춰 완료 시각을 microsecond로 고정해 최초 응답과 재생 응답을
동일하게 유지했다.

Rollback 검증은 실제 OpenSQL의 테스트용 Trigger가 마지막 `INDEXED` 이벤트 Insert를 실패시키도록
구성했다. 실패 뒤 새 조회에서 다음 원상태를 확인했다.

- 이전 current Version Embedding: `ACTIVE`
- 대상 Version Embedding: `ACTIVE`
- 대상 Version: `EMBEDDING`
- Document current Version: 이전 Version
- Attempt: `STARTED`
- Job: `PROCESSING`
- `INDEXED` 이벤트: 0건

테스트용 Trigger와 Function은 검증 직후 제거했다.

## 3. 전체 회귀

실행:

```bash
./gradlew clean build
git diff --check
```

결과:

```text
BUILD SUCCESSFUL
tests=432 failures=0 errors=0
git diff --check: 통과
```

Gradle 기본 `test` 설정이 제외하는 `benchmark`, `minio-integration`, `claim-concurrency` 태그는 이번
전체 빌드 범위에도 포함되지 않았다. 이번 변경 전용 OpenSQL 검색 전환·동시 완료·Rollback 테스트는
기본 `test` 범위에 포함되어 실행됐다.
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.opensource.docgrid.domain.document.enums.DocumentSourceType;
import com.opensource.docgrid.domain.document.enums.DocumentStatus;
import com.opensource.docgrid.domain.document.enums.DocumentType;
import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus;
import com.opensource.docgrid.domain.document.enums.VisibilityType;
import com.opensource.docgrid.domain.user.entity.User;
import com.opensource.docgrid.global.common.entity.BaseEntity;
Expand Down Expand Up @@ -75,8 +76,8 @@ public class Document extends BaseEntity {
@JoinColumn(name = "owner_user_id", nullable = false)
private User owner;

// 현재 활성 버전. documents <-> document_versions 순환 FK이므로 반드시 nullable.
// 최초 버전은 업로드 접수 시 설정하며, 후속 버전은 색인 완료 후 갱신한다.
// 현재 버전 포인터. 최초 업로드 중에는 처리 대상을, 완료 후에는 검색 가능한 최신 Version을 가리킨다.
// documents <-> document_versions 순환 FK이므로 반드시 nullable이다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "current_version_id")
private DocumentVersion currentVersion;
Expand Down Expand Up @@ -125,6 +126,34 @@ public void updateCurrentVersion(DocumentVersion currentVersion) {
this.currentVersion = currentVersion;
}

/**
* 같은 문서에 속하고 인덱싱을 마친 Version을 현재 검색 대상으로 활성화한다.
*
* <p>업로드 접수 단계의 포인터 설정은 {@link #updateCurrentVersion(DocumentVersion)}이 담당하고,
* 이 메서드는 완료 Transaction 경계에서 Version 포인터와 문서 상태를 함께 변경한다.
*
* @param documentVersion 새 검색 대상이 될 완료 Version
*/
public void activateIndexedVersion(DocumentVersion documentVersion) {
// 1. 영속 식별자를 기준으로 다른 문서의 Version이 연결되는 것을 차단한다.
if (id == null
|| documentVersion == null
|| documentVersion.getDocument() == null
|| documentVersion.getDocument().getId() == null
|| !id.equals(documentVersion.getDocument().getId())) {
throw new IllegalArgumentException("현재 문서에 속한 Version만 활성화할 수 있습니다.");
}

// 2. 검색 준비가 끝난 Version만 현재 포인터로 승격한다.
if (documentVersion.getStatus() != DocumentVersionStatus.INDEXED) {
throw new IllegalStateException("INDEXED 상태의 문서 버전만 활성화할 수 있습니다.");
}

// 3. 포인터와 문서 상태를 함께 변경해 검색 조건이 중간 상태를 관찰하지 않게 한다.
this.currentVersion = documentVersion;
this.status = DocumentStatus.INDEXED;
}

public void markIndexing() {
this.status = DocumentStatus.INDEXING;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,14 @@ public void markEmbedding() {
this.status = DocumentVersionStatus.EMBEDDING;
}

/**
* Embedding Set이 완성된 Version을 검색 가능한 완료 상태로 전환한다.
*/
public void markIndexed(LocalDateTime indexedAt) {
// Embedding 저장 단계를 거치지 않은 Version이 검색 대상으로 노출되지 않도록 전이를 제한한다.
if (status != DocumentVersionStatus.EMBEDDING) {
throw new IllegalStateException("EMBEDDING 상태의 문서 버전만 INDEXED로 전환할 수 있습니다.");
}
this.status = DocumentVersionStatus.INDEXED;
this.indexedAt = indexedAt;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,23 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.opensource.docgrid.domain.embedding.dto.request.StartEmbeddingJobAttemptRequest;
import com.opensource.docgrid.domain.embedding.dto.request.CompleteDocumentIndexingRequest;
import com.opensource.docgrid.domain.embedding.dto.request.CreateDocumentChunksRequest;
import com.opensource.docgrid.domain.embedding.dto.request.CreateDocumentEmbeddingsRequest;
import com.opensource.docgrid.domain.embedding.dto.request.StartEmbeddingJobAttemptRequest;
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;
import com.opensource.docgrid.domain.embedding.dto.response.DocumentIndexingCompletionResponse;
import com.opensource.docgrid.domain.embedding.dto.response.StartedEmbeddingJobAttemptResponse;
import com.opensource.docgrid.domain.document.service.DocumentParsingService;
import com.opensource.docgrid.domain.document.service.command.DocumentChunkTransactionService.ChunkResult;
import com.opensource.docgrid.domain.embedding.service.DocumentEmbeddingService;
import com.opensource.docgrid.domain.embedding.service.DocumentEmbeddingService.EmbeddingResult;
import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobClaimService;
import com.opensource.docgrid.domain.embedding.service.command.DocumentIndexingCompletionService;
import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobAttemptService;
import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobAttemptService.StartResult;
import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobClaimService;
import com.opensource.docgrid.global.common.response.ApiResponse;
import com.opensource.docgrid.global.common.response.ErrorResponse;
import com.opensource.docgrid.global.common.response.ResponseUtils;
Expand All @@ -40,7 +43,7 @@
import lombok.RequiredArgsConstructor;

/**
* 관리자용 Embedding Job Claim, Attempt 시작과 문서 Chunk·Embedding 실행을 HTTP API로 제공한다.
* 관리자용 Embedding Job Claim, Attempt 시작과 문서 Chunk·Embedding·인덱싱 완료 실행을 HTTP API로 제공한다.
*
* <p>HTTP 입력 검증과 성공 상태 변환만 담당한다. Job Claim 및 현재 소유권 기반 파이프라인 단계의
* Transaction·외부 호출·동시성 규칙은 각 Service에 위임한다.
Expand All @@ -56,6 +59,7 @@ public class IndexingJobAdminController {
private final EmbeddingJobAttemptService embeddingJobAttemptService;
private final DocumentParsingService documentParsingService;
private final DocumentEmbeddingService documentEmbeddingService;
private final DocumentIndexingCompletionService documentIndexingCompletionService;

@Operation(
summary = "PENDING Job Claim",
Expand Down Expand Up @@ -298,4 +302,57 @@ public ResponseEntity<ApiResponse<DocumentEmbeddingsResponse>> createEmbeddings(
}
return ResponseUtils.ok(result.response());
}

@Operation(
summary = "Document 인덱싱 완료",
description = "현재 PROCESSING Job의 소유권과 Attempt, 최신 Version 및 전체 ACTIVE Embedding Set을 "
+ "검증한 뒤 Version과 Document를 검색 가능한 INDEXED 상태로 확정합니다. "
+ "같은 완료 실행의 재요청은 저장된 최초 결과를 멱등 재생합니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "Document 인덱싱 최초 완료 또는 기존 완료 결과 재생"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "Job ID, Attempt ID, Worker ID 또는 Claim Token 형식 오류",
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))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "409",
description = "현재 소유권, Attempt, Lease 또는 최신 Version 상태 오류",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "500",
description = "Model, Chunk, Embedding, 완료 시각 또는 이벤트 데이터 불일치",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
)
})
@PostMapping(
value = "/{jobId}/attempts/{attemptId}/complete",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<ApiResponse<DocumentIndexingCompletionResponse>> completeIndexing(
@PathVariable @Positive Long jobId,
@PathVariable @Positive Long attemptId,
@Valid @RequestBody CompleteDocumentIndexingRequest request
) {
// 최초 완료와 멱등 재생 모두 같은 안정적인 완료 응답을 200 OK로 반환한다.
return ResponseUtils.ok(
documentIndexingCompletionService.complete(jobId, attemptId, request)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.opensource.docgrid.domain.embedding.dto.request;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;

/**
* 현재 Embedding Job Attempt의 소유권으로 문서 인덱싱 완료를 확정하는 요청 DTO.
*
* <p>Worker ID와 Claim Token은 최초 완료 및 멱등 재생의 실행 식별에만 사용하며 응답에는 노출하지 않는다.
*/
public record CompleteDocumentIndexingRequest(
@Schema(description = "현재 Job을 소유한 Worker 식별자", example = "7")
@NotNull
@Positive
Long workerId,

@Schema(description = "현재 Claim의 canonical UUID Token",
example = "34c19d16-6ae1-4f6a-a35d-0123456789ab")
@NotBlank
@Size(max = 36)
@Pattern(
regexp = "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
message = "canonical UUID 형식이어야 합니다."
)
String claimToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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 com.opensource.docgrid.domain.worker.enums.AttemptStatus;

import io.swagger.v3.oas.annotations.media.Schema;

/**
* 최초 완료 또는 멱등 재생된 문서 인덱싱 결과의 안정적인 실행·대상·시각 정보를 전달한다.
*
* <p>이후 새 Version이 활성화돼도 바뀌지 않는 완료 실행 정보만 반환하며 Claim Token, Vector와
* 현재 Document 포인터 같은 가변 내부 상태는 노출하지 않는다.
*/
public record DocumentIndexingCompletionResponse(
@Schema(description = "완료된 Embedding Job 식별자", example = "41")
Long jobId,

@Schema(description = "완료된 Attempt 식별자", example = "103")
Long attemptId,

@Schema(description = "인덱싱된 Document 식별자", example = "10")
Long documentId,

@Schema(description = "인덱싱된 Document Version 식별자", example = "22")
Long documentVersionId,

@Schema(description = "Job에 고정된 Embedding Model 식별자", example = "1")
Long embeddingModelId,

@Schema(description = "완료된 Job 상태", example = "INDEXED")
EmbeddingJobStatus jobStatus,

@Schema(description = "완료된 Attempt 상태", example = "SUCCESS")
AttemptStatus attemptStatus,

@Schema(description = "완료된 Version 상태", example = "INDEXED")
DocumentVersionStatus versionStatus,

@Schema(description = "최초 완료 시각", example = "2026-07-31T16:00:00")
LocalDateTime completedAt,

@Schema(description = "Attempt 시작부터 완료까지 걸린 시간(ms)", example = "8421")
long durationMs
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,16 @@ public void claim(WorkerNode workerNode, String claimToken, LocalDateTime claime
}
}

/**
* 현재 처리 중인 Job을 최종 인덱싱 완료 상태로 전환한다.
*
* <p>Claim 소유권 정보는 완료 재생과 감사에 사용하므로 완료 후에도 보존한다.
*/
public void markIndexed(LocalDateTime completedAt) {
// 완료 Transaction만 PROCESSING Job을 종결할 수 있어야 늦은 요청이 결과를 덮어쓰지 않는다.
if (status != EmbeddingJobStatus.PROCESSING) {
throw new IllegalStateException("PROCESSING 상태의 Job만 INDEXED로 전환할 수 있습니다.");
}
this.status = EmbeddingJobStatus.INDEXED;
this.completedAt = completedAt;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.opensource.docgrid.domain.embedding.repository;

import java.util.Collection;
import java.util.Optional;

import jakarta.persistence.LockModeType;
Expand All @@ -10,6 +11,7 @@
import org.springframework.data.repository.query.Param;

import com.opensource.docgrid.domain.embedding.entity.EmbeddingJob;
import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus;

/**
* Embedding Job Queue의 영속성과 Claim 후보 행 잠금을 담당하는 Repository.
Expand All @@ -19,6 +21,14 @@
*/
public interface EmbeddingJobRepository extends JpaRepository<EmbeddingJob, Long> {

/**
* 같은 Version에 동시에 살아 있는 Job이 하나뿐인지 완료 직전에 확인한다.
*/
long countByDocumentVersionIdAndStatusIn(
Long documentVersionId,
Collection<EmbeddingJobStatus> statuses
);

/**
* 우선순위 Queue 정책에 따라 다음 PENDING Job 한 건을 잠금 상태로 조회한다.
*
Expand Down
Loading