diff --git a/docs/design/Gimini-3-#88-document-indexing-failure-retry.md b/docs/design/Gimini-3-#88-document-indexing-failure-retry.md new file mode 100644 index 0000000..1d51679 --- /dev/null +++ b/docs/design/Gimini-3-#88-document-indexing-failure-retry.md @@ -0,0 +1,511 @@ +# Issue #88 인덱싱 실패 종료 및 지연 재시도 상세 설계 + +closes #88 + +## 1. 목적 + +문서 인덱싱 성공 경로는 현재 Claim과 Attempt를 검증한 뒤 Job, Version, Document를 하나의 +Transaction에서 `INDEXED`로 확정한다. 그러나 파싱, Embedding 생성 또는 완료 단계에서 오류가 발생하면 +호출자에게 오류 응답만 반환되고 다음 상태가 남는다. + +```text +EmbeddingJobAttempt = STARTED +EmbeddingJob = PROCESSING +DocumentVersion = PARSING 또는 EMBEDDING +lock_expires_at = 최초 Claim 시각 + 고정 Lease +``` + +이 상태에서는 같은 Job을 다시 Claim할 수 없고, 오류 원인과 시도 종료 시각도 기록되지 않는다. 기존 +Schema와 Enum에는 `retry_count`, `max_retry_count`, `failed_at`, `error_code`, `error_message`, +`PARSE_FAILED`, `EMBEDDING_FAILED`, `RETRY`, `FAILED`가 이미 존재하지만 이를 연결하는 Use Case가 없다. + +이 이슈의 목적은 현재 Claim을 소유한 Worker가 실패를 명시적으로 보고했을 때 실행 Attempt를 정확히 +한 번 종료하고, 서버가 실패 유형과 남은 횟수를 기준으로 지연 재시도 또는 최종 실패를 결정하는 것이다. + +## 2. 범위 + +### 2.1 포함 범위 + +- 관리자용 인덱싱 실패 종료 API +- Worker ID, Claim Token, Lease와 Attempt 실행 Context 검증 +- 제한된 실패 유형과 서버 소유 재시도 정책 +- Attempt `STARTED -> FAILED` 전이와 오류 원인·종료 시각·소요 시간 저장 +- Job `PROCESSING -> PENDING` 지연 재시도 전이 +- Job `PROCESSING -> FAILED` 최종 실패 전이 +- `retry_count` 증가와 `next_retry_at` 계산 +- 재시도 시 현재 Worker, Claim Token과 Lease 해제 +- 최종 실패 시 Version `FAILED` 전이 +- 검색 가능한 current Version 존재 여부에 따른 Document 상태 결정 +- 최종 실패 Version의 `ACTIVE` Embedding `STALE` 전환 +- 단계별 실패, 재시도와 최종 실패 이벤트 저장 +- 동일 실패 요청의 멱등 재생 +- 완료 요청과 실패 요청의 동시성 제어 +- 실제 PostgreSQL의 실행 가능 시각, 행 잠금, Rollback 검증 + +### 2.2 제외 범위 + +- Lease 연장 API와 주기적인 Lease 갱신 +- 만료 Lease 자동 회수 +- DEAD Worker가 소유한 Job 복구 +- Worker 자동 Polling Loop +- 관리자 수동 재시도·취소 API +- Retry Jitter +- Chunk 또는 Vector 단위 Checkpoint와 부분 재개 +- 실패 이력 조회 Dashboard와 Metric + +이번 기능은 유효한 Lease를 가진 Worker가 협력적으로 보고한 실패만 처리한다. Worker가 죽었거나 첫 실패 +보고 전에 Lease가 만료된 경우는 후속 Lease Recovery 기능이 담당한다. + +## 3. 핵심 결정 + +### 3.1 Worker가 재시도 여부를 직접 결정하지 않는다 + +요청에 `retryable: true`를 허용하면 호출자가 영구적인 데이터 오류를 무한히 재시도하거나 일시적인 +인프라 오류를 즉시 종결할 수 있다. API는 제한된 `failureType`만 받고 서버 Enum이 재시도 가능 여부를 +소유한다. + +| failureType | 의미 | 재시도 | +| --- | --- | --- | +| `STORAGE_UNAVAILABLE` | 원본 Object Storage 연결·Timeout | 가능 | +| `DOCUMENT_CONTENT_INVALID` | 지원 불가 형식, 빈 본문, Decode 실패 | 불가 | +| `EMBEDDING_PROVIDER_UNAVAILABLE` | Embedding Provider 연결·Timeout | 가능 | +| `EMBEDDING_RESULT_INVALID` | Vector 개수·차원·값 불일치 | 불가 | +| `INDEXING_STATE_INCONSISTENT` | Job·Version·Chunk·Embedding 불변식 위반 | 불가 | +| `WORKER_INTERNAL_ERROR` | Worker 내부의 일시적인 실행 실패 | 가능 | + +Enum 이름을 `error_code`에 저장한다. 사용자가 입력한 자유 문자열을 정책 결정에 사용하지 않는다. + +### 3.2 `max_retry_count`는 최초 실행 이후 재시도 횟수다 + +`max_retry_count = 3`이면 최초 Attempt를 포함해 최대 네 번 실행할 수 있다. + +```text +Attempt 1 실패 -> retry_count 1, 재시도 예약 +Attempt 2 실패 -> retry_count 2, 재시도 예약 +Attempt 3 실패 -> retry_count 3, 재시도 예약 +Attempt 4 실패 -> Retry 소진, 최종 FAILED +``` + +영구 실패는 현재 `retry_count`와 관계없이 즉시 최종 종료한다. + +### 3.3 Retry는 Scheduler가 아니라 Queue 실행 가능 시각으로 제어한다 + +`embedding_jobs.next_retry_at`을 추가하고 Claim 쿼리가 현재 시각 이후의 PENDING Job을 제외한다. + +```sql +WHERE job.status = 'PENDING' + AND (job.next_retry_at IS NULL OR job.next_retry_at <= :claimedAt) +ORDER BY job.priority DESC, + job.created_at ASC, + job.id ASC +LIMIT 1 +FOR UPDATE SKIP LOCKED +``` + +새 Job의 `next_retry_at`은 null이며 즉시 실행할 수 있다. Retry Job은 예약 시각이 지난 뒤 기존 Claim +API에서 자연스럽게 선택된다. 별도 Scheduler와 중복 Queue 이동 로직을 추가하지 않는다. + +### 3.4 Version 상태를 유지해 기존 멱등 재개 규칙을 사용한다 + +재시도 시 Version을 `UPLOADED`로 되돌리지 않는다. + +| 현재 Version 상태 | 저장 데이터 | 다음 Attempt 동작 | +| --- | --- | --- | +| `UPLOADED` | Chunk 없음 | 파싱 최초 실행 | +| `PARSING` | Chunk 없음 | 파싱 재개 | +| `CHUNKED` | 확정 Chunk Set | Embedding 최초 실행 | +| `EMBEDDING` | Embedding 없음 | Embedding 재실행 | +| `EMBEDDING` | 전체 Embedding Set | Embedding 결과 재생 후 완료 | + +Chunk와 Embedding은 각 완료 Transaction에서 전체 Set을 원자 저장하므로 정상 흐름에는 부분 Set이 남지 +않는다. 부분 Set은 기존 불변식 오류로 드러내며 이번 이슈에서 삭제하거나 보정하지 않는다. + +### 3.5 Attempt 응답만 고정해 장기 멱등 재생을 보장한다 + +실패 요청 이후 Job은 PENDING 재예약, 새 Claim, 성공 완료 등으로 계속 바뀔 수 있다. 따라서 실패 응답에 +현재 Job 상태나 다음 재시도 시각을 포함하면 같은 요청의 재생 결과가 최초 응답과 달라진다. + +응답은 종료된 Attempt에 저장된 값만 포함한다. + +```text +jobId +attemptId +attemptNo +attemptStatus = FAILED +failureType +failedAt +durationMs +``` + +Retry 결정은 Job 조회와 이벤트에서 확인하며 실패 응답 계약에는 포함하지 않는다. + +## 4. API 계약 + +### 4.1 Endpoint + +```http +POST /admin/indexing-jobs/{jobId}/attempts/{attemptId}/fail +Content-Type: application/json +``` + +### 4.2 요청 + +```json +{ + "workerId": 7, + "claimToken": "34c19d16-6ae1-4f6a-a35d-0123456789ab", + "failureType": "EMBEDDING_PROVIDER_UNAVAILABLE", + "errorMessage": "Embedding provider request timed out" +} +``` + +| 필드 | 검증 | +| --- | --- | +| `workerId` | 필수, 양수 | +| `claimToken` | 필수, 36자 이하 canonical UUID | +| `failureType` | 필수 Enum | +| `errorMessage` | 필수, 공백 불가, 최대 2,000자 | + +오류 메시지에는 Stack Trace, Authorization Header, 원본 문서 내용, Bucket/Object Key와 사용자 개인정보를 +넣지 않는다. 서버 로그에도 Claim Token 전체와 오류 메시지 전문을 출력하지 않는다. + +### 4.3 응답 + +```json +{ + "jobId": 10, + "attemptId": 21, + "attemptNo": 2, + "attemptStatus": "FAILED", + "failureType": "EMBEDDING_PROVIDER_UNAVAILABLE", + "failedAt": "2026-08-03T10:30:00", + "durationMs": 42031 +} +``` + +최초 실패와 동일 요청 재생은 모두 `200 OK`다. + +### 4.4 오류 응답 + +| 조건 | HTTP | ErrorCode | +| --- | --- | --- | +| Path 또는 Body 형식 오류 | 400 | `COMMON-002` | +| Job 없음 | 404 | `EMBEDDING-JOB-001` | +| Job 상태가 PROCESSING이 아님 | 409 | `EMBEDDING-JOB-002` | +| Worker 또는 Claim Token 불일치 | 409 | `EMBEDDING-JOB-003` | +| Lease 만료 | 409 | `EMBEDDING-JOB-004` | +| 소유권 데이터 불완전 | 500 | `EMBEDDING-JOB-005` | +| Attempt 실행 Context 불일치 | 409 | `EMBEDDING-JOB-006` | +| 같은 Attempt의 다른 실패 내용 | 409 | 신규 `EMBEDDING-JOB-007` | +| Version·Document 실패 데이터 모순 | 500 | 신규 `DOCUMENT-INDEXING-004` | + +## 5. 데이터 모델 + +### 5.1 Migration + +```sql +ALTER TABLE embedding_jobs + ADD COLUMN next_retry_at TIMESTAMP; + +CREATE INDEX idx_embedding_jobs_status_next_retry_at + ON embedding_jobs (status, next_retry_at); +``` + +기존 PENDING Job은 null이므로 즉시 Claim 가능하다. Migration에서 기존 데이터의 의미를 바꾸지 않는다. + +### 5.2 EmbeddingJob + +추가 필드: + +```java +private LocalDateTime nextRetryAt; +``` + +상태 전이 책임: + +- `scheduleRetry(...)` + - PROCESSING과 남은 Retry 횟수 검증 + - `retryCount` 증가 + - 상태를 PENDING으로 변경 + - `nextRetryAt`과 최근 오류 저장 + - Worker, Token, Lock 시작·만료 시각 제거 +- `markFailed(...)` + - PROCESSING만 최종 FAILED 허용 + - `failedAt`과 오류 저장 + - `nextRetryAt` 제거 +- `claim(...)` + - PENDING 상태에서 새 소유권 기록 + - 소비한 `nextRetryAt` 제거 +- `markIndexed(...)` + - 성공 상태 기록 + - Retry 과정의 최근 오류와 예약 시각 제거 + +`startedAt`은 Job 전체의 최초 처리 시작 시각이므로 재Claim에서도 유지한다. Attempt별 시작·실패 시각은 +`embedding_job_attempts`가 보존한다. + +### 5.3 EmbeddingJobAttempt + +`markFailed(...)`는 `STARTED` 상태만 허용한다. 이미 SUCCESS 또는 FAILED인 Attempt의 값을 덮어쓰지 +않는다. 멱등 재생 판단은 Entity 상태를 변경하기 전에 Service에서 수행한다. + +## 6. 상태 전이 + +### 6.1 재시도 가능한 실패 + +```text +Attempt: STARTED -> FAILED +Job: PROCESSING -> PENDING +Version: 현재 단계 유지 +Document: 변경 없음 +Embedding: 변경 없음 +``` + +처리 순서: + +1. 하나의 `failedAt`을 microsecond 정밀도로 계산한다. +2. Attempt에 실패 유형, 안전한 메시지, 종료 시각과 소요 시간을 기록한다. +3. 현재 Retry 횟수로 Backoff를 계산한다. +4. Job Retry 횟수를 증가시키고 `nextRetryAt`을 저장한다. +5. Job의 현재 소유권 정보를 제거한다. +6. 단계별 실패 이벤트와 RETRY 이벤트를 같은 시각으로 저장한다. + +### 6.2 영구 실패 또는 Retry 소진 + +```text +Attempt: STARTED -> FAILED +Job: PROCESSING -> FAILED +Version: UPLOADED/PARSING/CHUNKED/EMBEDDING -> FAILED +``` + +Document는 현재 검색 가능한 Version의 존재 여부로 결정한다. + +| current Version | Document 처리 | +| --- | --- | +| 실패 대상과 동일한 최초 Version | Document FAILED | +| 이전 FAILED Version이고 검색 가능 Version 없음 | Document FAILED | +| 이전 INDEXED Version | Document INDEXED와 current 포인터 유지 | +| null 또는 그 밖의 중간 상태 | 내부 불변식 오류, 전체 Rollback | + +대상 Version의 `ACTIVE` Embedding은 `STALE`로 바꾼다. Retry가 끝났으므로 재사용하지 않으며, 검색 +쿼리의 current Version 조건뿐 아니라 Embedding 자체 상태에서도 검색 불가를 표현한다. + +### 6.3 후속 성공 + +Retry 중 저장한 Job 오류 Snapshot은 Attempt 이력에 이미 보존돼 있다. Job이 이후 INDEXED로 완료되면 +`errorCode`, `errorMessage`, `failedAt`, `nextRetryAt`을 제거해 현재 Job Snapshot을 성공 상태와 맞춘다. + +## 7. Backoff + +설정: + +```yaml +indexing: + worker: + retry-initial-delay: ${INDEXING_WORKER_RETRY_INITIAL_DELAY:10s} + retry-max-delay: ${INDEXING_WORKER_RETRY_MAX_DELAY:5m} +``` + +검증: + +- 두 Duration 모두 양수 +- 최대 지연은 초기 지연 이상 + +계산: + +```text +delay = min(retryInitialDelay * 2 ^ currentRetryCount, retryMaxDelay) +nextRetryAt = failedAt + delay +``` + +곱셈 Overflow가 발생하지 않도록 최대 지연에 도달하면 즉시 계산을 중단한다. Jitter는 결정적인 테스트와 +MVP 단순성을 위해 이번 범위에 넣지 않는다. + +## 8. Transaction과 잠금 + +### 8.1 최초 실패 + +```text +Transaction 시작 +-> Job SELECT FOR UPDATE +-> Attempt 조회 및 STARTED 검증 +-> 현재 Worker, Claim Token, Lease 검증 +-> Version SELECT FOR UPDATE +-> Document SELECT FOR UPDATE +-> 실패 유형과 Retry 잔여 횟수 판정 +-> Attempt, Job, Version, Document, Embedding 상태 변경 +-> IndexingEvent Insert +-> Commit +``` + +완료 흐름과 동일하게 `Job -> Version -> Document` 순서를 유지한다. Job 잠금이 완료, 실패, Retry와 +Claim 세대 교체의 직렬화 지점이다. + +### 8.2 완료와 실패 경쟁 + +| 먼저 커밋한 요청 | 나중 요청 | +| --- | --- | +| 완료: Job INDEXED | 실패는 PROCESSING이 아니므로 거부 | +| Retry: Job PENDING, Token 제거 | 과거 완료는 상태·소유권 오류로 거부 | +| 최종 실패: Job FAILED | 완료는 완료 불가 오류로 거부 | + +한 요청이 상태를 일부 바꾼 뒤 다른 요청이 이어서 커밋할 수 없다. + +### 8.3 Version 업로드와의 관계 + +최종 실패 Transaction이 Document 잠금을 해제하기 전에는 새 Version 업로드가 진행되지 않는다. 새 +업로드는 Version이 FAILED이고 Document가 FAILED 또는 기존 INDEXED 상태인 일관된 결과만 관찰한다. + +## 9. 멱등성 + +Job 잠금 뒤 Attempt를 먼저 조회한다. Attempt가 이미 FAILED이면 현재 Job 상태와 Lease를 다시 검증하지 +않고 다음 저장 값을 확인한다. + +- Attempt ID와 Job ID +- Worker ID +- Claim Token +- 저장된 failureType +- 저장된 errorMessage + +모두 같으면 저장된 `failedAt`, `durationMs`로 응답한다. 최초 실패 후 Job이 새 Worker에게 Claim되거나 +이미 INDEXED가 됐어도 과거 실패 응답을 안전하게 재생할 수 있다. + +하나라도 다르면 같은 실행 이력을 다른 내용으로 덮으려는 요청이므로 409를 반환한다. 멱등 재생에서는 +Retry 횟수, 예약 시각, 상태, 이벤트를 변경하지 않는다. + +## 10. 이벤트 + +모든 이벤트는 Attempt 종료 시각과 같은 `occurredAt`을 사용한다. + +### 10.1 단계별 실패 이벤트 + +| Version 상태 | 이벤트 | +| --- | --- | +| `UPLOADED`, `PARSING` | `PARSE_FAILED` | +| `CHUNKED`, `EMBEDDING` | `EMBEDDING_FAILED` | + +오류 상세는 Job과 Attempt에 저장한다. 이벤트 메시지는 일반화하고 Metadata에는 다음 비민감 값만 넣는다. + +```json +{ + "attemptId": 21, + "attemptNo": 2, + "failureType": "EMBEDDING_PROVIDER_UNAVAILABLE" +} +``` + +### 10.2 Retry 이벤트 + +- eventType: `RETRY` +- fromStatus: `PROCESSING` +- toStatus: `PENDING` +- Metadata: `retryCount`, `nextRetryAt` + +### 10.3 최종 실패 이벤트 + +- eventType: `FAILED` +- fromStatus: `PROCESSING` +- toStatus: `FAILED` +- Metadata: `retryCount`, `maxRetryCount` + +같은 Attempt의 멱등 재생은 이벤트를 추가하지 않는다. + +## 11. 구성요소 + +### 11.1 신규 + +```text +src/main/java/com/opensource/docgrid/domain/embedding/ +├── dto/request/FailDocumentIndexingRequest.java +├── dto/response/DocumentIndexingFailureResponse.java +├── enums/IndexingFailureType.java +└── service/command/DocumentIndexingFailureService.java + +src/main/resources/db/migration/ +└── V35__add_embedding_job_next_retry_at.sql +``` + +### 11.2 수정 + +| 파일 | 변경 | +| --- | --- | +| `EmbeddingJob` | 예약 시각과 Retry·최종 실패 전이 | +| `EmbeddingJobAttempt` | STARTED 전용 실패 Guard | +| `DocumentVersion` | 실패 가능 상태 Guard | +| `EmbeddingJobRepository` | 실행 가능 시각 기반 Claim | +| `EmbeddingRepository` | 최종 실패 Version ACTIVE Set 비활성화 계약 설명 | +| `IndexingWorkerProperties` | Retry Backoff 설정과 검증 | +| `EmbeddingJobClaimService` | Claim 기준 시각 Repository 전달 | +| `IndexingJobAdminController` | 실패 API와 Swagger 계약 | +| `ErrorCode` | 실패 내용 충돌과 데이터 불일치 코드 | +| `application.yml` | Retry 설정 환경 변수 | + +## 12. 테스트 전략 + +### 12.1 단위 테스트 + +- Retry 설정 기본값과 양수·상하한 검증 +- Backoff 10초, 20초, 40초와 최대 지연 제한 +- PENDING이 아닌 Job의 Retry 예약 거부 +- Retry 소진 상태의 예약 거부 +- Retry 예약 시 횟수 증가, 시각 저장, 소유권 제거 +- Attempt STARTED만 FAILED 전이 +- Version 완료·실패 상태에서 중복 실패 거부 +- 실패 유형별 재시도 가능 여부 +- 요청 DTO 형식과 오류 메시지 길이 검증 +- 단계별 이벤트 선택 +- 최초 실패, Retry, 최종 실패와 멱등 재생 Service 분기 + +### 12.2 Controller 테스트 + +- ADMIN 정상 요청 200 +- 인증 없음·권한 없음 403 +- Path ID, Worker ID, Token, Enum, 메시지 Validation 400 +- Service 오류의 HTTP·안정적 ErrorCode 매핑 +- Entity 직접 노출 없이 응답 DTO 반환 + +### 12.3 PostgreSQL 통합 테스트 + +- `next_retry_at` 이전 Job이 Claim되지 않음 +- 정확히 예약 시각부터 Claim 가능 +- 일반 PENDING과 예약 Retry Job의 Queue 정렬 +- Retry 후 새 Token과 새 Attempt 번호 발급 +- 최초 Version 영구 실패 시 Document·Version·Job·Attempt 종결 +- 새 Version 영구 실패 시 기존 current Version과 Vector 검색 유지 +- Retry 소진 시 최종 실패 +- Embedding 저장 후 최종 실패 시 대상 Vector STALE +- 동일 실패 요청의 상태·이벤트·Retry 횟수 불변 +- 완료와 실패 동시 요청에서 한쪽만 성공 +- 이벤트 Insert 또는 Embedding Update 실패 시 전체 Rollback + +### 12.4 회귀 테스트 + +- 최초 인덱싱 완료와 새 Version 검색 전환 +- Chunk와 Embedding 생성 멱등 재생 +- PENDING Queue 동시 Claim과 `SKIP LOCKED` +- 문서 상태 조회와 Vector 검색 current Version 조건 +- 전체 `./gradlew test` + +## 13. 커밋 계획 + +1. `docs: #88 인덱싱 실패 및 지연 재시도 상세 설계 추가` +2. `feat: Embedding Job 지연 재시도 Queue 모델 추가` +3. `feat: 인덱싱 실패 유형과 종료 계약 추가` +4. `feat: 인덱싱 실패 종료 및 재시도 API 구현` +5. `test: 인덱싱 실패 재시도와 동시성 통합 검증` + +각 커밋은 관련 테스트를 함께 포함하고 독립적으로 Build 가능한 상태를 유지한다. + +## 14. 완료 기준 + +- 유효한 현재 Attempt만 최초 실패를 보고할 수 있다. +- Retry 가능 오류는 Attempt를 종료하고 Job을 지연 PENDING으로 복귀시킨다. +- 예약 시각 전에는 Claim되지 않고 예약 시각부터 Claim된다. +- 재Claim은 새로운 Claim Token과 Attempt 번호를 사용한다. +- 영구 오류 또는 Retry 소진은 Job과 Version을 최종 FAILED 처리한다. +- 새 Version 실패 시 기존 검색 결과와 current Version이 유지된다. +- 검색 가능한 Version이 없는 최종 실패 문서는 검색에서 제외된다. +- 최종 실패 Version의 ACTIVE Embedding은 STALE이 된다. +- 같은 실패 요청은 Retry 횟수와 이벤트를 중복 생성하지 않는다. +- 완료와 실패의 동시 요청에서 부분 상태가 남지 않는다. +- Transaction 실패 시 모든 상태와 이벤트 변경이 Rollback된다. +- 실제 PostgreSQL 통합 테스트와 전체 테스트가 통과한다. diff --git a/docs/test-results/Gimini-3-#88-document-indexing-failure-retry.md b/docs/test-results/Gimini-3-#88-document-indexing-failure-retry.md new file mode 100644 index 0000000..c58b469 --- /dev/null +++ b/docs/test-results/Gimini-3-#88-document-indexing-failure-retry.md @@ -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 +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 +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 +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 +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='' \ +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='' \ +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='' \ +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한다. diff --git a/src/main/java/com/opensource/docgrid/domain/document/entity/DocumentVersion.java b/src/main/java/com/opensource/docgrid/domain/document/entity/DocumentVersion.java index 83765e4..418b2a3 100644 --- a/src/main/java/com/opensource/docgrid/domain/document/entity/DocumentVersion.java +++ b/src/main/java/com/opensource/docgrid/domain/document/entity/DocumentVersion.java @@ -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; } } 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 9af1b64..367c918 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 @@ -13,6 +13,7 @@ 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; @@ -20,12 +21,14 @@ 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; @@ -43,7 +46,7 @@ import lombok.RequiredArgsConstructor; /** - * 관리자용 Embedding Job Claim, Attempt 시작과 문서 Chunk·Embedding·인덱싱 완료 실행을 HTTP API로 제공한다. + * 관리자용 Embedding Job Claim, Attempt 시작과 문서 Chunk·Embedding·인덱싱 완료·실패 실행을 HTTP API로 제공한다. * *

HTTP 입력 검증과 성공 상태 변환만 담당한다. Job Claim 및 현재 소유권 기반 파이프라인 단계의 * Transaction·외부 호출·동시성 규칙은 각 Service에 위임한다. @@ -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", @@ -355,4 +359,55 @@ public ResponseEntity> 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> failIndexing( + @PathVariable @Positive Long jobId, + @PathVariable @Positive Long attemptId, + @Valid @RequestBody FailDocumentIndexingRequest request + ) { + // 최초 실패와 멱등 재생 모두 같은 Attempt 기반 실패 응답을 200 OK로 반환한다. + return ResponseUtils.ok(documentIndexingFailureService.fail(jobId, attemptId, request)); + } } diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/converter/EmbeddingJobAttemptConverter.java b/src/main/java/com/opensource/docgrid/domain/embedding/converter/EmbeddingJobAttemptConverter.java index 4b2c063..c16107e 100644 --- a/src/main/java/com/opensource/docgrid/domain/embedding/converter/EmbeddingJobAttemptConverter.java +++ b/src/main/java/com/opensource/docgrid/domain/embedding/converter/EmbeddingJobAttemptConverter.java @@ -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. * *

Transaction 안에서 LAZY Job·Worker의 식별자만 추출하고 Entity, Claim Token, 내부 오류 정보는 * Controller 경계 밖으로 노출하지 않는다. @@ -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() + ); + } + + private IndexingFailureType toFailureType(String errorCode) { + try { + return IndexingFailureType.valueOf(errorCode); + } catch (IllegalArgumentException | NullPointerException exception) { + // 수동 변경이나 이전 데이터의 알 수 없는 코드를 임의 유형으로 오인하지 않고 불일치로 드러낸다. + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_FAILURE_INCONSISTENT); + } + } } diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/dto/request/FailDocumentIndexingRequest.java b/src/main/java/com/opensource/docgrid/domain/embedding/dto/request/FailDocumentIndexingRequest.java new file mode 100644 index 0000000..4e4fd89 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/dto/request/FailDocumentIndexingRequest.java @@ -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. + * + *

호출자는 제한된 실패 유형과 진단 메시지만 전달하며, 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 +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/DocumentIndexingFailureResponse.java b/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/DocumentIndexingFailureResponse.java new file mode 100644 index 0000000..e57c51d --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/DocumentIndexingFailureResponse.java @@ -0,0 +1,38 @@ +package com.opensource.docgrid.domain.embedding.dto.response; + +import java.time.LocalDateTime; + +import com.opensource.docgrid.domain.embedding.enums.IndexingFailureType; +import com.opensource.docgrid.domain.worker.enums.AttemptStatus; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 최초 실패 또는 멱등 재생된 Attempt의 불변 종료 정보를 전달한다. + * + *

후속 Retry와 완료로 바뀔 수 있는 현재 Job 상태는 제외하고, 종료된 Attempt에 저장된 값만 반환해 + * 같은 실패 요청의 장기 멱등 응답을 보장한다. + */ +public record DocumentIndexingFailureResponse( + @Schema(description = "실패가 보고된 Embedding Job 식별자", example = "10") + Long jobId, + + @Schema(description = "종료된 Attempt 식별자", example = "21") + Long attemptId, + + @Schema(description = "Job 안의 실행 시도 번호", example = "2") + int attemptNo, + + @Schema(description = "종료된 Attempt 상태", example = "FAILED") + AttemptStatus attemptStatus, + + @Schema(description = "저장된 인덱싱 실패 유형", example = "EMBEDDING_PROVIDER_UNAVAILABLE") + IndexingFailureType failureType, + + @Schema(description = "최초 실패 종료 시각", example = "2026-08-03T10:30:00") + LocalDateTime failedAt, + + @Schema(description = "Attempt 시작부터 실패까지 걸린 시간(ms)", example = "42031") + long durationMs +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingJob.java b/src/main/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingJob.java index e8ddf27..6acd831 100644 --- a/src/main/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingJob.java +++ b/src/main/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingJob.java @@ -33,8 +33,8 @@ * -> Worker가 파싱/청킹/임베딩 -> embeddings 저장). * 관계: document_version_id -> DocumentVersion, embedding_model_id -> EmbeddingModel, * locked_by_worker_id -> WorkerNode(nullable, lock을 잡은 Worker). - * index: (status, priority, created_at) 우선순위 큐 조회용, lock_expires_at, (document_version_id, embedding_model_id), - * locked_by_worker_id. + * index: (status, priority, created_at) 우선순위 큐 조회용, (status, next_retry_at) Retry 실행 가능 시각 조회용, + * lock_expires_at, (document_version_id, embedding_model_id), locked_by_worker_id. * *

주의사항: Worker는 Claim 시 PROCESSING 상태, 소유 Worker, UUID Claim Token, Lease 시작·만료 시각을 * 함께 기록한다. DB 행 잠금은 Claim Transaction 동안의 중복 선택을 막고, Lease와 Claim Token은 @@ -48,6 +48,7 @@ name = "embedding_jobs", indexes = { @Index(name = "idx_embedding_jobs_status_priority_created_at", columnList = "status, priority, created_at"), + @Index(name = "idx_embedding_jobs_status_next_retry_at", columnList = "status, next_retry_at"), @Index(name = "idx_embedding_jobs_lock_expires_at", columnList = "lock_expires_at"), @Index(name = "idx_embedding_jobs_document_version_id_embedding_model_id", columnList = "document_version_id, embedding_model_id"), @Index(name = "idx_embedding_jobs_locked_by_worker_id", columnList = "locked_by_worker_id") @@ -107,6 +108,10 @@ public class EmbeddingJob extends BaseEntity { @Column(name = "failed_at") private LocalDateTime failedAt; + // PENDING Retry Job이 다시 Claim 가능해지는 시각이며 null이면 즉시 실행할 수 있다. + @Column(name = "next_retry_at") + private LocalDateTime nextRetryAt; + @Column(name = "error_code", length = 100) private String errorCode; @@ -150,6 +155,7 @@ public void claim(WorkerNode workerNode, String claimToken, LocalDateTime claime // 3. DB 행 잠금 이후에도 소유권 유효 기간을 판단할 수 있도록 Lease 시간을 기록한다. this.lockedAt = claimedAt; this.lockExpiresAt = lockExpiresAt; + this.nextRetryAt = null; // 4. startedAt은 전체 처리의 최초 시작 시각이므로 향후 재Claim에서도 기존 값을 보존한다. if (startedAt == null) { @@ -169,16 +175,60 @@ public void markIndexed(LocalDateTime completedAt) { } this.status = EmbeddingJobStatus.INDEXED; this.completedAt = completedAt; + // Attempt 이력에 실패 원인이 남으므로 현재 Job Snapshot에서는 과거 Retry 오류를 제거한다. + this.failedAt = null; + this.nextRetryAt = null; + this.errorCode = null; + this.errorMessage = null; + } + + /** + * 현재 Claim을 실패한 Attempt 이력으로 남기고 Job을 지정 시각 이후의 PENDING Queue로 복귀시킨다. + * + *

현재 소유권을 모두 제거해야 과거 Worker의 Token이 후속 단계 저장 권한으로 재사용되지 않는다. + */ + public void scheduleRetry(String errorCode, String errorMessage, LocalDateTime nextRetryAt) { + // 1. 처리 중인 현재 Claim만 Queue로 되돌릴 수 있다. + if (status != EmbeddingJobStatus.PROCESSING) { + throw new IllegalStateException("PROCESSING 상태의 Job만 Retry를 예약할 수 있습니다."); + } + // 2. 최대 횟수와 같아진 Job은 별도의 최종 실패 전이로 종결해야 한다. + if (retryCount >= maxRetryCount) { + throw new IllegalStateException("Embedding Job Retry 횟수를 모두 소진했습니다."); + } + if (nextRetryAt == null) { + throw new IllegalArgumentException("다음 Retry 시각은 필수입니다."); + } + + // 3. Queue 상태와 실행 가능 시각 및 최신 오류 Snapshot을 함께 기록한다. + this.status = EmbeddingJobStatus.PENDING; + this.retryCount++; + this.nextRetryAt = nextRetryAt; + this.errorCode = errorCode; + this.errorMessage = errorMessage; + this.failedAt = null; + + // 4. 새 Claim이 새로운 소유권을 발급하도록 과거 Worker, Token과 Lease를 모두 해제한다. + this.lockedByWorker = null; + this.claimToken = null; + this.lockedAt = null; + this.lockExpiresAt = null; + } + + public boolean hasRemainingRetries() { + return retryCount < maxRetryCount; } public void markFailed(String errorCode, String errorMessage, LocalDateTime failedAt) { + // 현재 Claim을 보유한 처리 중 Job만 최종 실패로 종결할 수 있다. + if (status != EmbeddingJobStatus.PROCESSING) { + throw new IllegalStateException("PROCESSING 상태의 Job만 FAILED로 전환할 수 있습니다."); + } this.status = EmbeddingJobStatus.FAILED; this.errorCode = errorCode; this.errorMessage = errorMessage; this.failedAt = failedAt; + this.nextRetryAt = null; } - public void increaseRetryCount() { - this.retryCount++; - } } diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/enums/IndexingFailureType.java b/src/main/java/com/opensource/docgrid/domain/embedding/enums/IndexingFailureType.java new file mode 100644 index 0000000..1bcc244 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/enums/IndexingFailureType.java @@ -0,0 +1,27 @@ +package com.opensource.docgrid.domain.embedding.enums; + +/** + * Worker가 보고할 수 있는 인덱싱 실패 원인과 서버의 Retry 가능 정책을 정의한다. + * + *

외부 요청은 이 제한된 분류만 전달하며, Retry 여부를 직접 지정할 수 없다. 자유 형식 오류 메시지는 + * 진단 정보로만 저장되고 Job 상태 전이 결정에는 사용하지 않는다. + */ +public enum IndexingFailureType { + + STORAGE_UNAVAILABLE(true), + DOCUMENT_CONTENT_INVALID(false), + EMBEDDING_PROVIDER_UNAVAILABLE(true), + EMBEDDING_RESULT_INVALID(false), + INDEXING_STATE_INCONSISTENT(false), + WORKER_INTERNAL_ERROR(true); + + private final boolean retryable; + + IndexingFailureType(boolean retryable) { + this.retryable = retryable; + } + + public boolean isRetryable() { + return retryable; + } +} 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 2b27174..75033e2 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 @@ -1,5 +1,6 @@ package com.opensource.docgrid.domain.embedding.repository; +import java.time.LocalDateTime; import java.util.Collection; import java.util.Optional; @@ -30,24 +31,26 @@ long countByDocumentVersionIdAndStatusIn( ); /** - * 우선순위 Queue 정책에 따라 다음 PENDING Job 한 건을 잠금 상태로 조회한다. + * 우선순위 Queue 정책에 따라 현재 실행 가능한 다음 PENDING Job 한 건을 잠금 상태로 조회한다. * *

다른 Transaction이 잠근 행은 기다리지 않고 건너뛴다. 반환된 행 잠금은 호출한 Service의 * Transaction이 끝날 때까지 유지돼야 하므로 반드시 Transaction 내부에서 호출한다. * + * @param claimedAt Retry 예약 시각과 비교할 Claim 기준 시각 * @return 잠금을 획득한 다음 PENDING Job, 처리 가능한 후보가 없으면 빈 값 */ @Query(value = """ SELECT job.* FROM embedding_jobs job WHERE job.status = 'PENDING' + AND (job.next_retry_at IS NULL OR job.next_retry_at <= :claimedAt) ORDER BY job.priority DESC, job.created_at ASC, job.id ASC LIMIT 1 FOR UPDATE SKIP LOCKED """, nativeQuery = true) - Optional findNextPendingForUpdate(); + Optional findNextPendingForUpdate(@Param("claimedAt") LocalDateTime claimedAt); /** * 지정한 Job을 현재 Transaction이 끝날 때까지 쓰기 잠금 상태로 조회한다. diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingRepository.java b/src/main/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingRepository.java index fc5d60b..b3257fd 100644 --- a/src/main/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingRepository.java +++ b/src/main/java/com/opensource/docgrid/domain/embedding/repository/EmbeddingRepository.java @@ -13,7 +13,7 @@ * *

생성 Transaction은 Version·Model 단위 저장 개수로 부분 저장을 구분한다. 완료 Transaction은 * Vector를 Java Heap으로 역직렬화하지 않고 DB 집계로 관계·차원·Hash 불변식을 검증하고, - * 이전 현재 Version의 ACTIVE Set을 STALE로 일괄 전환한다. + * 이전 현재 Version 또는 최종 실패한 Version의 ACTIVE Set을 STALE로 일괄 전환한다. */ public interface EmbeddingRepository extends JpaRepository { @@ -31,7 +31,7 @@ long countByDocumentVersionIdAndEmbeddingModelIdAndStatus( ); /** - * 이전 현재 Version의 검색 가능한 Embedding을 한 SQL로 비활성화한다. + * 인덱싱 완료 시 이전 현재 Version 또는 최종 실패 대상의 검색 가능한 Embedding을 한 SQL로 비활성화한다. * * @return 실제 STALE로 변경된 행 수 */ diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingFailureService.java b/src/main/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingFailureService.java new file mode 100644 index 0000000..f536d6a --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingFailureService.java @@ -0,0 +1,410 @@ +package com.opensource.docgrid.domain.embedding.service.command; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Objects; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.document.entity.DocumentVersion; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.document.repository.DocumentRepository; +import com.opensource.docgrid.domain.document.repository.DocumentVersionRepository; +import com.opensource.docgrid.domain.embedding.converter.EmbeddingJobAttemptConverter; +import com.opensource.docgrid.domain.embedding.dto.request.FailDocumentIndexingRequest; +import com.opensource.docgrid.domain.embedding.dto.response.DocumentIndexingFailureResponse; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingJob; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.embedding.enums.IndexingFailureType; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingJobRepository; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingRepository; +import com.opensource.docgrid.domain.worker.config.IndexingWorkerProperties; +import com.opensource.docgrid.domain.worker.entity.EmbeddingJobAttempt; +import com.opensource.docgrid.domain.worker.entity.IndexingEvent; +import com.opensource.docgrid.domain.worker.enums.AttemptStatus; +import com.opensource.docgrid.domain.worker.enums.IndexingEventType; +import com.opensource.docgrid.domain.worker.repository.EmbeddingJobAttemptRepository; +import com.opensource.docgrid.domain.worker.repository.IndexingEventRepository; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 유효한 Worker Claim의 인덱싱 실패를 Attempt 이력과 Retry 또는 최종 실패 상태로 원자 기록한다. + * + *

Job → Version → Document 순서의 행 잠금을 유지해 완료 요청 및 새 Version 업로드와 직렬화한다. + * 이 Service는 외부 I/O를 수행하지 않으며, 실패 분류 정책과 남은 횟수만으로 Retry 여부를 결정한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class DocumentIndexingFailureService { + + private static final String PARSE_FAILURE_MESSAGE = "Document Version 파싱 단계가 실패했습니다."; + private static final String EMBEDDING_FAILURE_MESSAGE = "Document Version 임베딩 단계가 실패했습니다."; + private static final String RETRY_MESSAGE = "Embedding Job 재시도를 예약했습니다."; + private static final String TERMINAL_FAILURE_MESSAGE = "Embedding Job을 최종 실패로 종료했습니다."; + + private final EmbeddingJobRepository embeddingJobRepository; + private final EmbeddingJobAttemptRepository embeddingJobAttemptRepository; + private final DocumentVersionRepository documentVersionRepository; + private final DocumentRepository documentRepository; + private final EmbeddingRepository embeddingRepository; + private final IndexingEventRepository indexingEventRepository; + private final EmbeddingJobOwnershipValidator ownershipValidator; + private final EmbeddingJobAttemptConverter attemptConverter; + private final IndexingWorkerProperties workerProperties; + private final Clock clock; + + /** + * 현재 Attempt를 실패로 종결하고 서버 정책에 따라 Job을 재예약하거나 최종 종료한다. + */ + public DocumentIndexingFailureResponse fail( + Long jobId, + Long attemptId, + FailDocumentIndexingRequest request + ) { + // 1. Claim 세대 교체와 완료·실패 경쟁을 Job 행에서 직렬화하고 요청 Attempt를 먼저 확인한다. + EmbeddingJob embeddingJob = findLockedJob(jobId); + EmbeddingJobAttempt attempt = findAttempt(embeddingJob, request.claimToken()); + + // 2. 이미 실패한 같은 실행은 현재 Job과 Lease가 바뀌었어도 저장된 최초 결과만 재생한다. + if (attempt.getStatus() == AttemptStatus.FAILED) { + validateFailureReplay(embeddingJob, attempt, attemptId, request); + return attemptConverter.toFailureResponse(attempt); + } + if (attempt.getStatus() != AttemptStatus.STARTED) { + throw new DocGridException(ErrorCode.EMBEDDING_JOB_FAILURE_CONFLICT); + } + if (embeddingJob.getStatus() != EmbeddingJobStatus.PROCESSING) { + throw new DocGridException(ErrorCode.EMBEDDING_JOB_NOT_PROCESSING); + } + + // PostgreSQL TIMESTAMP 정밀도와 맞춰 최초 실패와 DB 재생 응답의 시각을 동일하게 유지한다. + LocalDateTime failedAt = LocalDateTime.now(clock).truncatedTo(ChronoUnit.MICROS); + + // 3. 현재 소유권과 STARTED Attempt를 검증한 뒤 Version과 Document를 정해진 순서로 잠근다. + ownershipValidator.validate( + embeddingJob, + request.workerId(), + request.claimToken(), + failedAt + ); + validateStartedAttempt(embeddingJob, attempt, attemptId, request, failedAt); + DocumentVersion documentVersion = findLockedVersion(embeddingJob); + Document document = findLockedDocument(documentVersion); + validateFailureTarget(embeddingJob, documentVersion, document); + + // 4. 상태 변경 전에 단계 이벤트와 소요 시간을 고정해 부분 전이가 남지 않게 한다. + DocumentVersionStatus failedFromStatus = documentVersion.getStatus(); + IndexingEventType stageEventType = resolveStageEventType(failedFromStatus); + long durationMs = Duration.between(attempt.getStartedAt(), failedAt).toMillis(); + attempt.markFailed( + failedAt, + durationMs, + request.failureType().name(), + request.errorMessage() + ); + + // 5. Retry 가능 정책과 잔여 횟수에 따라 Queue 복귀 또는 모든 대상의 최종 실패를 함께 기록한다. + if (request.failureType().isRetryable() && embeddingJob.hasRemainingRetries()) { + scheduleRetry( + embeddingJob, + attempt, + failedFromStatus, + stageEventType, + request.failureType(), + failedAt + ); + } else { + terminateFailure( + embeddingJob, + attempt, + documentVersion, + document, + failedFromStatus, + stageEventType, + request.failureType(), + failedAt + ); + } + + log.info( + "문서 인덱싱 실패 기록: jobId={}, attemptId={}, failureType={}, retryCount={}, terminal={}", + embeddingJob.getId(), + attempt.getId(), + request.failureType(), + embeddingJob.getRetryCount(), + embeddingJob.getStatus() == EmbeddingJobStatus.FAILED + ); + return attemptConverter.toFailureResponse(attempt); + } + + private EmbeddingJob findLockedJob(Long jobId) { + return embeddingJobRepository.findByIdForUpdate(jobId) + .orElseThrow(() -> new DocGridException(ErrorCode.EMBEDDING_JOB_NOT_FOUND)); + } + + private EmbeddingJobAttempt findAttempt(EmbeddingJob embeddingJob, String claimToken) { + return embeddingJobAttemptRepository + .findByEmbeddingJobIdAndClaimToken(embeddingJob.getId(), claimToken) + .orElseThrow(() -> new DocGridException(ErrorCode.EMBEDDING_JOB_ATTEMPT_INVALID)); + } + + private void validateFailureReplay( + EmbeddingJob embeddingJob, + EmbeddingJobAttempt attempt, + Long attemptId, + FailDocumentIndexingRequest request + ) { + boolean identityMatches = Objects.equals(attempt.getId(), attemptId) + && attempt.getEmbeddingJob() != null + && Objects.equals(attempt.getEmbeddingJob().getId(), embeddingJob.getId()) + && attempt.getWorkerNode() != null + && Objects.equals(attempt.getWorkerNode().getId(), request.workerId()) + && Objects.equals(attempt.getClaimToken(), request.claimToken()); + if (!identityMatches) { + throw new DocGridException(ErrorCode.EMBEDDING_JOB_ATTEMPT_INVALID); + } + + boolean failureMatches = Objects.equals(attempt.getErrorCode(), request.failureType().name()) + && Objects.equals(attempt.getErrorMessage(), request.errorMessage()); + if (!failureMatches) { + throw new DocGridException(ErrorCode.EMBEDDING_JOB_FAILURE_CONFLICT); + } + if (attempt.getEndedAt() == null + || attempt.getDurationMs() == null + || attempt.getDurationMs() < 0 + || attempt.getStartedAt() == null + || attempt.getStartedAt().isAfter(attempt.getEndedAt()) + || Duration.between(attempt.getStartedAt(), attempt.getEndedAt()).toMillis() + != attempt.getDurationMs()) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_FAILURE_INCONSISTENT); + } + } + + private void validateStartedAttempt( + EmbeddingJob embeddingJob, + EmbeddingJobAttempt attempt, + Long attemptId, + FailDocumentIndexingRequest request, + LocalDateTime failedAt + ) { + if (!Objects.equals(attempt.getId(), attemptId) + || attempt.getEmbeddingJob() == null + || !Objects.equals(attempt.getEmbeddingJob().getId(), embeddingJob.getId()) + || attempt.getWorkerNode() == null + || !Objects.equals(attempt.getWorkerNode().getId(), request.workerId()) + || !Objects.equals(attempt.getClaimToken(), request.claimToken()) + || attempt.getStartedAt() == null) { + throw new DocGridException(ErrorCode.EMBEDDING_JOB_ATTEMPT_INVALID); + } + if (attempt.getStartedAt().isAfter(failedAt)) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_FAILURE_INCONSISTENT); + } + } + + private DocumentVersion findLockedVersion(EmbeddingJob embeddingJob) { + if (embeddingJob.getDocumentVersion() == null + || embeddingJob.getDocumentVersion().getId() == null) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_FAILURE_INCONSISTENT); + } + return documentVersionRepository.findByIdForUpdate(embeddingJob.getDocumentVersion().getId()) + .orElseThrow(() -> new DocGridException(ErrorCode.DOCUMENT_INDEXING_FAILURE_INCONSISTENT)); + } + + private Document findLockedDocument(DocumentVersion documentVersion) { + if (documentVersion.getDocument() == null + || documentVersion.getDocument().getId() == null) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_FAILURE_INCONSISTENT); + } + return documentRepository.findByIdForUpdate(documentVersion.getDocument().getId()) + .orElseThrow(() -> new DocGridException(ErrorCode.DOCUMENT_INDEXING_FAILURE_INCONSISTENT)); + } + + private void validateFailureTarget( + EmbeddingJob embeddingJob, + DocumentVersion documentVersion, + Document document + ) { + if (!Objects.equals(embeddingJob.getDocumentVersion().getId(), documentVersion.getId()) + || documentVersion.getDocument() == null + || !Objects.equals(documentVersion.getDocument().getId(), document.getId()) + || document.getDeletedAt() != null) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_FAILURE_INCONSISTENT); + } + resolveStageEventType(documentVersion.getStatus()); + } + + private IndexingEventType resolveStageEventType(DocumentVersionStatus versionStatus) { + return switch (versionStatus) { + case UPLOADED, PARSING -> IndexingEventType.PARSE_FAILED; + case CHUNKED, EMBEDDING -> IndexingEventType.EMBEDDING_FAILED; + default -> throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_FAILURE_INCONSISTENT); + }; + } + + private void scheduleRetry( + EmbeddingJob embeddingJob, + EmbeddingJobAttempt attempt, + DocumentVersionStatus versionStatus, + IndexingEventType stageEventType, + IndexingFailureType failureType, + LocalDateTime failedAt + ) { + Duration retryDelay = calculateRetryDelay(embeddingJob.getRetryCount()); + LocalDateTime nextRetryAt = failedAt.plus(retryDelay); + embeddingJob.scheduleRetry(failureType.name(), attempt.getErrorMessage(), nextRetryAt); + + // Version 상태는 재개 지점으로 보존하고 같은 시각에 단계 실패와 Queue 재예약 이벤트를 남긴다. + saveStageFailureEvent( + embeddingJob, + attempt, + stageEventType, + versionStatus.name(), + versionStatus.name(), + failureType, + failedAt + ); + indexingEventRepository.save(IndexingEvent.builder() + .embeddingJob(embeddingJob) + .eventType(IndexingEventType.RETRY) + .fromStatus(EmbeddingJobStatus.PROCESSING.name()) + .toStatus(EmbeddingJobStatus.PENDING.name()) + .message(RETRY_MESSAGE) + .metadataJson(retryMetadata(embeddingJob, nextRetryAt)) + .occurredAt(failedAt) + .build()); + } + + private void terminateFailure( + EmbeddingJob embeddingJob, + EmbeddingJobAttempt attempt, + DocumentVersion documentVersion, + Document document, + DocumentVersionStatus versionStatus, + IndexingEventType stageEventType, + IndexingFailureType failureType, + LocalDateTime failedAt + ) { + // 1. 대상 Version의 검색 가능 Set을 비활성화하고 처리 중 상태를 최종 실패로 종결한다. + embeddingRepository.markActiveAsStaleByDocumentVersionId(documentVersion.getId()); + documentVersion.markFailed(); + transitionDocumentOnTerminalFailure(document, documentVersion); + embeddingJob.markFailed(failureType.name(), attempt.getErrorMessage(), failedAt); + + // 2. 단계 실패와 Job 최종 실패 이벤트를 같은 Transaction과 시각에 append한다. + saveStageFailureEvent( + embeddingJob, + attempt, + stageEventType, + versionStatus.name(), + DocumentVersionStatus.FAILED.name(), + failureType, + failedAt + ); + indexingEventRepository.save(IndexingEvent.builder() + .embeddingJob(embeddingJob) + .eventType(IndexingEventType.FAILED) + .fromStatus(EmbeddingJobStatus.PROCESSING.name()) + .toStatus(EmbeddingJobStatus.FAILED.name()) + .message(TERMINAL_FAILURE_MESSAGE) + .metadataJson(terminalFailureMetadata(embeddingJob)) + .occurredAt(failedAt) + .build()); + } + + private void transitionDocumentOnTerminalFailure( + Document document, + DocumentVersion failedVersion + ) { + DocumentVersion currentVersion = document.getCurrentVersion(); + if (currentVersion == null + || currentVersion.getId() == null + || currentVersion.getDocument() == null + || !Objects.equals(currentVersion.getDocument().getId(), document.getId())) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_FAILURE_INCONSISTENT); + } + + // 이전 INDEXED Version이 있으면 검색 가용성과 현재 포인터를 그대로 보존한다. + if (!Objects.equals(currentVersion.getId(), failedVersion.getId()) + && currentVersion.getStatus() == DocumentVersionStatus.INDEXED + && document.getStatus() == DocumentStatus.INDEXED) { + return; + } + + boolean noSearchableVersion = Objects.equals(currentVersion.getId(), failedVersion.getId()) + || currentVersion.getStatus() == DocumentVersionStatus.FAILED; + if (noSearchableVersion + && (document.getStatus() == DocumentStatus.UPLOADED + || document.getStatus() == DocumentStatus.INDEXING + || document.getStatus() == DocumentStatus.FAILED)) { + document.markFailed(); + return; + } + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_FAILURE_INCONSISTENT); + } + + private void saveStageFailureEvent( + EmbeddingJob embeddingJob, + EmbeddingJobAttempt attempt, + IndexingEventType eventType, + String fromStatus, + String toStatus, + IndexingFailureType failureType, + LocalDateTime failedAt + ) { + String message = eventType == IndexingEventType.PARSE_FAILED + ? PARSE_FAILURE_MESSAGE + : EMBEDDING_FAILURE_MESSAGE; + indexingEventRepository.save(IndexingEvent.builder() + .embeddingJob(embeddingJob) + .eventType(eventType) + .fromStatus(fromStatus) + .toStatus(toStatus) + .message(message) + .metadataJson(stageFailureMetadata(attempt, failureType)) + .occurredAt(failedAt) + .build()); + } + + private Duration calculateRetryDelay(int currentRetryCount) { + Duration delay = workerProperties.getRetryInitialDelay(); + Duration maxDelay = workerProperties.getRetryMaxDelay(); + for (int retry = 0; retry < currentRetryCount; retry++) { + // 두 배가 최대 지연에 닿는 순간 상한을 반환해 Duration 곱셈 Overflow를 피한다. + if (delay.compareTo(maxDelay.minus(delay)) >= 0) { + return maxDelay; + } + delay = delay.multipliedBy(2); + } + return delay; + } + + private String stageFailureMetadata( + EmbeddingJobAttempt attempt, + IndexingFailureType failureType + ) { + return "{\"attemptId\":" + attempt.getId() + + ",\"attemptNo\":" + attempt.getAttemptNo() + + ",\"failureType\":\"" + failureType.name() + "\"}"; + } + + private String retryMetadata(EmbeddingJob embeddingJob, LocalDateTime nextRetryAt) { + return "{\"retryCount\":" + embeddingJob.getRetryCount() + + ",\"nextRetryAt\":\"" + nextRetryAt + "\"}"; + } + + private String terminalFailureMetadata(EmbeddingJob embeddingJob) { + return "{\"retryCount\":" + embeddingJob.getRetryCount() + + ",\"maxRetryCount\":" + embeddingJob.getMaxRetryCount() + "}"; + } +} diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobClaimService.java b/src/main/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobClaimService.java index 0ba7ee5..91460db 100644 --- a/src/main/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobClaimService.java +++ b/src/main/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobClaimService.java @@ -69,7 +69,7 @@ public Optional claim(Long workerId) { validateClaimable(workerNode, claimedAt); // 4. 잠기지 않은 최우선 PENDING Job을 가져오고, 존재할 때만 Lease 발급 흐름을 계속한다. - return embeddingJobRepository.findNextPendingForUpdate() + return embeddingJobRepository.findNextPendingForUpdate(claimedAt) .map(job -> claim(job, workerNode, claimedAt)); } diff --git a/src/main/java/com/opensource/docgrid/domain/worker/config/IndexingWorkerProperties.java b/src/main/java/com/opensource/docgrid/domain/worker/config/IndexingWorkerProperties.java index af67e34..59564e2 100644 --- a/src/main/java/com/opensource/docgrid/domain/worker/config/IndexingWorkerProperties.java +++ b/src/main/java/com/opensource/docgrid/domain/worker/config/IndexingWorkerProperties.java @@ -13,7 +13,7 @@ import lombok.Setter; /** - * 인덱싱 Worker의 실행 여부, Heartbeat, DEAD 판정, Job Lease 시간을 바인딩하는 설정 클래스. + * 인덱싱 Worker의 실행 여부, Heartbeat, DEAD 판정, Job Lease와 Retry 지연을 바인딩하는 설정 클래스. * *

{@code indexing.worker} 환경 설정을 타입 안전한 {@link Duration}으로 제공하고, 애플리케이션 시작 * 단계에서 서로 모순되거나 0 이하인 시간 설정을 차단한다. @@ -41,6 +41,13 @@ public class IndexingWorkerProperties { @NotNull private Duration leaseDuration = Duration.ofMinutes(5); + // 실패한 Job의 첫 Retry 지연과 지수 Backoff 상한이다. + @NotNull + private Duration retryInitialDelay = Duration.ofSeconds(10); + + @NotNull + private Duration retryMaxDelay = Duration.ofMinutes(5); + /** * Heartbeat가 양수이고 DEAD 기준보다 짧은지 검증한다. */ @@ -62,4 +69,16 @@ public boolean isLeaseDurationValid() { && !leaseDuration.isZero() && !leaseDuration.isNegative(); } + + /** + * Retry 지연이 모두 양수이고 최대 지연이 초기 지연보다 짧지 않은지 검증한다. + */ + @AssertTrue(message = "Retry 최대 지연은 양수인 초기 지연보다 짧을 수 없습니다.") + public boolean isRetryDelayValid() { + return retryInitialDelay != null + && retryMaxDelay != null + && !retryInitialDelay.isZero() + && !retryInitialDelay.isNegative() + && retryMaxDelay.compareTo(retryInitialDelay) >= 0; + } } diff --git a/src/main/java/com/opensource/docgrid/domain/worker/entity/EmbeddingJobAttempt.java b/src/main/java/com/opensource/docgrid/domain/worker/entity/EmbeddingJobAttempt.java index e42844c..853a0db 100644 --- a/src/main/java/com/opensource/docgrid/domain/worker/entity/EmbeddingJobAttempt.java +++ b/src/main/java/com/opensource/docgrid/domain/worker/entity/EmbeddingJobAttempt.java @@ -130,6 +130,10 @@ public void markSuccess(LocalDateTime endedAt, Long durationMs) { } public void markFailed(LocalDateTime endedAt, Long durationMs, String errorCode, String errorMessage) { + // 한 Attempt의 최초 실패 내용이 멱등 재생 중 다른 값으로 덮이지 않도록 종결 상태를 차단한다. + if (status != AttemptStatus.STARTED) { + throw new IllegalStateException("STARTED 상태의 Attempt만 FAILED로 전환할 수 있습니다."); + } this.status = AttemptStatus.FAILED; this.endedAt = endedAt; this.durationMs = durationMs; diff --git a/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java b/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java index 5987e1f..14a3e7b 100644 --- a/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java +++ b/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java @@ -142,6 +142,11 @@ public enum ErrorCode { "DOCUMENT-INDEXING-003", "문서 인덱싱 완료 데이터를 확인할 수 없습니다." ), + DOCUMENT_INDEXING_FAILURE_INCONSISTENT( + HttpStatus.INTERNAL_SERVER_ERROR, + "DOCUMENT-INDEXING-004", + "문서 인덱싱 실패 데이터를 확인할 수 없습니다." + ), // PERMISSION INVALID_TARGET_TYPE(HttpStatus.BAD_REQUEST, "PERMISSION-001", "target_type과 ID 필드 조합이 올바르지 않습니다."), @@ -185,6 +190,11 @@ public enum ErrorCode { "EMBEDDING-JOB-006", "현재 Claim 실행 Context와 Attempt가 일치하지 않습니다." ), + EMBEDDING_JOB_FAILURE_CONFLICT( + HttpStatus.CONFLICT, + "EMBEDDING-JOB-007", + "동일한 Embedding Job Attempt에 다른 실패 내용이 이미 기록되었습니다." + ), // EMBEDDING MODEL EMBEDDING_MODEL_NOT_CONFIGURED( diff --git a/src/main/java/com/opensource/docgrid/global/exception/GlobalExceptionHandler.java b/src/main/java/com/opensource/docgrid/global/exception/GlobalExceptionHandler.java index 40c7cde..05dd5ac 100644 --- a/src/main/java/com/opensource/docgrid/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/opensource/docgrid/global/exception/GlobalExceptionHandler.java @@ -91,7 +91,7 @@ public ResponseEntity handleNotReadable( log.warn("[NotReadable] {} {} | {}", request.getMethod(), request.getRequestURI(), e.getMessage()); return ResponseEntity .status(HttpStatus.BAD_REQUEST) - .body(ErrorResponse.of(ErrorCode.BAD_REQUEST, request)); + .body(ErrorResponse.of(ErrorCode.INVALID_PARAMETER, request)); } @ExceptionHandler(HttpRequestMethodNotSupportedException.class) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2c17f4f..6144b73 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -24,6 +24,9 @@ indexing: heartbeat-interval: ${INDEXING_WORKER_HEARTBEAT_INTERVAL:10s} dead-threshold: ${INDEXING_WORKER_DEAD_THRESHOLD:30s} lease-duration: ${INDEXING_WORKER_LEASE_DURATION:5m} + # Retry 지연은 10초부터 지수 증가하며 운영 기본 상한인 5분에서 제한한다. + retry-initial-delay: ${INDEXING_WORKER_RETRY_INITIAL_DELAY:10s} + retry-max-delay: ${INDEXING_WORKER_RETRY_MAX_DELAY:5m} server: port: 8080 diff --git a/src/main/resources/db/migration/V35__add_embedding_job_next_retry_at.sql b/src/main/resources/db/migration/V35__add_embedding_job_next_retry_at.sql new file mode 100644 index 0000000..0e073e5 --- /dev/null +++ b/src/main/resources/db/migration/V35__add_embedding_job_next_retry_at.sql @@ -0,0 +1,6 @@ +-- Retry 예약 Job이 실행 가능해지는 시각. null인 기존·신규 Job은 즉시 Claim할 수 있다. +ALTER TABLE embedding_jobs + ADD COLUMN next_retry_at TIMESTAMP; + +CREATE INDEX idx_embedding_jobs_status_next_retry_at + ON embedding_jobs (status, next_retry_at); diff --git a/src/test/java/com/opensource/docgrid/domain/document/entity/DocumentVersionTest.java b/src/test/java/com/opensource/docgrid/domain/document/entity/DocumentVersionTest.java index dca49fb..fca67ec 100644 --- a/src/test/java/com/opensource/docgrid/domain/document/entity/DocumentVersionTest.java +++ b/src/test/java/com/opensource/docgrid/domain/document/entity/DocumentVersionTest.java @@ -13,7 +13,8 @@ import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; /** - * Document Version 파이프라인의 PARSING·CHUNKED·EMBEDDING·INDEXED 상태 전이 Guard를 검증한다. + * Document Version 파이프라인의 UPLOADED·PARSING·CHUNKED·EMBEDDING·INDEXED·FAILED 상태 전이 + * Guard를 검증한다. * *

Command Service를 우회한 잘못된 상태 변경은 즉시 실패하고 정상 순서만 허용되는지 확인한다. */ @@ -99,6 +100,29 @@ void markIndexed_rejectsUnexpectedStatus(DocumentVersionStatus status) { assertThat(version.getIndexedAt()).isNull(); } + @ParameterizedTest + @EnumSource(value = DocumentVersionStatus.class, names = {"UPLOADED", "PARSING", "CHUNKED", "EMBEDDING"}) + @DisplayName("처리 중인 Version은 FAILED로 종결할 수 있다") + void markFailed_transitionsProcessingStatus(DocumentVersionStatus status) { + DocumentVersion version = version(status); + + version.markFailed(); + + assertThat(version.getStatus()).isEqualTo(DocumentVersionStatus.FAILED); + } + + @ParameterizedTest + @EnumSource(value = DocumentVersionStatus.class, names = {"INDEXED", "FAILED"}) + @DisplayName("완료되거나 이미 실패한 Version은 다시 FAILED로 전환할 수 없다") + void markFailed_rejectsTerminalStatus(DocumentVersionStatus status) { + DocumentVersion version = version(status); + + assertThatThrownBy(version::markFailed) + .isInstanceOf(IllegalStateException.class) + .hasMessage("처리 중인 문서 버전만 FAILED로 전환할 수 있습니다."); + assertThat(version.getStatus()).isEqualTo(status); + } + private DocumentVersion version(DocumentVersionStatus status) { return DocumentVersion.builder() .versionNo(1) 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 fe65b0c..b395936 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 @@ -34,11 +34,14 @@ 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.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.embedding.enums.IndexingFailureType; 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; @@ -48,7 +51,7 @@ import com.opensource.docgrid.global.exception.ErrorCode; /** - * 관리자용 Job Claim, Attempt 시작과 Document Chunk·Embedding 생성·인덱싱 완료 API 계약을 검증한다. + * 관리자용 Job Claim, Attempt 시작과 Document Chunk·Embedding 생성·인덱싱 완료·실패 API 계약을 검증한다. * *

각 API의 최초 생성·멱등 재생·Validation·비즈니스 오류 및 ADMIN Security 동작을 * 실제 Service 실행 없이 Controller 경계에서 확인한다. @@ -63,6 +66,7 @@ class IndexingJobAdminControllerTest { private static final String CHUNKS_URL = "/admin/indexing-jobs/10/attempts/100/chunks"; private static final String EMBEDDINGS_URL = "/admin/indexing-jobs/10/attempts/100/embeddings"; private static final String COMPLETE_URL = "/admin/indexing-jobs/10/attempts/100/complete"; + private static final String FAIL_URL = "/admin/indexing-jobs/10/attempts/100/fail"; private static final Long JOB_ID = 10L; private static final Long ATTEMPT_ID = 100L; private static final Long WORKER_ID = 1L; @@ -73,6 +77,14 @@ class IndexingJobAdminControllerTest { "claimToken": "34c19d16-6ae1-4f6a-a35d-0123456789ab" } """; + private static final String VALID_FAILURE_BODY = """ + { + "workerId": 1, + "claimToken": "34c19d16-6ae1-4f6a-a35d-0123456789ab", + "failureType": "EMBEDDING_PROVIDER_UNAVAILABLE", + "errorMessage": "Embedding provider request timed out" + } + """; @Autowired private MockMvc mockMvc; @@ -81,6 +93,7 @@ class IndexingJobAdminControllerTest { @MockitoBean private DocumentParsingService documentParsingService; @MockitoBean private DocumentEmbeddingService documentEmbeddingService; @MockitoBean private DocumentIndexingCompletionService documentIndexingCompletionService; + @MockitoBean private DocumentIndexingFailureService documentIndexingFailureService; @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; @MockitoBean private JwtProvider jwtProvider; @MockitoBean private CorsConfigurationSource corsConfigurationSource; @@ -503,6 +516,82 @@ void completeIndexing_returnsForbidden_withoutAdminRole() throws Exception { .andExpect(status().isForbidden()); } + @Test + @DisplayName("ADMIN 사용자의 최초 실패와 멱등 재생은 Attempt 기반 응답으로 200을 반환한다") + void failIndexing_returnsOkWithoutSensitiveFields() throws Exception { + DocumentIndexingFailureResponse response = createFailureResponse(); + given(documentIndexingFailureService.fail(eq(JOB_ID), eq(ATTEMPT_ID), any())) + .willReturn(response); + + mockMvc.perform(post(FAIL_URL) + .contentType("application/json") + .content(VALID_FAILURE_BODY) + .with(user("admin").roles("ADMIN"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.jobId").value(JOB_ID)) + .andExpect(jsonPath("$.data.attemptId").value(ATTEMPT_ID)) + .andExpect(jsonPath("$.data.attemptNo").value(2)) + .andExpect(jsonPath("$.data.attemptStatus").value("FAILED")) + .andExpect(jsonPath("$.data.failureType").value("EMBEDDING_PROVIDER_UNAVAILABLE")) + .andExpect(jsonPath("$.data.failedAt").value("2026-08-03T10:30:00")) + .andExpect(jsonPath("$.data.durationMs").value(42031)) + .andExpect(jsonPath("$.data.claimToken").doesNotExist()) + .andExpect(jsonPath("$.data.errorMessage").doesNotExist()) + .andExpect(jsonPath("$.data.jobStatus").doesNotExist()) + .andExpect(jsonPath("$.data.nextRetryAt").doesNotExist()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidFailureRequests") + @DisplayName("잘못된 인덱싱 실패 요청은 400을 반환한다") + void failIndexing_returnsBadRequest_when_requestIsInvalid( + String description, + String url, + String body + ) throws Exception { + mockMvc.perform(post(url) + .contentType("application/json") + .content(body) + .with(user("admin").roles("ADMIN"))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON-002")); + } + + @ParameterizedTest + @MethodSource("failureBusinessErrors") + @DisplayName("인덱싱 실패 비즈니스 오류를 정의된 HTTP 상태와 코드로 반환한다") + void failIndexing_returnsDefinedError( + ErrorCode errorCode, + int expectedStatus, + String expectedCode + ) throws Exception { + given(documentIndexingFailureService.fail(eq(JOB_ID), eq(ATTEMPT_ID), any())) + .willThrow(new DocGridException(errorCode)); + + mockMvc.perform(post(FAIL_URL) + .contentType("application/json") + .content(VALID_FAILURE_BODY) + .with(user("admin").roles("ADMIN"))) + .andExpect(status().is(expectedStatus)) + .andExpect(jsonPath("$.code").value(expectedCode)); + } + + @Test + @DisplayName("일반 사용자와 미인증 사용자는 인덱싱 실패를 보고할 수 없다") + void failIndexing_returnsForbidden_withoutAdminRole() throws Exception { + mockMvc.perform(post(FAIL_URL) + .contentType("application/json") + .content(VALID_FAILURE_BODY) + .with(user("user").roles("USER"))) + .andExpect(status().isForbidden()); + + mockMvc.perform(post(FAIL_URL) + .contentType("application/json") + .content(VALID_FAILURE_BODY)) + .andExpect(status().isForbidden()); + } + private static Stream invalidAttemptRequests() { return Stream.of( Arguments.of("Job ID가 양수가 아님", "/admin/indexing-jobs/0/attempts", VALID_ATTEMPT_BODY), @@ -651,6 +740,84 @@ private static Stream completionBusinessErrors() { ); } + private static Stream invalidFailureRequests() { + return Stream.of( + Arguments.of( + "Job ID가 양수가 아님", + "/admin/indexing-jobs/0/attempts/100/fail", + VALID_FAILURE_BODY + ), + Arguments.of( + "Attempt ID가 양수가 아님", + "/admin/indexing-jobs/10/attempts/0/fail", + VALID_FAILURE_BODY + ), + Arguments.of("Worker ID가 양수가 아님", FAIL_URL, """ + { + "workerId": 0, + "claimToken": "%s", + "failureType": "WORKER_INTERNAL_ERROR", + "errorMessage": "temporary failure" + } + """.formatted(CLAIM_TOKEN)), + Arguments.of("Claim Token 형식 오류", FAIL_URL, """ + { + "workerId": 1, + "claimToken": "not-a-uuid", + "failureType": "WORKER_INTERNAL_ERROR", + "errorMessage": "temporary failure" + } + """), + Arguments.of("실패 유형 누락", FAIL_URL, """ + { + "workerId": 1, + "claimToken": "%s", + "errorMessage": "temporary failure" + } + """.formatted(CLAIM_TOKEN)), + Arguments.of("실패 유형 Enum 오류", FAIL_URL, """ + { + "workerId": 1, + "claimToken": "%s", + "failureType": "UNKNOWN_FAILURE", + "errorMessage": "temporary failure" + } + """.formatted(CLAIM_TOKEN)), + Arguments.of("오류 메시지 공백", FAIL_URL, """ + { + "workerId": 1, + "claimToken": "%s", + "failureType": "WORKER_INTERNAL_ERROR", + "errorMessage": " " + } + """.formatted(CLAIM_TOKEN)), + Arguments.of("오류 메시지 2000자 초과", FAIL_URL, """ + { + "workerId": 1, + "claimToken": "%s", + "failureType": "WORKER_INTERNAL_ERROR", + "errorMessage": "%s" + } + """.formatted(CLAIM_TOKEN, "x".repeat(2001))) + ); + } + + private static Stream failureBusinessErrors() { + return Stream.of( + Arguments.of(ErrorCode.EMBEDDING_JOB_NOT_FOUND, 404, "EMBEDDING-JOB-001"), + Arguments.of(ErrorCode.EMBEDDING_JOB_NOT_PROCESSING, 409, "EMBEDDING-JOB-002"), + Arguments.of(ErrorCode.EMBEDDING_JOB_OWNERSHIP_INVALID, 409, "EMBEDDING-JOB-003"), + Arguments.of(ErrorCode.EMBEDDING_JOB_LEASE_EXPIRED, 409, "EMBEDDING-JOB-004"), + Arguments.of(ErrorCode.EMBEDDING_JOB_ATTEMPT_INVALID, 409, "EMBEDDING-JOB-006"), + Arguments.of(ErrorCode.EMBEDDING_JOB_FAILURE_CONFLICT, 409, "EMBEDDING-JOB-007"), + Arguments.of( + ErrorCode.DOCUMENT_INDEXING_FAILURE_INCONSISTENT, + 500, + "DOCUMENT-INDEXING-004" + ) + ); + } + private StartedEmbeddingJobAttemptResponse createAttemptResponse() { return new StartedEmbeddingJobAttemptResponse( 100L, @@ -698,4 +865,16 @@ private DocumentIndexingCompletionResponse createCompletionResponse() { 8_421L ); } + + private DocumentIndexingFailureResponse createFailureResponse() { + return new DocumentIndexingFailureResponse( + JOB_ID, + ATTEMPT_ID, + 2, + AttemptStatus.FAILED, + IndexingFailureType.EMBEDDING_PROVIDER_UNAVAILABLE, + LocalDateTime.of(2026, 8, 3, 10, 30), + 42_031L + ); + } } diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/converter/EmbeddingJobAttemptConverterTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/converter/EmbeddingJobAttemptConverterTest.java index cfbef32..da20b6c 100644 --- a/src/test/java/com/opensource/docgrid/domain/embedding/converter/EmbeddingJobAttemptConverterTest.java +++ b/src/test/java/com/opensource/docgrid/domain/embedding/converter/EmbeddingJobAttemptConverterTest.java @@ -1,6 +1,7 @@ package com.opensource.docgrid.domain.embedding.converter; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.time.LocalDateTime; @@ -9,17 +10,22 @@ import org.springframework.test.util.ReflectionTestUtils; import com.opensource.docgrid.domain.embedding.dto.response.StartedEmbeddingJobAttemptResponse; +import com.opensource.docgrid.domain.embedding.dto.response.DocumentIndexingFailureResponse; import com.opensource.docgrid.domain.embedding.entity.EmbeddingJob; import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.embedding.enums.IndexingFailureType; import com.opensource.docgrid.domain.worker.entity.EmbeddingJobAttempt; import com.opensource.docgrid.domain.worker.entity.WorkerNode; import com.opensource.docgrid.domain.worker.enums.AttemptStatus; import com.opensource.docgrid.domain.worker.enums.WorkerStatus; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; /** - * Embedding Job Attempt Entity가 Token 비노출 시작 응답으로 정확히 변환되는지 검증하는 단위 테스트. + * Embedding Job Attempt Entity가 Token과 오류 메시지를 노출하지 않는 시작·실패 응답으로 변환되는지 검증한다. * - *

연관 Entity 대신 Job·Worker 식별자를 사용하고 외부 계약에 필요한 시작 정보만 반환하는지 확인한다. + *

연관 Entity 대신 Job·Worker 식별자를 사용하고 외부 계약에 필요한 정보만 반환하며, 알 수 없는 실패 + * 코드는 데이터 불일치로 거부하는지 확인한다. */ @DisplayName("EmbeddingJobAttemptConverter 테스트") class EmbeddingJobAttemptConverterTest { @@ -70,4 +76,80 @@ void toStartedResponse_convertsAttemptWithoutClaimToken() { .extracting(component -> component.getName()) .doesNotContain("claimToken", "errorCode", "errorMessage"); } + + @Test + @DisplayName("종료된 Attempt의 불변 실패 정보만 실패 응답으로 변환한다") + void toFailureResponse_convertsPersistedFailure() { + EmbeddingJob embeddingJob = EmbeddingJob.builder() + .status(EmbeddingJobStatus.PENDING) + .priority(0) + .maxRetryCount(3) + .build(); + WorkerNode workerNode = WorkerNode.builder() + .workerName("attempt-worker") + .instanceId("attempt-worker-instance") + .status(WorkerStatus.ACTIVE) + .lastHeartbeatAt(STARTED_AT) + .startedAt(STARTED_AT.minusMinutes(1)) + .build(); + ReflectionTestUtils.setField(embeddingJob, "id", JOB_ID); + + EmbeddingJobAttempt attempt = EmbeddingJobAttempt.builder() + .embeddingJob(embeddingJob) + .workerNode(workerNode) + .attemptNo(2) + .claimToken("34c19d16-6ae1-4f6a-a35d-0123456789ab") + .startedAt(STARTED_AT) + .build(); + ReflectionTestUtils.setField(attempt, "id", ATTEMPT_ID); + LocalDateTime failedAt = STARTED_AT.plusSeconds(4); + attempt.markFailed( + failedAt, + 4_000L, + IndexingFailureType.EMBEDDING_PROVIDER_UNAVAILABLE.name(), + "Embedding provider request timed out" + ); + + DocumentIndexingFailureResponse response = converter.toFailureResponse(attempt); + + assertThat(response.jobId()).isEqualTo(JOB_ID); + assertThat(response.attemptId()).isEqualTo(ATTEMPT_ID); + assertThat(response.attemptNo()).isEqualTo(2); + assertThat(response.attemptStatus()).isEqualTo(AttemptStatus.FAILED); + assertThat(response.failureType()).isEqualTo(IndexingFailureType.EMBEDDING_PROVIDER_UNAVAILABLE); + assertThat(response.failedAt()).isEqualTo(failedAt); + assertThat(response.durationMs()).isEqualTo(4_000L); + assertThat(DocumentIndexingFailureResponse.class.getRecordComponents()) + .extracting(component -> component.getName()) + .doesNotContain("claimToken", "errorCode", "errorMessage", "jobStatus", "nextRetryAt"); + } + + @Test + @DisplayName("알 수 없는 저장 실패 코드는 데이터 불일치로 거부한다") + void toFailureResponse_throws_when_failureCodeIsUnknown() { + EmbeddingJob embeddingJob = EmbeddingJob.builder() + .status(EmbeddingJobStatus.PENDING) + .priority(0) + .maxRetryCount(3) + .build(); + ReflectionTestUtils.setField(embeddingJob, "id", JOB_ID); + EmbeddingJobAttempt attempt = EmbeddingJobAttempt.builder() + .embeddingJob(embeddingJob) + .attemptNo(2) + .startedAt(STARTED_AT) + .endedAt(STARTED_AT.plusSeconds(4)) + .durationMs(4_000L) + .status(AttemptStatus.FAILED) + .errorCode("LEGACY_UNKNOWN_FAILURE") + .errorMessage("legacy failure") + .build(); + ReflectionTestUtils.setField(attempt, "id", ATTEMPT_ID); + + assertThatThrownBy(() -> converter.toFailureResponse(attempt)) + .isInstanceOf(DocGridException.class) + .hasFieldOrPropertyWithValue( + "errorCode", + ErrorCode.DOCUMENT_INDEXING_FAILURE_INCONSISTENT + ); + } } diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/dto/request/FailDocumentIndexingRequestTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/dto/request/FailDocumentIndexingRequestTest.java new file mode 100644 index 0000000..cc23774 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/dto/request/FailDocumentIndexingRequestTest.java @@ -0,0 +1,74 @@ +package com.opensource.docgrid.domain.embedding.dto.request; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.opensource.docgrid.domain.embedding.enums.IndexingFailureType; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; + +/** + * 인덱싱 실패 요청의 Worker, Claim Token, 실패 유형과 진단 메시지 입력 경계를 검증한다. + */ +@DisplayName("FailDocumentIndexingRequest 테스트") +class FailDocumentIndexingRequestTest { + + private static final String CLAIM_TOKEN = "34c19d16-6ae1-4f6a-a35d-0123456789ab"; + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + @DisplayName("모든 필수 값이 경계를 만족하면 유효하다") + void validRequest_hasNoViolations() { + FailDocumentIndexingRequest request = new FailDocumentIndexingRequest( + 7L, + CLAIM_TOKEN, + IndexingFailureType.EMBEDDING_PROVIDER_UNAVAILABLE, + "Embedding provider request timed out" + ); + + assertThat(validator.validate(request)).isEmpty(); + } + + @Test + @DisplayName("양수가 아닌 Worker와 잘못된 Token은 거부한다") + void ownershipFields_rejectInvalidValues() { + FailDocumentIndexingRequest request = new FailDocumentIndexingRequest( + 0L, + "not-a-canonical-uuid", + IndexingFailureType.WORKER_INTERNAL_ERROR, + "temporary failure" + ); + + assertThat(validator.validate(request)) + .extracting(violation -> violation.getPropertyPath().toString()) + .contains("workerId", "claimToken"); + } + + @Test + @DisplayName("실패 유형과 메시지는 필수이며 메시지는 2000자를 넘을 수 없다") + void failureFields_rejectMissingOrOversizedValues() { + FailDocumentIndexingRequest missingRequest = new FailDocumentIndexingRequest( + 7L, + CLAIM_TOKEN, + null, + " " + ); + FailDocumentIndexingRequest oversizedRequest = new FailDocumentIndexingRequest( + 7L, + CLAIM_TOKEN, + IndexingFailureType.STORAGE_UNAVAILABLE, + "x".repeat(2001) + ); + + assertThat(validator.validate(missingRequest)) + .extracting(violation -> violation.getPropertyPath().toString()) + .contains("failureType", "errorMessage"); + assertThat(validator.validate(oversizedRequest)) + .extracting(violation -> violation.getPropertyPath().toString()) + .contains("errorMessage"); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingJobTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingJobTest.java index a385963..92384d4 100644 --- a/src/test/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingJobTest.java +++ b/src/test/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingJobTest.java @@ -13,7 +13,7 @@ import com.opensource.docgrid.domain.worker.enums.WorkerStatus; /** - * Embedding Job의 Claim·인덱싱 완료 상태 전이와 소유권 불변식을 검증하는 Entity 단위 테스트. + * Embedding Job의 Claim·Retry 예약·인덱싱 완료·실패 상태 전이와 소유권 불변식을 검증하는 Entity 단위 테스트. * *

PENDING Job이 PROCESSING으로 바뀔 때 Worker, Token, Lease, 최초 시작 시각이 함께 기록되는지와 * 이미 Claim된 Job의 소유권 덮어쓰기가 차단되는지 확인한다. @@ -76,6 +76,132 @@ void markIndexed_acceptsOnlyProcessingJob() { assertThat(pendingJob.getStatus()).isEqualTo(EmbeddingJobStatus.PENDING); } + @Test + @DisplayName("PROCESSING Job의 Retry를 예약하면 횟수와 시각을 기록하고 현재 소유권을 해제한다") + void scheduleRetry_requeuesJobAndReleasesOwnership() { + EmbeddingJob embeddingJob = createPendingJob(); + embeddingJob.claim(createActiveWorker(), CLAIM_TOKEN, CLAIMED_AT, EXPIRES_AT); + LocalDateTime nextRetryAt = CLAIMED_AT.plusSeconds(10); + + embeddingJob.scheduleRetry("STORAGE_UNAVAILABLE", "Storage timeout", nextRetryAt); + + assertThat(embeddingJob.getStatus()).isEqualTo(EmbeddingJobStatus.PENDING); + assertThat(embeddingJob.getRetryCount()).isEqualTo(1); + assertThat(embeddingJob.getNextRetryAt()).isEqualTo(nextRetryAt); + assertThat(embeddingJob.getErrorCode()).isEqualTo("STORAGE_UNAVAILABLE"); + assertThat(embeddingJob.getErrorMessage()).isEqualTo("Storage timeout"); + assertThat(embeddingJob.getLockedByWorker()).isNull(); + assertThat(embeddingJob.getClaimToken()).isNull(); + assertThat(embeddingJob.getLockedAt()).isNull(); + assertThat(embeddingJob.getLockExpiresAt()).isNull(); + assertThat(embeddingJob.getStartedAt()).isEqualTo(CLAIMED_AT); + } + + @Test + @DisplayName("Retry로 복귀한 Job을 다시 Claim하면 예약 시각을 소비하고 최초 시작 시각을 보존한다") + void claim_clearsRetryScheduleAndPreservesFirstStart() { + EmbeddingJob embeddingJob = createPendingJob(); + WorkerNode workerNode = createActiveWorker(); + embeddingJob.claim(workerNode, CLAIM_TOKEN, CLAIMED_AT, EXPIRES_AT); + embeddingJob.scheduleRetry("STORAGE_UNAVAILABLE", "Storage timeout", CLAIMED_AT.plusSeconds(10)); + + LocalDateTime reclaimedAt = CLAIMED_AT.plusSeconds(10); + embeddingJob.claim( + workerNode, + "8d242ac5-0916-4e1c-a781-1f7b932f989b", + reclaimedAt, + reclaimedAt.plusMinutes(5) + ); + + assertThat(embeddingJob.getNextRetryAt()).isNull(); + assertThat(embeddingJob.getStartedAt()).isEqualTo(CLAIMED_AT); + assertThat(embeddingJob.getRetryCount()).isEqualTo(1); + } + + @Test + @DisplayName("Retry 횟수를 모두 소진한 Job은 다시 예약할 수 없다") + void scheduleRetry_throws_when_retriesAreExhausted() { + EmbeddingJob embeddingJob = EmbeddingJob.builder() + .status(EmbeddingJobStatus.PENDING) + .priority(0) + .maxRetryCount(1) + .build(); + WorkerNode workerNode = createActiveWorker(); + embeddingJob.claim(workerNode, CLAIM_TOKEN, CLAIMED_AT, EXPIRES_AT); + embeddingJob.scheduleRetry("WORKER_INTERNAL_ERROR", "temporary", CLAIMED_AT.plusSeconds(10)); + LocalDateTime reclaimedAt = CLAIMED_AT.plusSeconds(10); + embeddingJob.claim( + workerNode, + "8d242ac5-0916-4e1c-a781-1f7b932f989b", + reclaimedAt, + reclaimedAt.plusMinutes(5) + ); + + assertThat(embeddingJob.hasRemainingRetries()).isFalse(); + assertThatThrownBy(() -> embeddingJob.scheduleRetry( + "WORKER_INTERNAL_ERROR", + "temporary", + reclaimedAt.plusSeconds(20) + )).isInstanceOf(IllegalStateException.class) + .hasMessage("Embedding Job Retry 횟수를 모두 소진했습니다."); + assertThat(embeddingJob.getStatus()).isEqualTo(EmbeddingJobStatus.PROCESSING); + } + + @Test + @DisplayName("PROCESSING이 아닌 Job은 Retry를 예약할 수 없다") + void scheduleRetry_throws_when_jobIsNotProcessing() { + EmbeddingJob pendingJob = createPendingJob(); + + assertThatThrownBy(() -> pendingJob.scheduleRetry( + "STORAGE_UNAVAILABLE", + "Storage timeout", + CLAIMED_AT.plusSeconds(10) + )).isInstanceOf(IllegalStateException.class) + .hasMessage("PROCESSING 상태의 Job만 Retry를 예약할 수 있습니다."); + assertThat(pendingJob.getStatus()).isEqualTo(EmbeddingJobStatus.PENDING); + } + + @Test + @DisplayName("다음 Retry 시각이 없으면 예약할 수 없다") + void scheduleRetry_throws_when_nextRetryAtIsNull() { + EmbeddingJob embeddingJob = createPendingJob(); + embeddingJob.claim(createActiveWorker(), CLAIM_TOKEN, CLAIMED_AT, EXPIRES_AT); + + assertThatThrownBy(() -> embeddingJob.scheduleRetry( + "STORAGE_UNAVAILABLE", + "Storage timeout", + null + )).isInstanceOf(IllegalArgumentException.class) + .hasMessage("다음 Retry 시각은 필수입니다."); + assertThat(embeddingJob.getStatus()).isEqualTo(EmbeddingJobStatus.PROCESSING); + assertThat(embeddingJob.getRetryCount()).isZero(); + } + + @Test + @DisplayName("PROCESSING Job만 최종 FAILED로 종료하고 예약 시각을 제거할 수 있다") + void markFailed_acceptsOnlyProcessingJob() { + EmbeddingJob processingJob = createPendingJob(); + processingJob.claim(createActiveWorker(), CLAIM_TOKEN, CLAIMED_AT, EXPIRES_AT); + LocalDateTime failedAt = CLAIMED_AT.plusSeconds(3); + + processingJob.markFailed("DOCUMENT_CONTENT_INVALID", "Unsupported content", failedAt); + + assertThat(processingJob.getStatus()).isEqualTo(EmbeddingJobStatus.FAILED); + assertThat(processingJob.getErrorCode()).isEqualTo("DOCUMENT_CONTENT_INVALID"); + assertThat(processingJob.getErrorMessage()).isEqualTo("Unsupported content"); + assertThat(processingJob.getFailedAt()).isEqualTo(failedAt); + assertThat(processingJob.getNextRetryAt()).isNull(); + + EmbeddingJob pendingJob = createPendingJob(); + assertThatThrownBy(() -> pendingJob.markFailed( + "DOCUMENT_CONTENT_INVALID", + "Unsupported content", + failedAt + )).isInstanceOf(IllegalStateException.class) + .hasMessage("PROCESSING 상태의 Job만 FAILED로 전환할 수 있습니다."); + assertThat(pendingJob.getStatus()).isEqualTo(EmbeddingJobStatus.PENDING); + } + private EmbeddingJob createPendingJob() { return EmbeddingJob.builder() .status(EmbeddingJobStatus.PENDING) diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/enums/IndexingFailureTypeTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/enums/IndexingFailureTypeTest.java new file mode 100644 index 0000000..e5bd839 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/enums/IndexingFailureTypeTest.java @@ -0,0 +1,32 @@ +package com.opensource.docgrid.domain.embedding.enums; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +/** + * 인덱싱 실패 유형별 Retry 가능 정책이 외부 입력과 무관하게 고정되는지 검증한다. + */ +@DisplayName("IndexingFailureType 테스트") +class IndexingFailureTypeTest { + + @ParameterizedTest + @EnumSource(value = IndexingFailureType.class, names = { + "STORAGE_UNAVAILABLE", "EMBEDDING_PROVIDER_UNAVAILABLE", "WORKER_INTERNAL_ERROR" + }) + @DisplayName("일시적인 인프라와 Worker 내부 오류는 Retry할 수 있다") + void retryableTypes_returnTrue(IndexingFailureType failureType) { + assertThat(failureType.isRetryable()).isTrue(); + } + + @ParameterizedTest + @EnumSource(value = IndexingFailureType.class, names = { + "DOCUMENT_CONTENT_INVALID", "EMBEDDING_RESULT_INVALID", "INDEXING_STATE_INCONSISTENT" + }) + @DisplayName("데이터와 상태 불변식 오류는 Retry하지 않는다") + void permanentTypes_returnFalse(IndexingFailureType failureType) { + assertThat(failureType.isRetryable()).isFalse(); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/integration/DocumentIndexingFailureIntegrationTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/integration/DocumentIndexingFailureIntegrationTest.java new file mode 100644 index 0000000..8a91ff3 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/integration/DocumentIndexingFailureIntegrationTest.java @@ -0,0 +1,660 @@ +package com.opensource.docgrid.domain.embedding.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +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 org.springframework.transaction.support.TransactionTemplate; + +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.response.DocumentIndexingFailureResponse; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingJob; +import com.opensource.docgrid.domain.embedding.enums.IndexingFailureType; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingJobRepository; +import com.opensource.docgrid.domain.embedding.service.command.DocumentIndexingCompletionService; +import com.opensource.docgrid.domain.embedding.service.command.DocumentIndexingFailureService; +import com.opensource.docgrid.domain.search.dto.VectorSearchCandidate; +import com.opensource.docgrid.domain.search.service.query.VectorSearchQueryService; +import com.opensource.docgrid.global.exception.DocGridException; + +/** + * 실제 PostgreSQL에서 인덱싱 실패의 예약 Queue, 최종 검색 상태, 멱등성과 완료 경쟁을 검증한다. + * + *

격리 Schema에 각 실행 상태를 직접 구성한 뒤 실제 Service Transaction과 PostgreSQL 행 잠금을 + * 사용해 Retry 또는 최종 실패가 부분 상태 없이 원자 커밋되는지 확인한다. + */ +@Tag("integration") +@ActiveProfiles("test") +@SpringBootTest +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@DisplayName("Document 인덱싱 실패 PostgreSQL 통합 테스트") +class DocumentIndexingFailureIntegrationTest { + + private static final String TEST_SCHEMA = "docgrid_index_failure_integration_test"; + private static final int VECTOR_DIMENSION = 1024; + private static final int CONCURRENT_REQUESTS = 2; + private static final long TIMEOUT_SECONDS = 10; + private static final String CLAIM_TOKEN = "34c19d16-6ae1-4f6a-a35d-0123456789ab"; + private static final String CONTENT_HASH = + "26e4a23eec4241e034f1b4631f0222f1895847637c35e77687d5945f75edb42c"; + + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private TransactionTemplate transactionTemplate; + @Autowired private EmbeddingJobRepository embeddingJobRepository; + @Autowired private DocumentIndexingFailureService failureService; + @Autowired private DocumentIndexingCompletionService completionService; + @Autowired private VectorSearchQueryService vectorSearchQueryService; + + @DynamicPropertySource + static void configureDatabase(DynamicPropertyRegistry registry) { + registry.add("TEST_DB_SCHEMA", () -> TEST_SCHEMA); + registry.add("jwt.secret", () -> "docgrid-index-failure-integration-test-secret-key-2026"); + registry.add("indexing.worker.retry-initial-delay", () -> "10s"); + registry.add("indexing.worker.retry-max-delay", () -> "5m"); + } + + @BeforeEach + void resetState() { + jdbcTemplate.execute(""" + TRUNCATE TABLE + search_results, + search_queries, + embeddings, + indexing_events, + document_chunks, + 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("Retry 예약 전 Job은 선택되지 않고 정확한 예약 시각부터 Queue 후보가 된다") + void retryJob_becomesClaimableAtScheduledTime() { + ExecutionContext context = insertFirstVersionExecution("PARSING", false); + + DocumentIndexingFailureResponse response = failureService.fail( + context.jobId(), + context.attemptId(), + failureRequest(context.workerId(), IndexingFailureType.STORAGE_UNAVAILABLE, "Storage timeout") + ); + + LocalDateTime nextRetryAt = queryDateTime( + "SELECT next_retry_at FROM embedding_jobs WHERE id = ?", + context.jobId() + ); + assertThat(Duration.between(response.failedAt(), nextRetryAt)).isEqualTo(Duration.ofSeconds(10)); + assertThat(findPendingJobAt(nextRetryAt.minusNanos(1_000))).isNull(); + assertThat(findPendingJobAt(nextRetryAt)).isEqualTo(context.jobId()); + assertThat(queryString("SELECT status FROM embedding_jobs WHERE id = ?", context.jobId())) + .isEqualTo("PENDING"); + assertThat(queryInteger( + "SELECT retry_count FROM embedding_jobs WHERE id = ?", + context.jobId() + )).isOne(); + assertThat(queryString( + "SELECT status FROM document_versions WHERE id = ?", + context.targetVersionId() + )).isEqualTo("PARSING"); + assertThat(eventCount(context.jobId(), "PARSE_FAILED")).isOne(); + assertThat(eventCount(context.jobId(), "RETRY")).isOne(); + } + + @Test + @DisplayName("같은 실패 요청 두 건은 단일 Retry와 동일한 Attempt 응답으로 수렴한다") + void failConcurrently_isIdempotent() throws Exception { + ExecutionContext context = insertFirstVersionExecution("PARSING", false); + FailDocumentIndexingRequest request = failureRequest( + context.workerId(), + IndexingFailureType.WORKER_INTERNAL_ERROR, + "temporary worker failure" + ); + CyclicBarrier startBarrier = new CyclicBarrier(CONCURRENT_REQUESTS); + ExecutorService executor = Executors.newFixedThreadPool(CONCURRENT_REQUESTS); + + List responses; + try { + List> futures = List.of( + executor.submit(() -> failAfterBarrier(context, request, startBarrier)), + executor.submit(() -> failAfterBarrier(context, request, startBarrier)) + ); + responses = List.of( + futures.get(0).get(TIMEOUT_SECONDS, TimeUnit.SECONDS), + futures.get(1).get(TIMEOUT_SECONDS, TimeUnit.SECONDS) + ); + } finally { + executor.shutdownNow(); + assertThat(executor.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + } + + assertThat(responses.get(1)).isEqualTo(responses.get(0)); + assertThat(queryInteger( + "SELECT retry_count FROM embedding_jobs WHERE id = ?", + context.jobId() + )).isOne(); + assertThat(eventCount(context.jobId(), "PARSE_FAILED")).isOne(); + assertThat(eventCount(context.jobId(), "RETRY")).isOne(); + assertThat(queryString( + "SELECT status FROM embedding_job_attempts WHERE id = ?", + context.attemptId() + )).isEqualTo("FAILED"); + } + + @Test + @DisplayName("새 Version의 영구 실패는 이전 INDEXED 검색 Set을 그대로 보존한다") + void terminalFailure_preservesPreviousSearchableVersion() { + ExecutionContext context = insertReplacementVersionExecution(); + + assertThat(search(context)) + .extracting(VectorSearchCandidate::chunkText) + .containsExactly("이전 검색 본문"); + + failureService.fail( + context.jobId(), + context.attemptId(), + failureRequest( + context.workerId(), + IndexingFailureType.EMBEDDING_RESULT_INVALID, + "Vector dimension mismatch" + ) + ); + + assertThat(queryString("SELECT status FROM documents WHERE id = ?", context.documentId())) + .isEqualTo("INDEXED"); + assertThat(queryLong( + "SELECT current_version_id FROM documents WHERE id = ?", + context.documentId() + )).isEqualTo(context.previousVersionId()); + assertThat(queryString( + "SELECT status FROM document_versions WHERE id = ?", + context.targetVersionId() + )).isEqualTo("FAILED"); + assertThat(queryString( + "SELECT status FROM embeddings WHERE document_version_id = ?", + context.previousVersionId() + )).isEqualTo("ACTIVE"); + assertThat(queryString( + "SELECT status FROM embeddings WHERE document_version_id = ?", + context.targetVersionId() + )).isEqualTo("STALE"); + assertThat(search(context)) + .extracting(VectorSearchCandidate::chunkText) + .containsExactly("이전 검색 본문"); + assertThat(eventCount(context.jobId(), "EMBEDDING_FAILED")).isOne(); + assertThat(eventCount(context.jobId(), "FAILED")).isOne(); + } + + @Test + @DisplayName("동일 Attempt의 완료와 실패 동시 요청은 한쪽만 성공하고 일관된 상태로 수렴한다") + void completeAndFailConcurrently_onlyOneTransitionCommits() throws Exception { + ExecutionContext context = insertFirstVersionExecution("EMBEDDING", true); + CyclicBarrier startBarrier = new CyclicBarrier(CONCURRENT_REQUESTS); + ExecutorService executor = Executors.newFixedThreadPool(CONCURRENT_REQUESTS); + + List outcomes; + try { + List> futures = List.of( + executor.submit(() -> completeAfterBarrier(context, startBarrier)), + executor.submit(() -> failRaceAfterBarrier(context, startBarrier)) + ); + outcomes = List.of( + futures.get(0).get(TIMEOUT_SECONDS, TimeUnit.SECONDS), + futures.get(1).get(TIMEOUT_SECONDS, TimeUnit.SECONDS) + ); + } finally { + executor.shutdownNow(); + assertThat(executor.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + } + + assertThat(outcomes).filteredOn(RaceOutcome::committed).hasSize(1); + String jobStatus = queryString("SELECT status FROM embedding_jobs WHERE id = ?", context.jobId()); + String attemptStatus = queryString( + "SELECT status FROM embedding_job_attempts WHERE id = ?", + context.attemptId() + ); + if (jobStatus.equals("INDEXED")) { + assertThat(attemptStatus).isEqualTo("SUCCESS"); + assertThat(eventCount(context.jobId(), "INDEXED")).isOne(); + assertThat(eventCount(context.jobId(), "RETRY")).isZero(); + assertThat(eventCount(context.jobId(), "EMBEDDING_FAILED")).isZero(); + } else { + assertThat(jobStatus).isEqualTo("PENDING"); + assertThat(attemptStatus).isEqualTo("FAILED"); + assertThat(eventCount(context.jobId(), "INDEXED")).isZero(); + assertThat(eventCount(context.jobId(), "RETRY")).isOne(); + assertThat(eventCount(context.jobId(), "EMBEDDING_FAILED")).isOne(); + } + } + + @Test + @DisplayName("RETRY 이벤트 저장 실패 시 Attempt와 Job의 모든 실패 변경을 Rollback한다") + void fail_rollsBackAllChanges_whenRetryEventInsertFails() { + ExecutionContext context = insertFirstVersionExecution("PARSING", false); + installFailingRetryEventTrigger(); + + try { + assertThatThrownBy(() -> failureService.fail( + context.jobId(), + context.attemptId(), + failureRequest( + context.workerId(), + IndexingFailureType.STORAGE_UNAVAILABLE, + "Storage timeout" + ) + )).isInstanceOf(RuntimeException.class); + } finally { + removeFailingRetryEventTrigger(); + } + + assertThat(queryString("SELECT status FROM embedding_jobs WHERE id = ?", context.jobId())) + .isEqualTo("PROCESSING"); + assertThat(queryInteger( + "SELECT retry_count FROM embedding_jobs WHERE id = ?", + context.jobId() + )).isZero(); + assertThat(queryDateTime( + "SELECT next_retry_at FROM embedding_jobs WHERE id = ?", + context.jobId() + )).isNull(); + assertThat(queryString( + "SELECT status FROM embedding_job_attempts WHERE id = ?", + context.attemptId() + )).isEqualTo("STARTED"); + assertThat(queryString( + "SELECT status FROM document_versions WHERE id = ?", + context.targetVersionId() + )).isEqualTo("PARSING"); + assertThat(eventCount(context.jobId(), "PARSE_FAILED")).isZero(); + assertThat(eventCount(context.jobId(), "RETRY")).isZero(); + } + + private ExecutionContext insertFirstVersionExecution( + String versionStatus, + boolean withEmbedding + ) { + BaseContext base = insertBase("UPLOADED"); + Long versionId = insertVersion(base.documentId(), base.userId(), 1, versionStatus); + setCurrentVersion(base.documentId(), versionId); + if (withEmbedding) { + insertChunkAndEmbedding( + base.documentId(), + versionId, + base.embeddingModelId(), + "완료 경쟁 본문", + 1.0f + ); + } + JobContext job = insertProcessingJob(versionId, base.embeddingModelId(), base.workerId()); + return new ExecutionContext( + base.userId(), + base.workerId(), + base.documentId(), + null, + versionId, + base.embeddingModelId(), + job.jobId(), + job.attemptId() + ); + } + + private ExecutionContext insertReplacementVersionExecution() { + BaseContext base = insertBase("INDEXED"); + Long previousVersionId = insertVersion(base.documentId(), base.userId(), 1, "INDEXED"); + setCurrentVersion(base.documentId(), previousVersionId); + insertChunkAndEmbedding( + base.documentId(), + previousVersionId, + base.embeddingModelId(), + "이전 검색 본문", + 0.8f + ); + + Long targetVersionId = insertVersion(base.documentId(), base.userId(), 2, "EMBEDDING"); + insertChunkAndEmbedding( + base.documentId(), + targetVersionId, + base.embeddingModelId(), + "실패 대상 본문", + 1.0f + ); + JobContext job = insertProcessingJob(targetVersionId, base.embeddingModelId(), base.workerId()); + return new ExecutionContext( + base.userId(), + base.workerId(), + base.documentId(), + previousVersionId, + targetVersionId, + base.embeddingModelId(), + job.jobId(), + job.attemptId() + ); + } + + private BaseContext insertBase(String documentStatus) { + String suffix = UUID.randomUUID().toString(); + Long userId = jdbcTemplate.queryForObject(""" + INSERT INTO users (email, password_hash, name, status, created_at, updated_at) + VALUES (?, 'password-hash', 'Failure Test User', 'ACTIVE', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, "index-failure-" + suffix + "@example.com"); + Long workerId = jdbcTemplate.queryForObject(""" + INSERT INTO worker_nodes ( + worker_name, instance_id, status, last_heartbeat_at, started_at, + created_at, updated_at + ) + VALUES ('failure-worker', ?, 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, suffix); + Long documentId = jdbcTemplate.queryForObject(""" + INSERT INTO documents ( + owner_user_id, title, document_type, source_type, status, visibility, + created_at, updated_at + ) + VALUES (?, 'Failure Test Document', 'TXT', 'UPLOAD', ?, 'PRIVATE', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, userId, documentStatus); + Long embeddingModelId = jdbcTemplate.queryForObject(""" + SELECT id + FROM embedding_models + WHERE is_active = TRUE AND is_searchable = TRUE + """, Long.class); + return new BaseContext(userId, workerId, documentId, embeddingModelId); + } + + private Long insertVersion( + Long documentId, + Long userId, + int versionNo, + String status + ) { + return jdbcTemplate.queryForObject(""" + INSERT INTO document_versions ( + document_id, version_no, title_snapshot, content_type, status, + indexed_at, created_by, created_at, updated_at + ) + VALUES (?, ?, 'Failure Test Version', 'text/plain', ?, + CASE WHEN ? = 'INDEXED' THEN CURRENT_TIMESTAMP ELSE NULL END, + ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, documentId, versionNo, status, status, userId); + } + + private void setCurrentVersion(Long documentId, Long versionId) { + jdbcTemplate.update( + "UPDATE documents SET current_version_id = ? WHERE id = ?", + versionId, + documentId + ); + } + + private void insertChunkAndEmbedding( + Long documentId, + Long versionId, + Long embeddingModelId, + String chunkText, + float firstVectorValue + ) { + Long chunkId = jdbcTemplate.queryForObject(""" + INSERT INTO document_chunks ( + document_version_id, chunk_index, chunk_text, token_count, + char_start, char_end, content_hash, created_at, updated_at + ) + VALUES (?, 0, ?, 3, 0, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, versionId, chunkText, chunkText.length(), CONTENT_HASH); + jdbcTemplate.update(""" + INSERT INTO embeddings ( + chunk_id, document_id, document_version_id, embedding_model_id, + vector, dimension, vector_hash, status, created_at, updated_at + ) + VALUES (?, ?, ?, ?, CAST(? AS vector), ?, ?, 'ACTIVE', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + chunkId, + documentId, + versionId, + embeddingModelId, + vector(firstVectorValue), + VECTOR_DIMENSION, + CONTENT_HASH + ); + } + + private JobContext insertProcessingJob( + Long versionId, + Long embeddingModelId, + Long workerId + ) { + 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, created_at, updated_at + ) + VALUES (?, ?, 'PROCESSING', 0, 0, 3, ?, CURRENT_TIMESTAMP, + TIMESTAMP '2099-01-01 00:00:00', ?, + CURRENT_TIMESTAMP - INTERVAL '5 seconds', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, versionId, embeddingModelId, workerId, CLAIM_TOKEN); + Long attemptId = jdbcTemplate.queryForObject(""" + INSERT INTO embedding_job_attempts ( + embedding_job_id, worker_node_id, attempt_no, claim_token, status, + started_at, created_at, updated_at + ) + VALUES (?, ?, 1, ?, 'STARTED', CURRENT_TIMESTAMP - INTERVAL '5 seconds', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, jobId, workerId, CLAIM_TOKEN); + return new JobContext(jobId, attemptId); + } + + private FailDocumentIndexingRequest failureRequest( + Long workerId, + IndexingFailureType failureType, + String errorMessage + ) { + return new FailDocumentIndexingRequest( + workerId, + CLAIM_TOKEN, + failureType, + errorMessage + ); + } + + private Long findPendingJobAt(LocalDateTime claimedAt) { + return transactionTemplate.execute(status -> embeddingJobRepository + .findNextPendingForUpdate(claimedAt) + .map(EmbeddingJob::getId) + .orElse(null)); + } + + private List search(ExecutionContext context) { + float[] queryVector = new float[VECTOR_DIMENSION]; + queryVector[0] = 1.0f; + return vectorSearchQueryService.search( + queryVector, + context.embeddingModelId(), + List.of(context.documentId()), + 5 + ); + } + + private DocumentIndexingFailureResponse failAfterBarrier( + ExecutionContext context, + FailDocumentIndexingRequest request, + CyclicBarrier startBarrier + ) throws Exception { + startBarrier.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + return failureService.fail(context.jobId(), context.attemptId(), request); + } + + private RaceOutcome completeAfterBarrier( + ExecutionContext context, + CyclicBarrier startBarrier + ) throws Exception { + startBarrier.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + try { + completionService.complete( + context.jobId(), + context.attemptId(), + new CompleteDocumentIndexingRequest(context.workerId(), CLAIM_TOKEN) + ); + return new RaceOutcome(true, "INDEXED"); + } catch (DocGridException exception) { + return new RaceOutcome(false, exception.getErrorCode().name()); + } + } + + private RaceOutcome failRaceAfterBarrier( + ExecutionContext context, + CyclicBarrier startBarrier + ) throws Exception { + startBarrier.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + try { + failureService.fail( + context.jobId(), + context.attemptId(), + failureRequest( + context.workerId(), + IndexingFailureType.EMBEDDING_PROVIDER_UNAVAILABLE, + "Provider timeout" + ) + ); + return new RaceOutcome(true, "PENDING"); + } catch (DocGridException exception) { + return new RaceOutcome(false, exception.getErrorCode().name()); + } + } + + private void installFailingRetryEventTrigger() { + jdbcTemplate.execute(""" + CREATE OR REPLACE FUNCTION fail_retry_event_insert() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF NEW.event_type = 'RETRY' THEN + RAISE EXCEPTION 'forced retry event failure'; + END IF; + RETURN NEW; + END; + $$ + """); + jdbcTemplate.execute(""" + CREATE TRIGGER trg_fail_retry_event_insert + BEFORE INSERT ON indexing_events + FOR EACH ROW + EXECUTE FUNCTION fail_retry_event_insert() + """); + } + + private void removeFailingRetryEventTrigger() { + jdbcTemplate.execute(""" + DROP TRIGGER IF EXISTS trg_fail_retry_event_insert ON indexing_events + """); + jdbcTemplate.execute("DROP FUNCTION IF EXISTS fail_retry_event_insert()"); + } + + private String vector(float firstValue) { + return "[" + firstValue + "," + "0,".repeat(VECTOR_DIMENSION - 2) + "0]"; + } + + private String queryString(String sql, Long id) { + return jdbcTemplate.queryForObject(sql, String.class, id); + } + + private Long queryLong(String sql, Long id) { + return jdbcTemplate.queryForObject(sql, Long.class, id); + } + + private Integer queryInteger(String sql, Long id) { + return jdbcTemplate.queryForObject(sql, Integer.class, id); + } + + private LocalDateTime queryDateTime(String sql, Long id) { + return jdbcTemplate.queryForObject(sql, LocalDateTime.class, id); + } + + private int eventCount(Long jobId, String eventType) { + return jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM indexing_events + WHERE embedding_job_id = ? AND event_type = ? + """, Integer.class, jobId, eventType); + } + + /** + * 공통 사용자·Worker·Document와 검색 Model 식별자를 묶는다. + */ + private record BaseContext( + Long userId, + Long workerId, + Long documentId, + Long embeddingModelId + ) { + } + + /** + * 실패 대상 Job과 Attempt 식별자를 묶는다. + */ + private record JobContext(Long jobId, Long attemptId) { + } + + /** + * 실패 호출과 검색 전후 검증에 필요한 실행·문서·Version 식별자를 묶는다. + */ + private record ExecutionContext( + Long userId, + Long workerId, + Long documentId, + Long previousVersionId, + Long targetVersionId, + Long embeddingModelId, + Long jobId, + Long attemptId + ) { + } + + /** + * 완료와 실패 경쟁 요청 하나가 커밋됐는지 또는 어떤 오류로 거부됐는지 전달한다. + */ + private record RaceOutcome(boolean committed, String result) { + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/integration/EmbeddingJobClaimIntegrationTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/integration/EmbeddingJobClaimIntegrationTest.java index 761cb56..81e8e88 100644 --- a/src/test/java/com/opensource/docgrid/domain/embedding/integration/EmbeddingJobClaimIntegrationTest.java +++ b/src/test/java/com/opensource/docgrid/domain/embedding/integration/EmbeddingJobClaimIntegrationTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -54,6 +55,7 @@ class EmbeddingJobClaimIntegrationTest { private static final String TEST_SCHEMA = "docgrid_embedding_job_claim_test"; private static final long TIMEOUT_SECONDS = 10; + private static final LocalDateTime CLAIMED_AT = LocalDateTime.of(2026, 8, 3, 10, 0); @Autowired private JdbcTemplate jdbcTemplate; @@ -109,7 +111,7 @@ void findNextPendingForUpdate_ordersClaimCandidate() { insertJob(documentVersionId, "PROCESSING", 100, "2026-07-22 08:00:00"); Long selectedJobId = inNewTransaction(() -> - embeddingJobRepository.findNextPendingForUpdate().orElseThrow().getId() + embeddingJobRepository.findNextPendingForUpdate(CLAIMED_AT).orElseThrow().getId() ); assertThat(selectedJobId).isEqualTo(oldHighPriorityJobId); @@ -127,7 +129,7 @@ void findNextPendingForUpdate_skipsLockedRow() throws Exception { // 1. 첫 번째 Transaction이 최우선 Job의 행 잠금을 잡은 채 Commit을 지연한다. Future lockHolder = executorService.submit(() -> inNewTransaction(() -> { - Long selectedId = embeddingJobRepository.findNextPendingForUpdate().orElseThrow().getId(); + Long selectedId = embeddingJobRepository.findNextPendingForUpdate(CLAIMED_AT).orElseThrow().getId(); rowLocked.countDown(); awaitLatch(releaseLock); return selectedId; @@ -138,7 +140,7 @@ void findNextPendingForUpdate_skipsLockedRow() throws Exception { // 3. 두 번째 Transaction은 잠금 해제를 기다리지 않고 다음 PENDING Job을 선택해야 한다. Future skipLockedReader = executorService.submit(() -> inNewTransaction(() -> - embeddingJobRepository.findNextPendingForUpdate().orElseThrow().getId() + embeddingJobRepository.findNextPendingForUpdate(CLAIMED_AT).orElseThrow().getId() )); try { @@ -151,6 +153,31 @@ void findNextPendingForUpdate_skipsLockedRow() throws Exception { assertThat(lockHolder.get(TIMEOUT_SECONDS, TimeUnit.SECONDS)).isEqualTo(firstJobId); } + @Test + @DisplayName("Retry 예약 시각 이전에는 제외하고 정확히 예약 시각부터 Claim 후보가 된다") + void findNextPendingForUpdate_respectsNextRetryAtBoundary() { + Long documentVersionId = insertDocumentVersion(); + Long immediateJobId = insertJob(documentVersionId, "PENDING", 1, "2026-08-03 09:00:00"); + Long retryJobId = insertJob(documentVersionId, "PENDING", 100, "2026-08-03 08:00:00"); + jdbcTemplate.update( + "UPDATE embedding_jobs SET next_retry_at = CAST(? AS TIMESTAMP) WHERE id = ?", + "2026-08-03 10:00:00", + retryJobId + ); + + Long beforeRetry = inNewTransaction(() -> embeddingJobRepository + .findNextPendingForUpdate(CLAIMED_AT.minusNanos(1_000)) + .orElseThrow() + .getId()); + Long atRetry = inNewTransaction(() -> embeddingJobRepository + .findNextPendingForUpdate(CLAIMED_AT) + .orElseThrow() + .getId()); + + assertThat(beforeRetry).isEqualTo(immediateJobId); + assertThat(atRetry).isEqualTo(retryJobId); + } + @Test @DisplayName("두 Worker가 동시에 하나의 Job을 Claim해도 한 Worker만 소유권을 얻는다") void claim_allowsExactlyOneConcurrentOwner() throws Exception { @@ -222,6 +249,28 @@ void migration_createsClaimTokenColumn() { assertThat(length).isEqualTo(36); } + @Test + @DisplayName("V35 Migration이 next_retry_at 컬럼과 조회 인덱스를 생성한다") + void migration_createsNextRetryAtColumnAndIndex() { + String dataType = jdbcTemplate.queryForObject(""" + SELECT data_type + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = 'embedding_jobs' + AND column_name = 'next_retry_at' + """, String.class); + Integer indexCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM pg_indexes + WHERE schemaname = current_schema() + AND tablename = 'embedding_jobs' + AND indexname = 'idx_embedding_jobs_status_next_retry_at' + """, Integer.class); + + assertThat(dataType).isEqualTo("timestamp without time zone"); + assertThat(indexCount).isEqualTo(1); + } + private Optional claimAfterBarrier(Long workerId, CyclicBarrier barrier) { awaitBarrier(barrier); return embeddingJobClaimService.claim(workerId); diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingFailureServiceTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingFailureServiceTest.java new file mode 100644 index 0000000..5112413 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingFailureServiceTest.java @@ -0,0 +1,379 @@ +package com.opensource.docgrid.domain.embedding.service.command; + +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 static org.mockito.Mockito.times; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Optional; + +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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import com.opensource.docgrid.domain.document.entity.Document; +import com.opensource.docgrid.domain.document.entity.DocumentVersion; +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.document.repository.DocumentRepository; +import com.opensource.docgrid.domain.document.repository.DocumentVersionRepository; +import com.opensource.docgrid.domain.embedding.converter.EmbeddingJobAttemptConverter; +import com.opensource.docgrid.domain.embedding.dto.request.FailDocumentIndexingRequest; +import com.opensource.docgrid.domain.embedding.dto.response.DocumentIndexingFailureResponse; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingJob; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.embedding.enums.IndexingFailureType; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingJobRepository; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingRepository; +import com.opensource.docgrid.domain.worker.config.IndexingWorkerProperties; +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; +import com.opensource.docgrid.domain.worker.enums.WorkerStatus; +import com.opensource.docgrid.domain.worker.repository.EmbeddingJobAttemptRepository; +import com.opensource.docgrid.domain.worker.repository.IndexingEventRepository; +import com.opensource.docgrid.global.exception.DocGridException; +import com.opensource.docgrid.global.exception.ErrorCode; + +/** + * 인덱싱 실패 Service의 Retry·최종 종료·멱등 재생 분기와 원자 상태 변경을 검증한다. + * + *

외부 I/O 없이 Job → Version → Document 잠금 조회 뒤 Attempt, Queue, 검색 가용성과 이벤트가 + * 설계된 정책대로 함께 바뀌는지 확인한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("DocumentIndexingFailureService 테스트") +class DocumentIndexingFailureServiceTest { + + private static final Long JOB_ID = 41L; + private static final Long ATTEMPT_ID = 103L; + private static final Long DOCUMENT_ID = 10L; + private static final Long VERSION_ID = 22L; + private static final Long WORKER_ID = 7L; + private static final String CLAIM_TOKEN = "34c19d16-6ae1-4f6a-a35d-0123456789ab"; + private static final LocalDateTime FAILED_AT = LocalDateTime.of(2026, 8, 3, 10, 30); + + @Mock private EmbeddingJobRepository embeddingJobRepository; + @Mock private EmbeddingJobAttemptRepository embeddingJobAttemptRepository; + @Mock private DocumentVersionRepository documentVersionRepository; + @Mock private DocumentRepository documentRepository; + @Mock private EmbeddingRepository embeddingRepository; + @Mock private IndexingEventRepository indexingEventRepository; + @Mock private EmbeddingJobOwnershipValidator ownershipValidator; + + private DocumentIndexingFailureService service; + private IndexingWorkerProperties workerProperties; + private Document document; + private DocumentVersion documentVersion; + private WorkerNode workerNode; + private EmbeddingJob embeddingJob; + private EmbeddingJobAttempt attempt; + + @BeforeEach + void setUp() { + workerProperties = new IndexingWorkerProperties(); + Clock clock = Clock.fixed( + Instant.parse("2026-08-03T01:30:00Z"), + ZoneId.of("Asia/Seoul") + ); + service = new DocumentIndexingFailureService( + embeddingJobRepository, + embeddingJobAttemptRepository, + documentVersionRepository, + documentRepository, + embeddingRepository, + indexingEventRepository, + ownershipValidator, + new EmbeddingJobAttemptConverter(), + workerProperties, + clock + ); + prepareExecution(DocumentVersionStatus.PARSING, DocumentStatus.INDEXING); + } + + @Test + @DisplayName("일시적 파싱 실패는 Version을 보존하고 10초 뒤 Retry를 예약한다") + void fail_schedulesInitialRetryAndRecordsEvents() { + givenLockedExecution(); + + DocumentIndexingFailureResponse response = service.fail( + JOB_ID, + ATTEMPT_ID, + request(IndexingFailureType.STORAGE_UNAVAILABLE, "Storage timeout") + ); + + assertThat(response.attemptStatus()).isEqualTo(AttemptStatus.FAILED); + assertThat(response.failureType()).isEqualTo(IndexingFailureType.STORAGE_UNAVAILABLE); + assertThat(response.failedAt()).isEqualTo(FAILED_AT); + assertThat(response.durationMs()).isEqualTo(8_000L); + assertThat(embeddingJob.getStatus()).isEqualTo(EmbeddingJobStatus.PENDING); + assertThat(embeddingJob.getRetryCount()).isEqualTo(1); + assertThat(embeddingJob.getNextRetryAt()).isEqualTo(FAILED_AT.plusSeconds(10)); + assertThat(embeddingJob.getClaimToken()).isNull(); + assertThat(documentVersion.getStatus()).isEqualTo(DocumentVersionStatus.PARSING); + assertThat(document.getStatus()).isEqualTo(DocumentStatus.INDEXING); + then(embeddingRepository).shouldHaveNoInteractions(); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(IndexingEvent.class); + then(indexingEventRepository).should(times(2)).save(eventCaptor.capture()); + assertThat(eventCaptor.getAllValues()) + .extracting(IndexingEvent::getEventType) + .containsExactly(IndexingEventType.PARSE_FAILED, IndexingEventType.RETRY); + assertThat(eventCaptor.getAllValues().get(0).getMetadataJson()) + .isEqualTo("{\"attemptId\":103,\"attemptNo\":1,\"failureType\":\"STORAGE_UNAVAILABLE\"}"); + assertThat(eventCaptor.getAllValues().get(1).getMetadataJson()) + .isEqualTo("{\"retryCount\":1,\"nextRetryAt\":\"2026-08-03T10:30:10\"}"); + } + + @Test + @DisplayName("누적 Retry 횟수가 커도 Backoff는 설정한 최대 지연을 넘지 않는다") + void fail_capsRetryBackoffAtMaximumDelay() { + workerProperties.setRetryMaxDelay(Duration.ofSeconds(40)); + ReflectionTestUtils.setField(embeddingJob, "retryCount", 4); + ReflectionTestUtils.setField(embeddingJob, "maxRetryCount", 6); + givenLockedExecution(); + + service.fail( + JOB_ID, + ATTEMPT_ID, + request(IndexingFailureType.WORKER_INTERNAL_ERROR, "temporary worker failure") + ); + + assertThat(embeddingJob.getRetryCount()).isEqualTo(5); + assertThat(embeddingJob.getNextRetryAt()).isEqualTo(FAILED_AT.plusSeconds(40)); + } + + @ParameterizedTest + @CsvSource({"1, 20", "2, 40"}) + @DisplayName("현재 Retry 횟수에 따라 20초와 40초 지수 Backoff를 적용한다") + void fail_doublesRetryDelay(int retryCount, long expectedDelaySeconds) { + ReflectionTestUtils.setField(embeddingJob, "retryCount", retryCount); + givenLockedExecution(); + + service.fail( + JOB_ID, + ATTEMPT_ID, + request(IndexingFailureType.EMBEDDING_PROVIDER_UNAVAILABLE, "Provider timeout") + ); + + assertThat(embeddingJob.getNextRetryAt()) + .isEqualTo(FAILED_AT.plusSeconds(expectedDelaySeconds)); + } + + @Test + @DisplayName("영구 임베딩 실패는 최초 Version과 Document 및 Job을 최종 실패로 종료한다") + void fail_terminatesFirstVersionForPermanentFailure() { + prepareExecution(DocumentVersionStatus.EMBEDDING, DocumentStatus.INDEXING); + givenLockedExecution(); + given(embeddingRepository.markActiveAsStaleByDocumentVersionId(VERSION_ID)).willReturn(2); + + service.fail( + JOB_ID, + ATTEMPT_ID, + request(IndexingFailureType.EMBEDDING_RESULT_INVALID, "Vector dimension mismatch") + ); + + assertThat(attempt.getStatus()).isEqualTo(AttemptStatus.FAILED); + assertThat(embeddingJob.getStatus()).isEqualTo(EmbeddingJobStatus.FAILED); + assertThat(embeddingJob.getFailedAt()).isEqualTo(FAILED_AT); + assertThat(documentVersion.getStatus()).isEqualTo(DocumentVersionStatus.FAILED); + assertThat(document.getStatus()).isEqualTo(DocumentStatus.FAILED); + then(embeddingRepository).should().markActiveAsStaleByDocumentVersionId(VERSION_ID); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(IndexingEvent.class); + then(indexingEventRepository).should(times(2)).save(eventCaptor.capture()); + assertThat(eventCaptor.getAllValues()) + .extracting(IndexingEvent::getEventType) + .containsExactly(IndexingEventType.EMBEDDING_FAILED, IndexingEventType.FAILED); + assertThat(eventCaptor.getAllValues().get(1).getMetadataJson()) + .isEqualTo("{\"retryCount\":0,\"maxRetryCount\":3}"); + } + + @Test + @DisplayName("재시도 가능 실패도 Retry 횟수를 소진하면 최종 실패로 종료한다") + void fail_terminatesRetryableFailureWhenRetriesAreExhausted() { + ReflectionTestUtils.setField(embeddingJob, "retryCount", 3); + givenLockedExecution(); + + service.fail( + JOB_ID, + ATTEMPT_ID, + request(IndexingFailureType.WORKER_INTERNAL_ERROR, "temporary worker failure") + ); + + assertThat(embeddingJob.getStatus()).isEqualTo(EmbeddingJobStatus.FAILED); + assertThat(embeddingJob.getRetryCount()).isEqualTo(3); + assertThat(documentVersion.getStatus()).isEqualTo(DocumentVersionStatus.FAILED); + assertThat(document.getStatus()).isEqualTo(DocumentStatus.FAILED); + } + + @Test + @DisplayName("새 Version 최종 실패 시 이전 INDEXED Version과 Document 검색 상태를 보존한다") + void fail_preservesPreviousIndexedVersion() { + prepareExecution(DocumentVersionStatus.EMBEDDING, DocumentStatus.INDEXED); + DocumentVersion previousVersion = DocumentVersion.builder() + .document(document) + .versionNo(1) + .status(DocumentVersionStatus.INDEXED) + .build(); + ReflectionTestUtils.setField(previousVersion, "id", 21L); + document.updateCurrentVersion(previousVersion); + givenLockedExecution(); + + service.fail( + JOB_ID, + ATTEMPT_ID, + request(IndexingFailureType.DOCUMENT_CONTENT_INVALID, "Invalid document content") + ); + + assertThat(documentVersion.getStatus()).isEqualTo(DocumentVersionStatus.FAILED); + assertThat(document.getStatus()).isEqualTo(DocumentStatus.INDEXED); + assertThat(document.getCurrentVersion()).isSameAs(previousVersion); + then(embeddingRepository).should().markActiveAsStaleByDocumentVersionId(VERSION_ID); + } + + @Test + @DisplayName("같은 실패 요청은 Job과 Lease가 바뀌어도 저장된 Attempt 결과만 재생한다") + void fail_replaysStoredFailureWithoutStateChanges() { + givenLockedExecution(); + FailDocumentIndexingRequest request = + request(IndexingFailureType.STORAGE_UNAVAILABLE, "Storage timeout"); + service.fail(JOB_ID, ATTEMPT_ID, request); + LocalDateTime firstNextRetryAt = embeddingJob.getNextRetryAt(); + + DocumentIndexingFailureResponse replayed = service.fail(JOB_ID, ATTEMPT_ID, request); + + assertThat(replayed.failedAt()).isEqualTo(FAILED_AT); + assertThat(replayed.durationMs()).isEqualTo(8_000L); + assertThat(embeddingJob.getRetryCount()).isEqualTo(1); + assertThat(embeddingJob.getNextRetryAt()).isEqualTo(firstNextRetryAt); + then(ownershipValidator).should(times(1)) + .validate(embeddingJob, WORKER_ID, CLAIM_TOKEN, FAILED_AT); + then(documentVersionRepository).should(times(1)).findByIdForUpdate(VERSION_ID); + then(indexingEventRepository).should(times(2)).save(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("이미 실패한 Attempt의 다른 실패 내용은 충돌로 거부한다") + void fail_rejectsDifferentFailureReplay() { + givenLockedExecution(); + service.fail( + JOB_ID, + ATTEMPT_ID, + request(IndexingFailureType.STORAGE_UNAVAILABLE, "Storage timeout") + ); + + assertThatThrownBy(() -> service.fail( + JOB_ID, + ATTEMPT_ID, + request(IndexingFailureType.STORAGE_UNAVAILABLE, "different failure") + )).isInstanceOfSatisfying(DocGridException.class, + exception -> assertThat(exception.getErrorCode()) + .isEqualTo(ErrorCode.EMBEDDING_JOB_FAILURE_CONFLICT)); + } + + @Test + @DisplayName("STARTED Attempt의 Job이 PROCESSING이 아니면 소유권 검증 전에 거부한다") + void fail_rejectsUnexpectedJobStatus() { + ReflectionTestUtils.setField(embeddingJob, "status", EmbeddingJobStatus.PENDING); + given(embeddingJobRepository.findByIdForUpdate(JOB_ID)).willReturn(Optional.of(embeddingJob)); + given(embeddingJobAttemptRepository.findByEmbeddingJobIdAndClaimToken(JOB_ID, CLAIM_TOKEN)) + .willReturn(Optional.of(attempt)); + + assertThatThrownBy(() -> service.fail( + JOB_ID, + ATTEMPT_ID, + request(IndexingFailureType.WORKER_INTERNAL_ERROR, "temporary failure") + )).isInstanceOfSatisfying(DocGridException.class, + exception -> assertThat(exception.getErrorCode()) + .isEqualTo(ErrorCode.EMBEDDING_JOB_NOT_PROCESSING)); + + then(ownershipValidator).shouldHaveNoInteractions(); + then(documentVersionRepository).shouldHaveNoInteractions(); + } + + private void prepareExecution( + DocumentVersionStatus versionStatus, + DocumentStatus documentStatus + ) { + document = Document.builder() + .title("Failure service document") + .status(documentStatus) + .build(); + ReflectionTestUtils.setField(document, "id", DOCUMENT_ID); + documentVersion = DocumentVersion.builder() + .document(document) + .versionNo(1) + .status(versionStatus) + .build(); + ReflectionTestUtils.setField(documentVersion, "id", VERSION_ID); + document.updateCurrentVersion(documentVersion); + + workerNode = WorkerNode.builder() + .workerName("failure-worker") + .instanceId("failure-worker-instance") + .status(WorkerStatus.ACTIVE) + .lastHeartbeatAt(FAILED_AT.minusSeconds(1)) + .startedAt(FAILED_AT.minusMinutes(1)) + .build(); + ReflectionTestUtils.setField(workerNode, "id", WORKER_ID); + + embeddingJob = EmbeddingJob.builder() + .documentVersion(documentVersion) + .status(EmbeddingJobStatus.PENDING) + .priority(0) + .maxRetryCount(3) + .build(); + ReflectionTestUtils.setField(embeddingJob, "id", JOB_ID); + embeddingJob.claim( + workerNode, + CLAIM_TOKEN, + FAILED_AT.minusSeconds(10), + FAILED_AT.plusMinutes(5) + ); + attempt = EmbeddingJobAttempt.builder() + .embeddingJob(embeddingJob) + .workerNode(workerNode) + .attemptNo(1) + .claimToken(CLAIM_TOKEN) + .startedAt(FAILED_AT.minusSeconds(8)) + .build(); + ReflectionTestUtils.setField(attempt, "id", ATTEMPT_ID); + } + + private void givenLockedExecution() { + given(embeddingJobRepository.findByIdForUpdate(JOB_ID)).willReturn(Optional.of(embeddingJob)); + given(embeddingJobAttemptRepository.findByEmbeddingJobIdAndClaimToken(JOB_ID, CLAIM_TOKEN)) + .willReturn(Optional.of(attempt)); + given(documentVersionRepository.findByIdForUpdate(VERSION_ID)) + .willReturn(Optional.of(documentVersion)); + given(documentRepository.findByIdForUpdate(DOCUMENT_ID)).willReturn(Optional.of(document)); + } + + private FailDocumentIndexingRequest request( + IndexingFailureType failureType, + String errorMessage + ) { + return new FailDocumentIndexingRequest( + WORKER_ID, + CLAIM_TOKEN, + failureType, + errorMessage + ); + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobClaimServiceTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobClaimServiceTest.java index 7398bba..afab256 100644 --- a/src/test/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobClaimServiceTest.java +++ b/src/test/java/com/opensource/docgrid/domain/embedding/service/command/EmbeddingJobClaimServiceTest.java @@ -94,7 +94,7 @@ void claim_claimsPendingJob_when_workerIsActive() { NOW.plusMinutes(5) ); given(workerNodeRepository.findById(WorkerNodeFixture.WORKER_ID)).willReturn(Optional.of(workerNode)); - given(embeddingJobRepository.findNextPendingForUpdate()).willReturn(Optional.of(embeddingJob)); + given(embeddingJobRepository.findNextPendingForUpdate(NOW)).willReturn(Optional.of(embeddingJob)); given(embeddingJobConverter.toClaimedResponse(embeddingJob)).willReturn(expected); Optional result = embeddingJobClaimService.claim(WorkerNodeFixture.WORKER_ID); @@ -128,7 +128,7 @@ void claim_claimsPendingJob_when_workerIsIdle() { 5L, 2L, "token", NOW, NOW.plusMinutes(5) ); given(workerNodeRepository.findById(WorkerNodeFixture.WORKER_ID)).willReturn(Optional.of(workerNode)); - given(embeddingJobRepository.findNextPendingForUpdate()).willReturn(Optional.of(embeddingJob)); + given(embeddingJobRepository.findNextPendingForUpdate(NOW)).willReturn(Optional.of(embeddingJob)); given(embeddingJobConverter.toClaimedResponse(embeddingJob)).willReturn(expected); assertThat(embeddingJobClaimService.claim(WorkerNodeFixture.WORKER_ID)).contains(expected); @@ -142,7 +142,7 @@ void claim_throws_when_workerDoesNotExist() { assertThatThrownBy(() -> embeddingJobClaimService.claim(WorkerNodeFixture.WORKER_ID)) .isInstanceOf(DocGridException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.WORKER_NOT_FOUND); - then(embeddingJobRepository).should(never()).findNextPendingForUpdate(); + then(embeddingJobRepository).should(never()).findNextPendingForUpdate(any()); } @Test @@ -154,7 +154,7 @@ void claim_throws_when_workerIsStopped() { assertThatThrownBy(() -> embeddingJobClaimService.claim(WorkerNodeFixture.WORKER_ID)) .isInstanceOf(DocGridException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.WORKER_NOT_AVAILABLE); - then(embeddingJobRepository).should(never()).findNextPendingForUpdate(); + then(embeddingJobRepository).should(never()).findNextPendingForUpdate(any()); } @Test @@ -166,7 +166,7 @@ void claim_throws_when_workerHeartbeatIsExpired() { assertThatThrownBy(() -> embeddingJobClaimService.claim(WorkerNodeFixture.WORKER_ID)) .isInstanceOf(DocGridException.class) .hasFieldOrPropertyWithValue("errorCode", ErrorCode.WORKER_NOT_AVAILABLE); - then(embeddingJobRepository).should(never()).findNextPendingForUpdate(); + then(embeddingJobRepository).should(never()).findNextPendingForUpdate(any()); } @Test @@ -174,7 +174,7 @@ void claim_throws_when_workerHeartbeatIsExpired() { void claim_returnsEmpty_when_pendingJobDoesNotExist() { WorkerNode workerNode = createWorker(WorkerStatus.ACTIVE, NOW); given(workerNodeRepository.findById(WorkerNodeFixture.WORKER_ID)).willReturn(Optional.of(workerNode)); - given(embeddingJobRepository.findNextPendingForUpdate()).willReturn(Optional.empty()); + given(embeddingJobRepository.findNextPendingForUpdate(NOW)).willReturn(Optional.empty()); assertThat(embeddingJobClaimService.claim(WorkerNodeFixture.WORKER_ID)).isEmpty(); then(indexingEventRepository).should(never()).save(any()); diff --git a/src/test/java/com/opensource/docgrid/domain/worker/config/IndexingWorkerPropertiesTest.java b/src/test/java/com/opensource/docgrid/domain/worker/config/IndexingWorkerPropertiesTest.java index 8c4638c..99640ac 100644 --- a/src/test/java/com/opensource/docgrid/domain/worker/config/IndexingWorkerPropertiesTest.java +++ b/src/test/java/com/opensource/docgrid/domain/worker/config/IndexingWorkerPropertiesTest.java @@ -8,7 +8,7 @@ import org.junit.jupiter.api.Test; /** - * Worker Lease 설정의 기본값과 애플리케이션 시작 단계 유효성 검사를 검증하는 단위 테스트. + * Worker Lease와 Retry 지연 설정의 기본값 및 애플리케이션 시작 단계 유효성 검사를 검증하는 단위 테스트. * *

정상적인 양수 기간은 허용하고 발급 즉시 만료되는 0 또는 음수 기간은 차단하는지 확인한다. */ @@ -35,4 +35,33 @@ void leaseDuration_isInvalid_when_notPositive() { properties.setLeaseDuration(Duration.ofSeconds(-1)); assertThat(properties.isLeaseDurationValid()).isFalse(); } + + @Test + @DisplayName("기본 Retry 지연은 10초부터 5분까지이며 유효하다") + void defaultRetryDelay_isValid() { + IndexingWorkerProperties properties = new IndexingWorkerProperties(); + + assertThat(properties.getRetryInitialDelay()).isEqualTo(Duration.ofSeconds(10)); + assertThat(properties.getRetryMaxDelay()).isEqualTo(Duration.ofMinutes(5)); + assertThat(properties.isRetryDelayValid()).isTrue(); + } + + @Test + @DisplayName("Retry 지연이 0 이하이거나 최대 지연이 더 짧으면 유효하지 않다") + void retryDelay_isInvalid_when_nonPositiveOrReversed() { + IndexingWorkerProperties properties = new IndexingWorkerProperties(); + + properties.setRetryInitialDelay(Duration.ZERO); + assertThat(properties.isRetryDelayValid()).isFalse(); + + properties.setRetryInitialDelay(Duration.ofSeconds(-1)); + assertThat(properties.isRetryDelayValid()).isFalse(); + + properties.setRetryInitialDelay(Duration.ofSeconds(10)); + properties.setRetryMaxDelay(Duration.ofSeconds(9)); + assertThat(properties.isRetryDelayValid()).isFalse(); + + properties.setRetryMaxDelay(Duration.ofSeconds(10)); + assertThat(properties.isRetryDelayValid()).isTrue(); + } } diff --git a/src/test/java/com/opensource/docgrid/domain/worker/entity/EmbeddingJobAttemptTest.java b/src/test/java/com/opensource/docgrid/domain/worker/entity/EmbeddingJobAttemptTest.java index 5e40c59..8f674d9 100644 --- a/src/test/java/com/opensource/docgrid/domain/worker/entity/EmbeddingJobAttemptTest.java +++ b/src/test/java/com/opensource/docgrid/domain/worker/entity/EmbeddingJobAttemptTest.java @@ -93,6 +93,26 @@ void markFailed_recordsFailure() { assertThat(attempt.getErrorMessage()).isEqualTo("문서 파싱 실패"); } + @Test + @DisplayName("종결된 Attempt는 다른 실패 내용으로 다시 실패 처리할 수 없다") + void markFailed_rejectsCompletedAttempt() { + EmbeddingJobAttempt attempt = createStartedAttempt(); + LocalDateTime firstEndedAt = STARTED_AT.plusSeconds(2); + attempt.markFailed(firstEndedAt, 2_000L, "STORAGE_UNAVAILABLE", "Storage timeout"); + + assertThatThrownBy(() -> attempt.markFailed( + STARTED_AT.plusSeconds(5), + 5_000L, + "WORKER_INTERNAL_ERROR", + "different failure" + )).isInstanceOf(IllegalStateException.class) + .hasMessage("STARTED 상태의 Attempt만 FAILED로 전환할 수 있습니다."); + assertThat(attempt.getEndedAt()).isEqualTo(firstEndedAt); + assertThat(attempt.getDurationMs()).isEqualTo(2_000L); + assertThat(attempt.getErrorCode()).isEqualTo("STORAGE_UNAVAILABLE"); + assertThat(attempt.getErrorMessage()).isEqualTo("Storage timeout"); + } + private EmbeddingJobAttempt createStartedAttempt() { return EmbeddingJobAttempt.builder() .embeddingJob(createJob())