|
| 1 | +# Issue #108 최종 실패 인덱싱 Job 수동 재처리 상세 설계 |
| 2 | + |
| 3 | +closes #108 |
| 4 | + |
| 5 | +## 1. 문서 목적 |
| 6 | + |
| 7 | +이 문서는 이슈 [#108](https://github.com/DocGrid/backend/issues/108)의 구현 기준을 정의한다. |
| 8 | + |
| 9 | +인덱싱 Job은 실패 유형이 재시도 가능하고 남은 횟수가 있을 때만 `PENDING` Queue로 재예약된다. 재시도 |
| 10 | +가능 횟수를 모두 소진했거나 재시도 불가 유형으로 종료된 Job은 `FAILED`로 종결되고, 대상 Document |
| 11 | +Version은 `FAILED`, 그 Version의 Embedding Set은 `STALE`이 되어 검색에서 제외된다. |
| 12 | + |
| 13 | +`FAILED` Job을 다시 처리할 경로는 현재 존재하지 않는다. Claim은 `PENDING`만, Lease 만료 복구는 |
| 14 | +`PROCESSING`만 후보로 삼기 때문에 어떤 자동 경로도 `FAILED` Job을 되살리지 않는다. 외부 Embedding |
| 15 | +서버 장애나 일시적인 Storage 장애처럼 원인이 이미 해소된 뒤에도 같은 문서를 다시 인덱싱하려면 새 |
| 16 | +Version을 업로드하는 방법밖에 없다. |
| 17 | + |
| 18 | +이 작업은 최종 실패로 종결된 Job만 관리자가 명시적으로 Queue에 되돌릴 수 있는 수동 재처리 경로를 |
| 19 | +추가한다. |
| 20 | + |
| 21 | +### 1.1 성공 기준 |
| 22 | + |
| 23 | +- 최종 `FAILED` Job만 수동 재처리 대상이 된다. |
| 24 | +- 처리 중이거나 자동 재시도가 예정된 Job은 명시적으로 거부한다. |
| 25 | +- 최신 처리 대상 Version이 아니면 거부한다. |
| 26 | +- 현재 검색 가능한 이전 Version과 `current_version` 포인터를 보존한다. |
| 27 | +- 이전 Worker, Claim Token, Lease 등 소유권 정보를 초기화한다. |
| 28 | +- Retry Count와 기존 Attempt 이력을 삭제하지 않는다. |
| 29 | +- 수동 재처리를 새 상태 전이와 감사 Event로 남긴다. |
| 30 | +- 기존 Chunk를 무조건 삭제하지 않는다. |
| 31 | +- 중복 요청과 동시 요청이 하나의 상태 전이로 수렴한다. |
| 32 | +- Lease 만료 복구 및 자동 재시도와 경합하지 않는다. |
| 33 | +- 관리자 권한을 요구하고 Claim Token 등 민감 정보를 응답에 노출하지 않는다. |
| 34 | + |
| 35 | +## 2. 범위 |
| 36 | + |
| 37 | +### 2.1 포함 |
| 38 | + |
| 39 | +- 최종 실패 Job 한 건을 다시 Queue에 넣는 관리자 API |
| 40 | +- Job, Document Version, Document의 재처리 상태 전이 |
| 41 | +- 재개 지점 결정과 대상 Version Embedding 정리 |
| 42 | +- 수동 재처리 감사 Event 기록 |
| 43 | + |
| 44 | +### 2.2 제외 |
| 45 | + |
| 46 | +- 재처리 대상 Job 목록·상세 조회 API (관리자 인덱싱 조회 작업에서 진행) |
| 47 | +- 자동 재시도 정책과 Backoff 계산 변경 |
| 48 | +- 여러 Job을 한 번에 재처리하는 Batch API |
| 49 | +- 재처리 예약, 스케줄링, 자동 트리거 |
| 50 | +- OCR 등 실패 원인 자체를 해결하는 파싱 기능 |
| 51 | + |
| 52 | +## 3. 현재 구조 분석 |
| 53 | + |
| 54 | +### 3.1 상태 모델 |
| 55 | + |
| 56 | +`EmbeddingJobStatus`는 `PENDING`, `PROCESSING`, `INDEXED`, `FAILED`, `CANCELED`로 구성된다. 별도의 |
| 57 | +재시도 예약 상태는 없고, 자동 재시도가 예정된 Job은 `PENDING` + 미래의 `next_retry_at`으로 표현된다. |
| 58 | +Claim Query는 `next_retry_at IS NULL OR next_retry_at <= :claimedAt` 조건을 사용하므로 예약 시각 전에는 |
| 59 | +후보가 되지 않는다. |
| 60 | + |
| 61 | +### 3.2 최종 실패 시점의 데이터 상태 |
| 62 | + |
| 63 | +`IndexingFailureTransitionService`의 최종 실패 경로는 하나의 Transaction에서 다음을 수행한다. |
| 64 | + |
| 65 | +1. 대상 Version의 `ACTIVE` Embedding을 모두 `STALE`로 전환 |
| 66 | +2. `document_versions.status`를 `FAILED`로 전환 |
| 67 | +3. 이전 `INDEXED` Version이 현재 검색 대상이면 Document를 그대로 두고, 아니면 `FAILED`로 전환 |
| 68 | +4. `embedding_jobs.status`를 `FAILED`로 전환하고 `failed_at`, 오류 Snapshot 기록 |
| 69 | +5. 단계 실패 Event와 `FAILED` Event를 같은 시각으로 append |
| 70 | + |
| 71 | +`markFailed`는 `locked_by_worker_id`, `claim_token`, `locked_at`, `lock_expires_at`을 감사 목적으로 |
| 72 | +남긴다. `document_chunks`는 삭제하지 않는다. |
| 73 | + |
| 74 | +### 3.3 재개 지점 계약 |
| 75 | + |
| 76 | +파이프라인 각 단계는 Version 상태로 재개 지점을 판단한다. |
| 77 | + |
| 78 | +| Version 상태 | 동작 | |
| 79 | +|---|---| |
| 80 | +| `UPLOADED`, `PARSING` | 원본을 다시 읽어 파싱하고 Chunk Set 저장 | |
| 81 | +| `CHUNKED` | 파싱을 생략하고 Embedding 생성 | |
| 82 | +| `EMBEDDING` | 저장된 Embedding 수에 따라 재생 또는 재작업 | |
| 83 | + |
| 84 | +Chunk Set 저장과 `CHUNKED` 전이는 같은 Transaction에서 일어나므로 Chunk가 존재하면 항상 완전한 |
| 85 | +Set이다. 반면 `CHUNKED` 상태에서 대상 Version·Model의 Embedding 행이 0이 아니면 |
| 86 | +`DOCUMENT_EMBEDDINGS_INCONSISTENT`로 차단된다. 따라서 최종 실패가 남긴 `STALE` Embedding을 정리하지 |
| 87 | +않으면 재처리 자체가 불가능하다. |
| 88 | + |
| 89 | +### 3.4 검색 보호 장치 |
| 90 | + |
| 91 | +Vector 검색 Query는 `e.status = 'ACTIVE' AND d.status = 'INDEXED' AND d.current_version_id = |
| 92 | +e.document_version_id` 조건을 사용한다. 실패한 Version의 Embedding은 `STALE`이므로 원래 검색에 노출될 |
| 93 | +수 없고, 이전 `INDEXED` Version은 `current_version_id`가 유지되는 한 계속 검색된다. |
| 94 | + |
| 95 | +## 4. 설계 |
| 96 | + |
| 97 | +### 4.1 상태 전이 계약 |
| 98 | + |
| 99 | +```text |
| 100 | +사전조건: embedding_jobs.status = FAILED |
| 101 | + document_versions = 해당 문서의 최신 Version, status = FAILED |
| 102 | + documents.deleted_at IS NULL |
| 103 | + documents.status ∈ {UPLOADED, INDEXING, INDEXED, FAILED} |
| 104 | + 같은 Version에 PENDING/PROCESSING Job 없음 |
| 105 | +
|
| 106 | +전이: Job: FAILED -> PENDING, next_retry_at = NULL |
| 107 | + locked_by_worker_id, claim_token, locked_at, lock_expires_at, failed_at = NULL |
| 108 | + retry_count, max_retry_count, error_code, error_message 보존 |
| 109 | + Version: FAILED -> CHUNKED (Chunk가 이미 있는 경우) |
| 110 | + -> UPLOADED (Chunk가 없는 경우) |
| 111 | + Document: 이전 INDEXED Version이 현재 검색 대상이면 변경 없음 |
| 112 | + 그 외에는 INDEXING |
| 113 | + Event: MANUAL_RETRY (FAILED -> PENDING) 1건 append |
| 114 | + Attempt: 변경 없음 |
| 115 | +``` |
| 116 | + |
| 117 | +`retry_count`를 유지하므로 수동 재처리는 추가 실행 1회만 부여한다. 이번 실행이 다시 실패하면 |
| 118 | +`hasRemainingRetries()`가 거짓이 되어 자동 재시도 없이 즉시 최종 실패로 종결되고, 필요하면 관리자가 |
| 119 | +다시 수동 재처리를 요청한다. 이 선택은 재시도 이력을 지우지 않으면서 무한 자동 재시도를 만들지 |
| 120 | +않기 위한 것이다. |
| 121 | + |
| 122 | +### 4.2 Chunk와 Embedding 처리 정책 |
| 123 | + |
| 124 | +- Chunk는 삭제하지 않는다. 존재하면 완전한 Set이므로 파싱을 생략하고 재사용한다. |
| 125 | +- 대상 Version의 Embedding 행만 삭제한다. 최종 실패 시점에 이미 `STALE`이라 검색에 노출되지 않으며, |
| 126 | + 남겨두면 Embedding 개수 불변식 검증에서 재처리가 차단된다. |
| 127 | +- 다른 Version의 Chunk와 Embedding은 조회하지도 변경하지도 않는다. |
| 128 | + |
| 129 | +실패한 실행이 남긴 Vector를 다시 `ACTIVE`로 되살리는 방식은 채택하지 않았다. 완료 검증 단계에서 |
| 130 | +실패한 경우 그 Vector Set이 실제로 불완전할 수 있고, 이를 판별하려면 완료 Transaction과 같은 수준의 |
| 131 | +검증을 재처리 경로에 중복 구현해야 하기 때문이다. |
| 132 | + |
| 133 | +### 4.3 Transaction 경계와 잠금 순서 |
| 134 | + |
| 135 | +`EmbeddingJobManualRetryService`는 단일 `@Transactional` 경계에서 외부 I/O 없이 동작한다. |
| 136 | + |
| 137 | +1. `findByIdForUpdate`로 Job 행을 잠근다. |
| 138 | +2. Job 상태가 `FAILED`인지 확인한다. |
| 139 | +3. Version, Document를 기존 경로와 같은 순서로 잠근다. |
| 140 | +4. 재처리 대상 조건을 모두 검증한다. |
| 141 | +5. 재개 지점을 정하고 대상 Version Embedding을 삭제한다. |
| 142 | +6. Job, Version, Document 상태를 바꾸고 `MANUAL_RETRY` Event를 append한다. |
| 143 | + |
| 144 | +Job 행 잠금이 Claim, 완료, 협력적 실패, Lease 복구와의 단일 직렬화 지점이다. Lease 복구는 |
| 145 | +`PROCESSING` + 만료 행만, Claim은 `PENDING` 행만 후보로 삼으므로 커밋 전에는 이 Transaction과 경합하지 |
| 146 | +않고, 커밋 후에는 정상 Claim 경로로 흡수된다. |
| 147 | + |
| 148 | +### 4.4 API 계약 |
| 149 | + |
| 150 | +```text |
| 151 | +POST /admin/indexing-jobs/{jobId}/retry |
| 152 | +Request Body 없음, ADMIN 권한 필요 |
| 153 | +
|
| 154 | +200 OK |
| 155 | +{ |
| 156 | + "success": true, |
| 157 | + "data": { |
| 158 | + "jobId": 10, |
| 159 | + "status": "PENDING", |
| 160 | + "documentId": 3, |
| 161 | + "documentVersionId": 5, |
| 162 | + "documentVersionStatus": "CHUNKED", |
| 163 | + "retryCount": 3, |
| 164 | + "maxRetryCount": 3, |
| 165 | + "requeuedAt": "2026-08-06T15:00:00" |
| 166 | + } |
| 167 | +} |
| 168 | +``` |
| 169 | + |
| 170 | +`/admin/**`은 `SecurityConfig`에서 이미 `hasRole("ADMIN")`으로 보호되므로 Security 설정은 변경하지 |
| 171 | +않는다. 응답에는 Claim Token, 실패 원인 상세, 내부 예외 정보를 포함하지 않는다. |
| 172 | + |
| 173 | +## 5. 오류 케이스 |
| 174 | + |
| 175 | +| 상황 | HTTP | 코드 | |
| 176 | +|---|---|---| |
| 177 | +| Job 없음 | 404 | `EMBEDDING-JOB-001` | |
| 178 | +| Job이 `PENDING`·`PROCESSING`·`INDEXED`·`CANCELED` (중복 요청 포함) | 409 | `EMBEDDING-JOB-008` | |
| 179 | +| 최신 Version이 아님 | 409 | `EMBEDDING-JOB-009` | |
| 180 | +| 삭제된 문서이거나 재처리 불가 문서 상태 | 409 | `EMBEDDING-JOB-009` | |
| 181 | +| 같은 Version에 살아 있는 Job 존재 | 409 | `EMBEDDING-JOB-009` | |
| 182 | +| Version이 `FAILED`가 아니거나 현재 Version 포인터 불일치 | 500 | `DOCUMENT-INDEXING-004` | |
| 183 | +| Job ID가 양수가 아님 | 400 | `COMMON-002` | |
| 184 | + |
| 185 | +중복 요청은 멱등 재생 대신 명시적 충돌로 처리한다. 현재 Schema에는 `PENDING` Job이 자동 재시도 |
| 186 | +예약인지 수동 재처리 결과인지 구분하는 식별자가 없어, 멱등 재생을 지원하려면 추가 Column이나 Event |
| 187 | +조회가 필요하기 때문이다. |
| 188 | + |
| 189 | +## 6. 테스트 설계 |
| 190 | + |
| 191 | +### 6.1 단위 테스트 |
| 192 | + |
| 193 | +`EmbeddingJobManualRetryServiceTest` (Mockito) |
| 194 | + |
| 195 | +- Chunk 존재 시 `CHUNKED` 재개, 미존재 시 `UPLOADED` 재개 |
| 196 | +- 소유권 필드와 종료 시각 초기화, `retry_count` 보존 |
| 197 | +- Claim Token 없는 `MANUAL_RETRY` Event 기록 |
| 198 | +- 이전 `INDEXED` Version이 있을 때 문서 상태·포인터 보존 |
| 199 | +- Job 없음, `PENDING`·`PROCESSING`·`INDEXED` 거부 |
| 200 | +- 최신 Version 아님, 삭제된 문서, 살아 있는 Job 존재 거부 |
| 201 | +- Version이 `FAILED`가 아닐 때 불변식 오류 |
| 202 | + |
| 203 | +### 6.2 Controller 테스트 |
| 204 | + |
| 205 | +`IndexingJobAdminControllerTest` (`@WebMvcTest`) |
| 206 | + |
| 207 | +- 정상 응답 필드와 민감 정보 미노출 |
| 208 | +- Job ID Validation |
| 209 | +- 정의된 오류 코드와 HTTP 상태 매핑 |
| 210 | +- ADMIN 외 사용자와 미인증 요청 차단 |
| 211 | + |
| 212 | +### 6.3 통합 테스트 |
| 213 | + |
| 214 | +`EmbeddingJobManualRetryIntegrationTest` (`@Tag("integration")`, 실제 PostgreSQL) |
| 215 | + |
| 216 | +- 소유권 초기화 후 즉시 Claim 후보가 되는지 확인 |
| 217 | +- Chunk 유지와 대상 Version Embedding 삭제 |
| 218 | +- Chunk 없는 Job의 `UPLOADED` 재개 |
| 219 | +- Attempt 이력·재시도 횟수 보존과 `MANUAL_RETRY` Event 1건 |
| 220 | +- 이전 `INDEXED` Version의 검색 결과와 현재 포인터 보존 |
| 221 | +- 동시 요청 2건이 전이 1회 + 충돌 1회로 수렴 |
| 222 | +- 자동 재시도 예정 Job 거부 시 예약 유지 |
| 223 | +- 최신 Version이 아닐 때 거부하고 기존 데이터 유지 |
| 224 | + |
| 225 | +## 7. 커밋 분할 |
| 226 | + |
| 227 | +1. `feat: #108 최종 실패 Job 수동 재처리 도메인 규칙 추가` |
| 228 | +2. `feat: #108 수동 재처리 대상 Embedding 삭제 쿼리 추가` |
| 229 | +3. `feat: #108 최종 실패 Job 수동 재처리 Command Service 구현` |
| 230 | +4. `feat: #108 관리자 수동 재처리 API 추가` |
| 231 | +5. `test: #108 수동 재처리 단위·Controller 테스트 추가` |
| 232 | +6. `test: #108 수동 재처리 PostgreSQL 통합 테스트와 검증 결과 추가` |
| 233 | +7. `docs: #108 최종 실패 Job 수동 재처리 설계 문서 추가` |
| 234 | + |
| 235 | +## 8. 완료 조건 |
| 236 | + |
| 237 | +- 최종 `FAILED` Job만 수동 재처리 가능 |
| 238 | +- `PENDING`·`PROCESSING`·`INDEXED`·`CANCELED` 거부 |
| 239 | +- 현재 검색 가능한 Version 유지 |
| 240 | +- Attempt와 Retry 감사 이력 유지 |
| 241 | +- 소유권 정보 초기화 |
| 242 | +- 동시 재처리 요청이 하나의 상태 전이로 수렴 |
| 243 | +- 전체 회귀 테스트 통과 |
| 244 | + |
| 245 | +## 9. 참고 |
| 246 | + |
| 247 | +- Flyway 마이그레이션 없음. `indexing_events.event_type`은 CHECK 제약이 없는 `VARCHAR(30)`이라 |
| 248 | + `MANUAL_RETRY` 값을 그대로 저장할 수 있다. |
| 249 | +- `SecurityConfig` 변경 없음. |
0 commit comments