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
511 changes: 511 additions & 0 deletions docs/design/Gimini-3-#88-document-indexing-failure-retry.md

Large diffs are not rendered by default.

182 changes: 182 additions & 0 deletions docs/test-results/Gimini-3-#88-document-indexing-failure-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# #88 인덱싱 실패 및 지연 재시도 검증 결과

## 1. 검증 정보

- 실행일: 2026-08-03
- 실행 환경: macOS Docker Desktop의 일회용 `docgrid-postgres:latest` 컨테이너
- 데이터베이스: PostgreSQL 14.6(OpenSQL-PG 호환), 테스트별 격리 Schema, Flyway V1~V35 적용
- 애플리케이션: Spring Boot 3.5.16, Java 17
- 브랜치: `codex/feature-88-indexing-failure-retry`

기존 로컬 DB 컨테이너와 영구 Volume은 변경하지 않았다. 일회용 컨테이너에는 호스트 테스트 접속을
위해 이미지가 생성한 `pg_hba.conf`를 다시 로드했으며, 운영 Secret은 사용하지 않았다.

## 2. Swagger/OpenAPI 실제 HTTP 수동 검증

- 서버: Test Profile, `http://localhost:18089`
- Schema: `docgrid_pr89_swagger`
- 인증: Seed ADMIN 로그인 후 발급한 Bearer Token 사용, Token 값은 기록하지 않음
- Swagger 계약: `GET /v3/api-docs`가 `200 OK`이고 실패 Endpoint의 POST Operation이 존재함

### 2.1 최초 실패와 멱등 재생

최초 요청과 같은 요청을 한 번 더 전송했다.

```http
POST /admin/indexing-jobs/8911/attempts/8911/fail
Authorization: Bearer <admin-token>
Content-Type: application/json

{
"workerId": 8901,
"claimToken": "44444444-4444-4444-8444-444444444444",
"failureType": "STORAGE_UNAVAILABLE",
"errorMessage": "Storage timeout"
}
```

두 요청 모두 `200 OK`였고 전체 응답 JSON이 같았다.

```json
{
"success": true,
"status": 200,
"data": {
"jobId": 8911,
"attemptId": 8911,
"attemptNo": 1,
"attemptStatus": "FAILED",
"failureType": "STORAGE_UNAVAILABLE",
"failedAt": "2026-08-03T11:01:33.1175",
"durationMs": 25068
},
"timestamp": "2026-08-03 11:01:33"
}
```

### 2.2 요청 검증 실패

```http
POST /admin/indexing-jobs/8901/attempts/8901/fail
Authorization: Bearer <admin-token>
Content-Type: application/json

{
"workerId": 8901,
"claimToken": "11111111-1111-4111-8111-111111111111",
"failureType": "STORAGE_UNAVAILABLE",
"errorMessage": ""
}
```

결과: `400 Bad Request`, `COMMON-002`, `errorMessage: 공백일 수 없습니다`.

### 2.3 소유권 충돌

Job을 소유한 Worker `8901` 대신 `workerId=8902`로 요청했다.

```http
POST /admin/indexing-jobs/8902/attempts/8902/fail
Authorization: Bearer <admin-token>
Content-Type: application/json

{
"workerId": 8902,
"claimToken": "22222222-2222-4222-8222-222222222222",
"failureType": "STORAGE_UNAVAILABLE",
"errorMessage": "Storage timeout"
}
```

결과: `409 Conflict`, `EMBEDDING-JOB-003`,
`현재 Embedding Job 소유권과 요청이 일치하지 않습니다.`

### 2.4 상태 충돌

`PENDING` Job에 남겨 둔 `STARTED` Attempt로 실패를 요청했다.

```http
POST /admin/indexing-jobs/8903/attempts/8903/fail
Authorization: Bearer <admin-token>
Content-Type: application/json

{
"workerId": 8901,
"claimToken": "33333333-3333-4333-8333-333333333333",
"failureType": "STORAGE_UNAVAILABLE",
"errorMessage": "Storage timeout"
}
```

결과: `409 Conflict`, `EMBEDDING-JOB-002`,
`현재 상태에서는 Embedding Job Attempt를 시작할 수 없습니다.`

## 3. 신규 PostgreSQL 통합 검증

실행 명령의 비밀 값은 placeholder로 대체한다.

```bash
DB_HOST=localhost \
DB_PORT=55433 \
DB_NAME=docgrid \
DB_USER=docgrid \
DB_PASSWORD='<local-test-password>' \
DB_SSLMODE=disable \
JWT_SECRET='<64-char-test-secret>' \
./gradlew test \
--tests 'com.opensource.docgrid.domain.embedding.integration.DocumentIndexingFailureIntegrationTest'
```

결과: `BUILD SUCCESSFUL`, 5개 테스트 통과.

| 검증 항목 | 결과 |
| --- | --- |
| `next_retry_at` 이전 Queue 선택 제외 | 통과 |
| 정확한 예약 시각의 Queue 선택 허용 | 통과 |
| 동일 실패 동시 요청의 단일 Retry·동일 응답 수렴 | 통과 |
| 새 Version 최종 실패 시 이전 INDEXED 검색 Set 보존 | 통과 |
| 완료와 실패 동시 요청의 단일 상태 전이 | 통과 |
| RETRY 이벤트 Insert 실패 시 전체 Transaction Rollback | 통과 |

## 4. 전체 회귀 검증

```bash
DB_HOST=localhost \
DB_PORT=55433 \
DB_NAME=docgrid \
DB_USER=docgrid \
DB_PASSWORD='<local-test-password>' \
DB_SSLMODE=disable \
JWT_SECRET='<64-char-test-secret>' \
./gradlew test
```

결과: `BUILD SUCCESSFUL`, 514개 테스트 통과, 실패 0, Skip 0.

프로젝트 설정에 따라 `benchmark`, `minio-integration`, `claim-concurrency` Tag는 기본 `test`에서
제외됐다. 이번 변경과 직접 관련된 SKIP LOCKED 동시성은 아래 전용 Task로 추가 검증했다.

```bash
DB_HOST=localhost \
DB_PORT=55433 \
DB_NAME=docgrid \
DB_USER=docgrid \
DB_PASSWORD='<local-test-password>' \
DB_SSLMODE=disable \
JWT_SECRET='<64-char-test-secret>' \
./gradlew claimConcurrencyTest
```

결과: `BUILD SUCCESSFUL`, 2개 테스트 통과, 실패 0, Skip 0.

## 5. 확인된 불변식

- Retry 가능 여부는 요청 Boolean이 아니라 `IndexingFailureType` 서버 정책으로 결정된다.
- `max_retry_count`는 최초 실행 이후 허용할 Retry 횟수로 동작한다.
- Retry 예약은 Version과 Document 상태를 되돌리지 않고 현재 재개 지점을 보존한다.
- Retry 예약 시 이전 Worker, Claim Token과 Lease가 제거된다.
- 동일 실패 요청은 Retry 횟수와 이벤트를 중복 생성하지 않는다.
- 영구 실패 또는 Retry 소진 시 대상 Version의 ACTIVE Embedding은 STALE이 된다.
- 이전 INDEXED Version이 있으면 Document 상태와 현재 검색 포인터는 유지된다.
- 완료와 실패는 Job 행 잠금에서 직렬화되며 한쪽 상태만 커밋된다.
- 이벤트 저장 실패는 Attempt, Job, Version과 Document 변경을 모두 Rollback한다.
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ public void markIndexed(LocalDateTime indexedAt) {
}

public void markFailed() {
// 처리 중인 Version만 실패할 수 있고 완료되거나 이미 실패한 결과는 덮어쓰지 않는다.
if (status == DocumentVersionStatus.INDEXED || status == DocumentVersionStatus.FAILED) {
throw new IllegalStateException("처리 중인 문서 버전만 FAILED로 전환할 수 있습니다.");
}
this.status = DocumentVersionStatus.FAILED;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,22 @@
import org.springframework.web.bind.annotation.RestController;

import com.opensource.docgrid.domain.embedding.dto.request.CompleteDocumentIndexingRequest;
import com.opensource.docgrid.domain.embedding.dto.request.FailDocumentIndexingRequest;
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.DocumentIndexingFailureResponse;
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.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.EmbeddingJobAttemptService.StartResult;
import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobClaimService;
Expand All @@ -43,7 +46,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 @@ -60,6 +63,7 @@ public class IndexingJobAdminController {
private final DocumentParsingService documentParsingService;
private final DocumentEmbeddingService documentEmbeddingService;
private final DocumentIndexingCompletionService documentIndexingCompletionService;
private final DocumentIndexingFailureService documentIndexingFailureService;

@Operation(
summary = "PENDING Job Claim",
Expand Down Expand Up @@ -355,4 +359,55 @@ public ResponseEntity<ApiResponse<DocumentIndexingCompletionResponse>> completeI
documentIndexingCompletionService.complete(jobId, attemptId, request)
);
}

@Operation(
summary = "Document 인덱싱 실패",
description = "현재 PROCESSING Job의 소유권과 Attempt를 검증하고 서버 실패 유형 정책에 따라 "
+ "지연 Retry를 예약하거나 Version과 Job을 최종 실패로 종료합니다. "
+ "같은 실패 실행의 재요청은 저장된 최초 Attempt 결과를 멱등 재생합니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "Document 인덱싱 최초 실패 기록 또는 기존 실패 결과 재생"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "ID, Worker, 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 = "현재 소유권, Lease, Attempt 또는 기존 실패 내용 충돌",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "500",
description = "Version, Document 또는 실패 이력 데이터 불일치",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
)
})
@PostMapping(
value = "/{jobId}/attempts/{attemptId}/fail",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE
)
public ResponseEntity<ApiResponse<DocumentIndexingFailureResponse>> failIndexing(
@PathVariable @Positive Long jobId,
@PathVariable @Positive Long attemptId,
@Valid @RequestBody FailDocumentIndexingRequest request
) {
// 최초 실패와 멱등 재생 모두 같은 Attempt 기반 실패 응답을 200 OK로 반환한다.
return ResponseUtils.ok(documentIndexingFailureService.fail(jobId, attemptId, request));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@

import org.springframework.stereotype.Component;

import com.opensource.docgrid.domain.embedding.dto.response.DocumentIndexingFailureResponse;
import com.opensource.docgrid.domain.embedding.dto.response.StartedEmbeddingJobAttemptResponse;
import com.opensource.docgrid.domain.embedding.enums.IndexingFailureType;
import com.opensource.docgrid.domain.worker.entity.EmbeddingJobAttempt;
import com.opensource.docgrid.global.exception.DocGridException;
import com.opensource.docgrid.global.exception.ErrorCode;

/**
* Embedding Job Attempt Entity를 시작 결과 API DTO로 변환하는 Converter.
* Embedding Job Attempt Entity를 시작 또는 실패 결과 API DTO로 변환하는 Converter.
*
* <p>Transaction 안에서 LAZY Job·Worker의 식별자만 추출하고 Entity, Claim Token, 내부 오류 정보는
* Controller 경계 밖으로 노출하지 않는다.
Expand All @@ -25,4 +29,26 @@ public StartedEmbeddingJobAttemptResponse toStartedResponse(EmbeddingJobAttempt
embeddingJobAttempt.getStartedAt()
);
}

public DocumentIndexingFailureResponse toFailureResponse(EmbeddingJobAttempt embeddingJobAttempt) {
// 실패 유형은 서버가 저장한 제한된 Enum 이름만 해석하며 자유 형식 오류 메시지는 노출하지 않는다.
return new DocumentIndexingFailureResponse(
embeddingJobAttempt.getEmbeddingJob().getId(),
embeddingJobAttempt.getId(),
embeddingJobAttempt.getAttemptNo(),
embeddingJobAttempt.getStatus(),
toFailureType(embeddingJobAttempt.getErrorCode()),
embeddingJobAttempt.getEndedAt(),
embeddingJobAttempt.getDurationMs()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);
}

private IndexingFailureType toFailureType(String errorCode) {
try {
return IndexingFailureType.valueOf(errorCode);
} catch (IllegalArgumentException | NullPointerException exception) {
// 수동 변경이나 이전 데이터의 알 수 없는 코드를 임의 유형으로 오인하지 않고 불일치로 드러낸다.
throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_FAILURE_INCONSISTENT);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.opensource.docgrid.domain.embedding.dto.request;

import com.opensource.docgrid.domain.embedding.enums.IndexingFailureType;

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>호출자는 제한된 실패 유형과 진단 메시지만 전달하며, Retry 여부와 다음 실행 시각은 서버 정책이
* 결정한다. Claim Token과 오류 메시지는 실패 응답에 다시 노출하지 않는다.
*/
public record FailDocumentIndexingRequest(
@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,

@Schema(description = "서버 Retry 정책에 연결되는 인덱싱 실패 유형",
example = "EMBEDDING_PROVIDER_UNAVAILABLE")
@NotNull
IndexingFailureType failureType,

@Schema(description = "비밀정보와 원문을 제외한 진단 메시지", example = "Embedding provider request timed out")
@NotBlank
@Size(max = 2000)
String errorMessage
) {
}
Loading