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
249 changes: 249 additions & 0 deletions docs/design/Gimini-3-#108-manual-retry-failed-indexing-job.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
# Issue #108 최종 실패 인덱싱 Job 수동 재처리 상세 설계

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

private sequence label을 제거하십시오.

전제: Gimini-33은 private PR sequence label입니다. 파일명과 제목은 실제 이슈 번호만 사용해야 합니다. 파일명을 {github아이디}-#108-manual-retry-failed-indexing-job.md 형식으로 바꾸고 제목에서도 Gimini-3을 제거하십시오.

As per coding guidelines, "Never expose private numbered PR sequence labels" and "docs/design/*.md: PR design documents must use the {github아이디}-#{이슈번호}-{설명}.md naming convention."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/design/Gimini-3-`#108-manual-retry-failed-indexing-job.md at line 1,
Remove the private sequence label “Gimini-3” from the document title and rename
the file to the {github아이디}-#108-manual-retry-failed-indexing-job.md convention,
preserving the issue number and description.

Source: Coding guidelines


closes #108

## 1. 문서 목적

이 문서는 이슈 [#108](https://github.com/DocGrid/backend/issues/108)의 구현 기준을 정의한다.

인덱싱 Job은 실패 유형이 재시도 가능하고 남은 횟수가 있을 때만 `PENDING` Queue로 재예약된다. 재시도
가능 횟수를 모두 소진했거나 재시도 불가 유형으로 종료된 Job은 `FAILED`로 종결되고, 대상 Document
Version은 `FAILED`, 그 Version의 Embedding Set은 `STALE`이 되어 검색에서 제외된다.

`FAILED` Job을 다시 처리할 경로는 현재 존재하지 않는다. Claim은 `PENDING`만, Lease 만료 복구는
`PROCESSING`만 후보로 삼기 때문에 어떤 자동 경로도 `FAILED` Job을 되살리지 않는다. 외부 Embedding
서버 장애나 일시적인 Storage 장애처럼 원인이 이미 해소된 뒤에도 같은 문서를 다시 인덱싱하려면 새
Version을 업로드하는 방법밖에 없다.

이 작업은 최종 실패로 종결된 Job만 관리자가 명시적으로 Queue에 되돌릴 수 있는 수동 재처리 경로를
추가한다.

### 1.1 성공 기준

- 최종 `FAILED` Job만 수동 재처리 대상이 된다.
- 처리 중이거나 자동 재시도가 예정된 Job은 명시적으로 거부한다.
- 최신 처리 대상 Version이 아니면 거부한다.
- 현재 검색 가능한 이전 Version과 `current_version` 포인터를 보존한다.
- 이전 Worker, Claim Token, Lease 등 소유권 정보를 초기화한다.
- Retry Count와 기존 Attempt 이력을 삭제하지 않는다.
- 수동 재처리를 새 상태 전이와 감사 Event로 남긴다.
- 기존 Chunk를 무조건 삭제하지 않는다.
- 중복 요청과 동시 요청이 하나의 상태 전이로 수렴한다.
- Lease 만료 복구 및 자동 재시도와 경합하지 않는다.
- 관리자 권한을 요구하고 Claim Token 등 민감 정보를 응답에 노출하지 않는다.

## 2. 범위

### 2.1 포함

- 최종 실패 Job 한 건을 다시 Queue에 넣는 관리자 API
- Job, Document Version, Document의 재처리 상태 전이
- 재개 지점 결정과 대상 Version Embedding 정리
- 수동 재처리 감사 Event 기록

### 2.2 제외

- 재처리 대상 Job 목록·상세 조회 API (관리자 인덱싱 조회 작업에서 진행)
- 자동 재시도 정책과 Backoff 계산 변경
- 여러 Job을 한 번에 재처리하는 Batch API
- 재처리 예약, 스케줄링, 자동 트리거
- OCR 등 실패 원인 자체를 해결하는 파싱 기능

## 3. 현재 구조 분석

### 3.1 상태 모델

`EmbeddingJobStatus`는 `PENDING`, `PROCESSING`, `INDEXED`, `FAILED`, `CANCELED`로 구성된다. 별도의
재시도 예약 상태는 없고, 자동 재시도가 예정된 Job은 `PENDING` + 미래의 `next_retry_at`으로 표현된다.
Claim Query는 `next_retry_at IS NULL OR next_retry_at <= :claimedAt` 조건을 사용하므로 예약 시각 전에는
후보가 되지 않는다.

### 3.2 최종 실패 시점의 데이터 상태

`IndexingFailureTransitionService`의 최종 실패 경로는 하나의 Transaction에서 다음을 수행한다.

1. 대상 Version의 `ACTIVE` Embedding을 모두 `STALE`로 전환
2. `document_versions.status`를 `FAILED`로 전환
3. 이전 `INDEXED` Version이 현재 검색 대상이면 Document를 그대로 두고, 아니면 `FAILED`로 전환
4. `embedding_jobs.status`를 `FAILED`로 전환하고 `failed_at`, 오류 Snapshot 기록
5. 단계 실패 Event와 `FAILED` Event를 같은 시각으로 append

`markFailed`는 `locked_by_worker_id`, `claim_token`, `locked_at`, `lock_expires_at`을 감사 목적으로
남긴다. `document_chunks`는 삭제하지 않는다.

### 3.3 재개 지점 계약

파이프라인 각 단계는 Version 상태로 재개 지점을 판단한다.

| Version 상태 | 동작 |
|---|---|
| `UPLOADED`, `PARSING` | 원본을 다시 읽어 파싱하고 Chunk Set 저장 |
| `CHUNKED` | 파싱을 생략하고 Embedding 생성 |
| `EMBEDDING` | 저장된 Embedding 수에 따라 재생 또는 재작업 |

Chunk Set 저장과 `CHUNKED` 전이는 같은 Transaction에서 일어나므로 Chunk가 존재하면 항상 완전한
Set이다. 반면 `CHUNKED` 상태에서 대상 Version·Model의 Embedding 행이 0이 아니면
`DOCUMENT_EMBEDDINGS_INCONSISTENT`로 차단된다. 따라서 최종 실패가 남긴 `STALE` Embedding을 정리하지
않으면 재처리 자체가 불가능하다.

### 3.4 검색 보호 장치

Vector 검색 Query는 `e.status = 'ACTIVE' AND d.status = 'INDEXED' AND d.current_version_id =
e.document_version_id` 조건을 사용한다. 실패한 Version의 Embedding은 `STALE`이므로 원래 검색에 노출될
수 없고, 이전 `INDEXED` Version은 `current_version_id`가 유지되는 한 계속 검색된다.

## 4. 설계

### 4.1 상태 전이 계약

```text
사전조건: embedding_jobs.status = FAILED
document_versions = 해당 문서의 최신 Version, status = FAILED
documents.deleted_at IS NULL
documents.status ∈ {UPLOADED, INDEXING, INDEXED, FAILED}
같은 Version에 PENDING/PROCESSING Job 없음

전이: Job: FAILED -> PENDING, next_retry_at = NULL
locked_by_worker_id, claim_token, locked_at, lock_expires_at, failed_at = NULL
retry_count, max_retry_count, error_code, error_message 보존
Version: FAILED -> CHUNKED (Chunk가 이미 있는 경우)
-> UPLOADED (Chunk가 없는 경우)
Document: 이전 INDEXED Version이 현재 검색 대상이면 변경 없음
그 외에는 INDEXING
Event: MANUAL_RETRY (FAILED -> PENDING) 1건 append
Attempt: 변경 없음
```

`retry_count`를 유지하므로 수동 재처리는 추가 실행 1회만 부여한다. 이번 실행이 다시 실패하면
`hasRemainingRetries()`가 거짓이 되어 자동 재시도 없이 즉시 최종 실패로 종결되고, 필요하면 관리자가
다시 수동 재처리를 요청한다. 이 선택은 재시도 이력을 지우지 않으면서 무한 자동 재시도를 만들지
않기 위한 것이다.

### 4.2 Chunk와 Embedding 처리 정책

- Chunk는 삭제하지 않는다. 존재하면 완전한 Set이므로 파싱을 생략하고 재사용한다.
- 대상 Version의 Embedding 행만 삭제한다. 최종 실패 시점에 이미 `STALE`이라 검색에 노출되지 않으며,
남겨두면 Embedding 개수 불변식 검증에서 재처리가 차단된다.
- 다른 Version의 Chunk와 Embedding은 조회하지도 변경하지도 않는다.

실패한 실행이 남긴 Vector를 다시 `ACTIVE`로 되살리는 방식은 채택하지 않았다. 완료 검증 단계에서
실패한 경우 그 Vector Set이 실제로 불완전할 수 있고, 이를 판별하려면 완료 Transaction과 같은 수준의
검증을 재처리 경로에 중복 구현해야 하기 때문이다.

### 4.3 Transaction 경계와 잠금 순서

`EmbeddingJobManualRetryService`는 단일 `@Transactional` 경계에서 외부 I/O 없이 동작한다.

1. `findByIdForUpdate`로 Job 행을 잠근다.
2. Job 상태가 `FAILED`인지 확인한다.
3. Version, Document를 기존 경로와 같은 순서로 잠근다.
4. 재처리 대상 조건을 모두 검증한다.
5. 재개 지점을 정하고 대상 Version Embedding을 삭제한다.
6. Job, Version, Document 상태를 바꾸고 `MANUAL_RETRY` Event를 append한다.

Job 행 잠금이 Claim, 완료, 협력적 실패, Lease 복구와의 단일 직렬화 지점이다. Lease 복구는
`PROCESSING` + 만료 행만, Claim은 `PENDING` 행만 후보로 삼으므로 커밋 전에는 이 Transaction과 경합하지
않고, 커밋 후에는 정상 Claim 경로로 흡수된다.

### 4.4 API 계약

```text
POST /admin/indexing-jobs/{jobId}/retry
Request Body 없음, ADMIN 권한 필요

200 OK
{
"success": true,
"data": {
"jobId": 10,
"status": "PENDING",
"documentId": 3,
"documentVersionId": 5,
"documentVersionStatus": "CHUNKED",
"retryCount": 3,
"maxRetryCount": 3,
"requeuedAt": "2026-08-06T15:00:00"
}
}
```

`/admin/**`은 `SecurityConfig`에서 이미 `hasRole("ADMIN")`으로 보호되므로 Security 설정은 변경하지
않는다. 응답에는 Claim Token, 실패 원인 상세, 내부 예외 정보를 포함하지 않는다.

## 5. 오류 케이스

| 상황 | HTTP | 코드 |
|---|---|---|
| Job 없음 | 404 | `EMBEDDING-JOB-001` |
| Job이 `PENDING`·`PROCESSING`·`INDEXED`·`CANCELED` (중복 요청 포함) | 409 | `EMBEDDING-JOB-008` |
| 최신 Version이 아님 | 409 | `EMBEDDING-JOB-009` |
| 삭제된 문서이거나 재처리 불가 문서 상태 | 409 | `EMBEDDING-JOB-009` |
| 같은 Version에 살아 있는 Job 존재 | 409 | `EMBEDDING-JOB-009` |
| Version이 `FAILED`가 아니거나 현재 Version 포인터 불일치 | 500 | `DOCUMENT-INDEXING-004` |
| Job ID가 양수가 아님 | 400 | `COMMON-002` |

중복 요청은 멱등 재생 대신 명시적 충돌로 처리한다. 현재 Schema에는 `PENDING` Job이 자동 재시도
예약인지 수동 재처리 결과인지 구분하는 식별자가 없어, 멱등 재생을 지원하려면 추가 Column이나 Event
조회가 필요하기 때문이다.

## 6. 테스트 설계

### 6.1 단위 테스트

`EmbeddingJobManualRetryServiceTest` (Mockito)

- Chunk 존재 시 `CHUNKED` 재개, 미존재 시 `UPLOADED` 재개
- 소유권 필드와 종료 시각 초기화, `retry_count` 보존
- Claim Token 없는 `MANUAL_RETRY` Event 기록
- 이전 `INDEXED` Version이 있을 때 문서 상태·포인터 보존
- Job 없음, `PENDING`·`PROCESSING`·`INDEXED` 거부
- 최신 Version 아님, 삭제된 문서, 살아 있는 Job 존재 거부
- Version이 `FAILED`가 아닐 때 불변식 오류

### 6.2 Controller 테스트

`IndexingJobAdminControllerTest` (`@WebMvcTest`)

- 정상 응답 필드와 민감 정보 미노출
- Job ID Validation
- 정의된 오류 코드와 HTTP 상태 매핑
- ADMIN 외 사용자와 미인증 요청 차단

### 6.3 통합 테스트

`EmbeddingJobManualRetryIntegrationTest` (`@Tag("integration")`, 실제 PostgreSQL)

- 소유권 초기화 후 즉시 Claim 후보가 되는지 확인
- Chunk 유지와 대상 Version Embedding 삭제
- Chunk 없는 Job의 `UPLOADED` 재개
- Attempt 이력·재시도 횟수 보존과 `MANUAL_RETRY` Event 1건
- 이전 `INDEXED` Version의 검색 결과와 현재 포인터 보존
- 동시 요청 2건이 전이 1회 + 충돌 1회로 수렴
- 자동 재시도 예정 Job 거부 시 예약 유지
- 최신 Version이 아닐 때 거부하고 기존 데이터 유지

## 7. 커밋 분할

1. `feat: #108 최종 실패 Job 수동 재처리 도메인 규칙 추가`
2. `feat: #108 수동 재처리 대상 Embedding 삭제 쿼리 추가`
3. `feat: #108 최종 실패 Job 수동 재처리 Command Service 구현`
4. `feat: #108 관리자 수동 재처리 API 추가`
5. `test: #108 수동 재처리 단위·Controller 테스트 추가`
6. `test: #108 수동 재처리 PostgreSQL 통합 테스트와 검증 결과 추가`
7. `docs: #108 최종 실패 Job 수동 재처리 설계 문서 추가`

## 8. 완료 조건

- 최종 `FAILED` Job만 수동 재처리 가능
- `PENDING`·`PROCESSING`·`INDEXED`·`CANCELED` 거부
- 현재 검색 가능한 Version 유지
- Attempt와 Retry 감사 이력 유지
- 소유권 정보 초기화
- 동시 재처리 요청이 하나의 상태 전이로 수렴
- 전체 회귀 테스트 통과

## 9. 참고

- Flyway 마이그레이션 없음. `indexing_events.event_type`은 CHECK 제약이 없는 `VARCHAR(30)`이라
`MANUAL_RETRY` 값을 그대로 저장할 수 있다.
- `SecurityConfig` 변경 없음.
148 changes: 148 additions & 0 deletions docs/test-results/Gimini-3-#108-manual-retry-failed-indexing-job.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# #108 최종 실패 인덱싱 Job 수동 재처리 검증 결과

## 1. 검증 정보

- 실행일: 2026-08-06 (Asia/Seoul)
- 대상 브랜치: `feature/108`
- 애플리케이션: Spring Boot 3.5.16, Java 17
- 데이터베이스: 로컬 PostgreSQL 17.8 + pgvector 0.8.1 컨테이너
- 검증 범위: 전체 회귀 Test와 수동 재처리 전용 단위·Controller·PostgreSQL 통합 Test
- 최종 결과: 653개 통과, 실패·오류·Skip 0개

Test Class별 격리 Schema와 Flyway Migration을 사용했고, 기존 개발 데이터는 변경하지 않았다. DB 접속
정보와 인증 값은 실행 Process 환경변수로만 주입했으며 실제 값은 기록하지 않는다.

## 2. 전체 회귀 검증

실행 명령의 환경 값은 Placeholder로 대체한다.

```bash
DB_PORT='<local-test-port>' \
DB_SSLMODE=disable \
JWT_SECRET='<test-only-secret>' \
MINIO_ENDPOINT='<local-test-endpoint>' \
MINIO_ACCESS_KEY='<local-test-value>' \
MINIO_SECRET_KEY='<local-test-value>' \
MINIO_BUCKET='<local-test-bucket>' \
./gradlew test
```

결과:

```text
BUILD SUCCESSFUL
tests=653 failures=0 errors=0 skipped=0
```

같은 명령을 `develop`(`e1bd2d2`)에서 실행한 기준 Test 수는 625개이며, 이번 작업으로 단위 13개, 통합
8개, Controller 7개가 추가되어 653개가 됐다.

```text
develop : tests=625 failures=0 errors=0
feature : tests=653 failures=0 errors=0 (625 + 28)
```

## 3. 단위 검증

`EmbeddingJobManualRetryServiceTest` 13개 통과.

| 시나리오 | 기대 | 결과 |
|---|---|---|
| Chunk가 있는 최종 실패 Job | `CHUNKED` 재개, 문서 `INDEXING` | 통과 |
| Chunk가 없는 최종 실패 Job | `UPLOADED` 재개 | 통과 |
| 재처리 후 소유권과 재시도 이력 | Worker·Token·Lease·`failed_at` 모두 `null`, `retry_count = 3` 유지, 잔여 자동 재시도 없음 | 통과 |
| 감사 Event | `MANUAL_RETRY` 1건, Claim Token 미포함 | 통과 |
| 이전 `INDEXED` Version 존재 | 문서 상태·현재 포인터 보존 | 통과 |
| Job 없음 | `EMBEDDING_JOB_NOT_FOUND` | 통과 |
| `PENDING` Job | `EMBEDDING_JOB_MANUAL_RETRY_NOT_ALLOWED` | 통과 |
| `PROCESSING` Job | `EMBEDDING_JOB_MANUAL_RETRY_NOT_ALLOWED` | 통과 |
| `INDEXED` Job | `EMBEDDING_JOB_MANUAL_RETRY_NOT_ALLOWED` | 통과 |
| 최신 Version 아님 | `EMBEDDING_JOB_MANUAL_RETRY_TARGET_INVALID`, 상태 변경 없음 | 통과 |
| 삭제된 문서 | `EMBEDDING_JOB_MANUAL_RETRY_TARGET_INVALID` | 통과 |
| 같은 Version에 살아 있는 Job | `EMBEDDING_JOB_MANUAL_RETRY_TARGET_INVALID` | 통과 |
| Version이 `FAILED`가 아님 | `DOCUMENT_INDEXING_FAILURE_INCONSISTENT` | 통과 |

## 4. Controller 계약 검증

`IndexingJobAdminControllerTest`의 수동 재처리 Test 7개 통과.

| 시나리오 | 기대 | 결과 |
|---|---|---|
| ADMIN 정상 요청 | 200, 재개 지점 포함, `claimToken`·`errorMessage` 미노출 | 통과 |
| `jobId = 0` | 400 `COMMON-002` | 통과 |
| Job 없음 | 404 `EMBEDDING-JOB-001` | 통과 |
| 최종 실패 Job 아님 | 409 `EMBEDDING-JOB-008` | 통과 |
| 대상 조건 불충족 | 409 `EMBEDDING-JOB-009` | 통과 |
| 종료 데이터 불일치 | 500 `DOCUMENT-INDEXING-004` | 통과 |
| USER 권한·미인증 | 403 | 통과 |

## 5. PostgreSQL 통합 검증

실행:

```bash
DB_PORT='<local-test-port>' \
DB_SSLMODE=disable \
JWT_SECRET='<test-only-secret>' \
MINIO_ENDPOINT='<local-test-endpoint>' \
MINIO_ACCESS_KEY='<local-test-value>' \
MINIO_SECRET_KEY='<local-test-value>' \
MINIO_BUCKET='<local-test-bucket>' \
./gradlew test \
--tests 'com.opensource.docgrid.domain.embedding.integration.EmbeddingJobManualRetryIntegrationTest'
```

결과:

```text
tests=8 failures=0 errors=0 skipped=0
```

### 5.1 재현 절차

각 Test는 격리 Schema에 다음 상태를 직접 구성한 뒤 실제 Service Transaction을 호출한다.

```text
embedding_jobs status=FAILED, retry_count=3, max_retry_count=3,
locked_by_worker_id / claim_token / lock_expires_at / failed_at 존재
embedding_job_attempts status=FAILED (attempt_no=1)
indexing_events FAILED 1건
document_versions status=FAILED
embeddings status=STALE (대상 Version)
documents status=FAILED 또는 이전 INDEXED Version 보유
```

### 5.2 시나리오별 결과

| 시나리오 | 확인 항목 | 결과 |
|---|---|---|
| 최종 실패 Job 재처리 | `status=PENDING`, 소유권 4개 Column과 `failed_at`·`next_retry_at` `null`, `findNextPendingForUpdate`가 즉시 해당 Job 반환 | 통과 |
| Chunk 유지·Embedding 정리 | Version `CHUNKED`, `document_chunks` 1건 유지, 대상 Version `embeddings` 0건, 문서 `INDEXING` | 통과 |
| Chunk 없는 Job | Version `UPLOADED` | 통과 |
| 감사 이력 | `retry_count=3`, Attempt 1건 `FAILED` 유지, `FAILED` Event 1건 유지, `MANUAL_RETRY` Event 1건 추가, metadata에 Claim Token 없음 | 통과 |
| 이전 검색 Version 보호 | 문서 `INDEXED` 유지, `current_version_id`가 이전 Version, 이전 Version Embedding `ACTIVE`, 재처리 전후 Vector 검색 결과가 모두 `이전 검색 본문` | 통과 |
| 동시 요청 2건 | 커밋 1건, 나머지 1건 `EMBEDDING_JOB_MANUAL_RETRY_NOT_ALLOWED`, `MANUAL_RETRY` Event 정확히 1건, 대상 Embedding 0건 | 통과 |
| 자동 재시도 예정 Job | 409로 거부, `next_retry_at` 유지, Version `FAILED` 유지, `MANUAL_RETRY` Event 0건 | 통과 |
| 최신 Version 아님 | 409로 거부, Job `FAILED` 유지, 대상 Embedding 1건 그대로 유지 | 통과 |

## 6. Swagger 수동 검증

이번 작업에서는 Swagger 수동 검증을 수행하지 않았다. 수동 재처리 API는 `hasRole("ADMIN")`으로 보호되며
검증하려면 ADMIN 계정 생성과 JWT 발급, 그리고 자동 재시도를 모두 소진한 최종 실패 Job을 실제로
만들어야 한다. 이 사전 상태는 현재 로컬에서 실제 문서 업로드부터 Worker 실행까지 전 구간을 돌려야
재현할 수 있어, 예정된 로컬 전체 관통 E2E 작업에서 함께 수행하는 것이 적절하다.

대체 검증으로 다음 두 계층을 사용했다.

- HTTP 계약: `@WebMvcTest` 기반 Controller Test로 상태 코드, 응답 필드, 민감 정보 미노출, ADMIN 권한
차단을 확인
- 실제 DB 동작: 실제 PostgreSQL 통합 Test로 상태 전이 원자성, 검색 보호, 동시 요청 수렴을 확인

## 7. 결론

- 최종 실패 Job만 수동 재처리 가능하고 나머지 상태는 모두 거부한다.
- 현재 검색 가능한 Version은 재처리 전후 동일한 검색 결과를 유지한다.
- Attempt와 재시도 감사 이력을 삭제하지 않는다.
- 소유권 정보가 초기화되어 과거 Claim Token으로는 후속 단계를 수행할 수 없다.
- 동시 재처리 요청은 하나의 상태 전이로 수렴한다.
- 전체 653개 Test가 실패 없이 통과한다.
Loading