diff --git a/docs/design/Gimini-3-#84-document-indexing-completion.md b/docs/design/Gimini-3-#84-document-indexing-completion.md new file mode 100644 index 0000000..ee2202e --- /dev/null +++ b/docs/design/Gimini-3-#84-document-indexing-completion.md @@ -0,0 +1,1198 @@ +# 문서 인덱싱 완료 및 검색 가능 Version 전환 설계 + +## 1. 문서 목적 + +이 문서는 이슈 [#84](https://github.com/DocGrid/backend/issues/84)의 구현 기준을 정의한다. + +선행 기능은 문서 원본을 Chunk로 나누고, Job에 고정된 Embedding Model로 모든 Chunk의 Vector를 +`embeddings`에 원자 저장한다. 하지만 Vector 저장만으로는 문서가 검색 대상이 되지 않는다. +현재 검색 Query는 다음 세 조건을 모두 요구하기 때문이다. + +```text +documents.status = INDEXED +documents.current_version_id = embeddings.document_version_id +embeddings.status = ACTIVE +``` + +이번 기능은 전체 Embedding Set이 저장된 실행을 최종 완료하고 새 Version을 검색 가능하게 만든다. +Attempt, Job, Version, Document, current Version 포인터, 이전 Version의 Embedding 상태와 완료 이벤트를 +하나의 짧은 DB Transaction에서 함께 변경한다. + +### 1.1 성공 기준 + +- 유효한 현재 Claim만 최초 완료를 수행한다. +- 대상 Version은 해당 Document의 최신 Version이며 전체 ACTIVE Embedding Set을 가진다. +- Attempt, Job, Version, Document와 Embedding 가시성이 한 Transaction으로 전환된다. +- 최초 문서는 완료 Transaction 커밋 전에는 검색되지 않고 커밋 후 검색된다. +- 새 Version 처리 중에는 기존 current Version이 검색되고 완료 후에는 새 Version만 검색된다. +- 같은 완료 요청의 순차·동시 재전송은 추가 상태 변경과 중복 이벤트를 만들지 않는다. +- 이미 성공한 같은 실행은 Lease가 만료된 뒤에도 완료 결과를 재생할 수 있다. +- 오래된 Worker, 다른 Token, 다른 Attempt와 stale Version은 검색 가시성을 바꾸지 못한다. + +### 1.2 제외 범위 + +- Attempt·Job·Version 실패 종료와 오류 원인 저장 +- 자동 재시도, Retry 횟수 증가와 다음 실행 시각 계산 +- Lease 연장, 만료 Job 회수와 Worker Polling +- 과거 Version 수동 재활성화 +- 부분 Embedding Set 복구·삭제 도구 +- Batch·병렬 Embedding과 Checkpoint +- Embedding Model 교체·기존 문서 재색인 +- 다중 searchable Model 지원 +- Message Broker, Cache와 새 Production Dependency +- Flyway Migration + +--- + +## 2. 현재 기준선 + +### 2.1 선행 파이프라인 + +현재 관리자 파이프라인은 다음 단계까지 구현돼 있다. + +```text +PENDING Job Claim + ↓ +Attempt STARTED + ↓ +원본 읽기·Chunk 저장 + ↓ +Chunk Embedding 생성·전체 Set 저장 + ↓ +Version = EMBEDDING +Job = PROCESSING +Attempt = STARTED +Embedding = ACTIVE +``` + +`DocumentEmbeddingTransactionService`는 외부 HTTP 호출 전후로 Job과 Version을 같은 순서로 잠그고, +현재 Worker·Claim Token·Lease·Attempt를 재검증한다. 모든 Vector를 준비한 뒤 하나의 Transaction으로 +전체 Embedding Set을 저장하므로 정상 경로에서는 부분 Set이 남지 않는다. + +이번 완료 기능은 외부 I/O가 없다. 따라서 별도의 준비·외부 작업·완료 분리는 필요하지 않고, +한 개의 짧은 Command Transaction으로 구현한다. + +### 2.2 최초 Version과 후속 Version의 차이 + +최초 업로드와 새 Version 업로드는 `current_version_id` 계약이 다르다. + +| 구분 | 완료 전 Document 상태 | 완료 전 current Version | 완료 동작 | +|---|---|---|---| +| 최초 Version | `UPLOADED` | 미완료 대상 Version 자신 | 포인터 유지, 대상 Embedding 유지 | +| 새 Version | `INDEXED` | 기존 검색 가능 Version | 이전 Embedding `STALE`, 포인터 교체 | +| 실패 후 새 Version | `UPLOADED` | 과거 실패 또는 기존 Version | 기존 ACTIVE Embedding만 `STALE`, 포인터 교체 | + +최초 업로드는 순환 FK 때문에 Document와 Version을 차례로 Insert한 뒤 미완료 Version을 +`current_version_id`로 설정한다. 이때 Document가 `INDEXED`가 아니므로 검색 Query가 노출을 막는다. + +새 Version 업로드는 새 Version이 완료될 때까지 기존 `INDEXED` current Version을 유지한다. +따라서 완료 Transaction은 최초 Version에서 자기 Embedding을 `STALE`로 만들지 않아야 하고, +후속 Version에서만 이전 current Version의 ACTIVE Embedding을 비활성화해야 한다. + +### 2.3 검색 가시성의 이중 방어 + +`VectorSearchRepository`는 다음 조건을 함께 적용한다. + +```sql +e.status = 'ACTIVE' +AND d.status = 'INDEXED' +AND d.current_version_id = e.document_version_id +``` + +`current_version_id`만 바꿔도 이전 Version은 검색되지 않지만 Embedding의 `STALE` 전환을 함께 수행한다. +두 조건을 같이 유지하는 이유는 다음과 같다. + +- current Version 포인터는 어떤 Version이 논리적으로 활성인지 표현한다. +- Embedding 상태는 해당 Vector Set이 검색 후보인지 표현한다. +- Query의 방어 조건이 하나 누락돼도 구버전 Vector 혼입을 줄인다. +- 운영 조회에서 ACTIVE이지만 current가 아닌 오래된 Set을 정상으로 오해하지 않는다. + +--- + +## 3. 핵심 설계 결정 + +### 3.1 완료는 하나의 짧은 Transaction이다 + +완료 과정에는 외부 HTTP, Object Storage와 긴 계산이 없다. 모든 검증과 상태 변경을 하나의 +`@Transactional` Command Service에서 수행한다. + +```mermaid +flowchart LR + A["관리자 완료 API"] --> B["DocumentIndexingCompletionService"] + B --> C["Job 잠금·실행 Context 판정"] + C --> D["Version 잠금"] + D --> E["Document 잠금"] + E --> F["Chunk·Embedding 전체성 검증"] + F --> G["이전 Embedding STALE"] + G --> H["Attempt·Job·Version·Document 완료"] + H --> I["INDEXED 이벤트 저장"] + I --> J["한 번에 Commit"] +``` + +검증 실패나 상태 변경 중 예외가 발생하면 Transaction 전체가 Rollback된다. 중간 상태를 별도 +보상 Transaction으로 복구하지 않는다. + +### 3.2 잠금 순서는 Job → Version → Document다 + +모든 최초 완료와 완료 재생은 다음 순서로 행을 잠근다. + +```text +1. embedding_jobs +2. document_versions +3. documents +``` + +이 순서의 의미는 다음과 같다. + +- Job 잠금이 Claim 교체, Attempt 변경과 같은 Job의 동시 완료를 직렬화한다. +- Version 잠금이 Embedding 저장 완료와 최종 Version 전이를 직렬화한다. +- Document 잠금이 `current_version_id` 교체와 새 Version 업로드를 직렬화한다. + +`DocumentVersionUploadService`는 Document만 먼저 잠그지만 기존 Job이나 Version의 쓰기 잠금을 +추가로 획득하지 않는다. 완료 흐름은 Job과 Version을 잠근 뒤 Document를 기다릴 수 있으나, +업로드 흐름이 반대 방향으로 같은 Job·Version 잠금을 기다리지 않으므로 현재 구조에는 순환 대기가 없다. + +향후 실패·복구 기능이 Job, Version과 Document를 함께 변경하면 반드시 같은 순서를 사용해야 한다. +Document를 먼저 잠근 뒤 기존 Job이나 Version을 잠그는 새 흐름은 추가하지 않는다. + +### 3.3 최초 완료와 완료 재생의 검증 규칙을 분리한다 + +Job 잠금 뒤 상태로 요청을 분기한다. + +| Job 상태 | 처리 | +|---|---| +| `PROCESSING` | 현재 소유권과 Lease를 검증하고 최초 완료 수행 | +| `INDEXED` | 저장된 완료 실행 식별자를 검증하고 읽기 전용 재생 | +| `PENDING`, `FAILED`, `CANCELED` | 완료 불가 `409` | + +최초 완료는 아직 상태를 바꾸는 권한을 확인해야 하므로 `EmbeddingJobOwnershipValidator`를 사용한다. +따라서 요청 시각에 Lease가 유효해야 한다. + +완료 재생은 이미 커밋된 결과를 확인하는 작업이다. 응답 유실 뒤 재요청이 Lease 만료 때문에 실패하면 +완료 API의 멱등성이 깨진다. 재생에서는 Lease를 검사하지 않고 다음 저장값을 모두 비교한다. + +- Job ID와 상태 `INDEXED` +- Job의 Worker ID와 Claim Token +- Path Attempt ID +- Attempt의 Job, Worker와 Claim Token +- Attempt 상태 `SUCCESS` +- Job `completed_at`, Attempt `ended_at`, `duration_ms` +- Version 상태 `INDEXED`, `indexed_at` +- Job의 `INDEXED` 이벤트 정확히 한 건 + +다른 Worker나 Token이 완료 결과를 가장하는 것은 재생에서도 `409`로 거부한다. + +### 3.4 완료 응답은 이후 Version 교체에도 안정적인 값만 담는다 + +완료된 과거 Job은 더 최신 Version이 활성화된 뒤에도 재생될 수 있다. +따라서 응답에 호출 시점의 `currentVersionId`, 현재 Document 상태, 현재 Embedding 상태를 넣으면 +같은 완료 실행의 응답이 이후 작업에 따라 달라진다. + +응답에는 완료 실행에 귀속돼 이후에도 변하지 않는 값만 포함한다. + +- Job ID +- Attempt ID +- Document ID +- 완료한 Document Version ID +- Job 고정 Embedding Model ID +- Job 상태 `INDEXED` +- Attempt 상태 `SUCCESS` +- Version 상태 `INDEXED` +- 완료 시각 +- Attempt 처리 시간 + +최신 Document 상태와 current Version은 기존 문서 상태 조회 API가 담당한다. + +### 3.5 Job 고정 Model이 완료 시점에도 검색 Model이어야 한다 + +문서 Vector는 Job 생성 시점의 Model로 고정한다. Query Embedding은 호출 시점의 유일한 +`active + searchable` Model을 사용한다. + +Job 처리 중 Model이 바뀌었는데 과거 Model의 Version을 `INDEXED`로 활성화하면 Document는 상태상 +검색 가능하지만 Query와 같은 Vector 공간을 사용하지 않아 실제 검색 결과가 나오지 않는다. + +MVP에서는 완료 시 다음 조건을 검증한다. + +```text +job.embeddingModel.id is not null +job.embeddingModel.isActive = true +job.embeddingModel.isSearchable = true +job.embeddingModel.dimension > 0 +``` + +완료된 Job의 재생에서는 이후 Model 설정 변경을 이유로 과거 성공 기록을 거부하지 않는다. +Model 교체와 기존 Document 재색인은 별도 기능으로 다룬다. + +--- + +## 4. 상태 전이 + +### 4.1 최초 완료 전후 + +| 대상 | 완료 전 | 완료 후 | +|---|---|---| +| Attempt | `STARTED` | `SUCCESS`, endedAt·durationMs 기록 | +| Job | `PROCESSING` | `INDEXED`, completedAt 기록 | +| 대상 Version | `EMBEDDING` | `INDEXED`, indexedAt 기록 | +| Document | `UPLOADED` 또는 `INDEXED` | `INDEXED` | +| current Version | 대상 자신 또는 더 오래된 Version | 대상 Version | +| 대상 Embedding | 전부 `ACTIVE` | 전부 `ACTIVE` | +| 이전 current Embedding | `ACTIVE` 가능 | 대상과 다를 때 `STALE` | +| 이벤트 | `INDEXED` 없음 | `INDEXED` 한 건 | + +Attempt, Job, Version과 이벤트는 같은 `completedAt`을 사용한다. + +```text +durationMs = completedAt - attempt.startedAt +``` + +`startedAt`이 없거나 완료 시각보다 미래면 내부 데이터 불일치로 처리한다. 음수를 0으로 보정해 +데이터 문제를 숨기지 않는다. + +### 4.2 완료 재생 + +재생에서는 어떤 Entity나 Embedding도 수정하지 않는다. + +```text +Job INDEXED +Attempt SUCCESS +Version INDEXED +INDEXED Event 1 + ↓ +저장된 completedAt·durationMs로 동일 완료 응답 반환 +``` + +더 최신 Version이 이미 활성화돼 과거 Version의 Embedding이 `STALE`이어도 과거 완료는 유효하다. +재생 검증은 대상 Version이 현재 Version인지, 대상 Embedding이 아직 ACTIVE인지 요구하지 않는다. + +### 4.3 상태 머신 + +```mermaid +stateDiagram-v2 + [*] --> PROCESSING: Job Claim + PROCESSING --> PROCESSING: Chunk·Embedding 저장 + PROCESSING --> INDEXED: 유효 Claim + 전체 Embedding Set + INDEXED --> INDEXED: 같은 완료 실행 재생 + PROCESSING --> Rejected: Lease·소유권·전체성 오류 + FAILED --> Rejected + CANCELED --> Rejected +``` + +```mermaid +stateDiagram-v2 + [*] --> UPLOADED + UPLOADED --> PARSING + PARSING --> CHUNKED + CHUNKED --> EMBEDDING + EMBEDDING --> INDEXED: 완료 Transaction + EMBEDDING --> Rejected: 부분 Set·stale Version +``` + +--- + +## 5. API 계약 + +### 5.1 Endpoint + +```http +POST /admin/indexing-jobs/{jobId}/attempts/{attemptId}/complete +Content-Type: application/json +Authorization: Bearer {ADMIN_TOKEN} +``` + +기존 `/admin/**` Security 정책을 재사용한다. + +### 5.2 요청 + +```json +{ + "workerId": 7, + "claimToken": "34c19d16-6ae1-4f6a-a35d-0123456789ab" +} +``` + +`CompleteDocumentIndexingRequest`를 새로 만든다. + +| 필드 | 검증 | +|---|---| +| `workerId` | null 불가, 양수 | +| `claimToken` | null·공백 불가, canonical 소문자 UUID | + +Claim Token 형식은 선행 Attempt·Chunk·Embedding 요청과 같은 검증 계약을 사용한다. + +### 5.3 성공 응답 + +최초 완료와 멱등 재생은 모두 `200 OK`다. + +```json +{ + "success": true, + "data": { + "jobId": 41, + "attemptId": 103, + "documentId": 10, + "documentVersionId": 22, + "embeddingModelId": 1, + "jobStatus": "INDEXED", + "attemptStatus": "SUCCESS", + "versionStatus": "INDEXED", + "completedAt": "2026-07-31T16:00:00", + "durationMs": 8421 + } +} +``` + +`DocumentIndexingCompletionResponse`는 Claim Token, Worker 내부 상태, Chunk 본문, Vector, +Vector Hash와 이전 Version ID를 반환하지 않는다. + +### 5.4 HTTP 오류 + +| HTTP | 조건 | +|---|---| +| `400` | Path ID, Worker ID, Claim Token 형식 오류 | +| `403` | ADMIN 권한 없음 또는 미인증 | +| `404` | Embedding Job 없음 | +| `409` | Job 상태, 소유권, Lease, Attempt, stale Version, 완료 불가 상태 | +| `500` | Job·Attempt·Version·Document·Embedding·이벤트 데이터 불일치 | + +--- + +## 6. 완료 Transaction 상세 흐름 + +### 6.1 전체 알고리즘 + +```text +1. Job을 FOR UPDATE로 조회한다. +2. Job 상태가 INDEXED면 완료 재생으로 분기한다. +3. Job 상태가 PROCESSING이 아니면 거부한다. +4. 잠금 획득 뒤 계산한 현재 시각으로 Worker·Token·Lease를 검증한다. +5. 같은 Job·Token의 Attempt를 조회하고 Path ID·Worker·STARTED 상태를 검증한다. +6. Job이 가리키는 Version을 FOR UPDATE로 조회한다. +7. Version이 가리키는 Document를 FOR UPDATE로 조회한다. +8. Version·Document 관계, 최신 Version과 current Version 포인터를 검증한다. +9. Job Model과 Version 상태를 검증한다. +10. Chunk·Embedding 전체 Set과 INDEXED 이벤트 사전 상태를 검증한다. +11. 이전 current Version이 대상과 다르면 ACTIVE Embedding을 STALE로 일괄 갱신한다. +12. Version, Document, Attempt와 Job을 완료 상태로 전환한다. +13. INDEXED 이벤트를 저장한다. +14. Transaction Commit 뒤 완료 응답을 반환한다. +``` + +모든 검증은 최초 상태 변경 전에 끝낸다. 검증 도중 일부 Entity 상태를 먼저 바꾸지 않는다. + +### 6.2 정상 완료 Sequence + +```mermaid +sequenceDiagram + participant C as Admin Client + participant S as Completion Service + participant J as Embedding Job + participant V as Document Version + participant D as Document + participant E as Embeddings + participant A as Attempt/Event + + C->>S: POST complete(workerId, claimToken) + S->>J: FOR UPDATE + S->>S: PROCESSING·Worker·Token·Lease 검증 + S->>S: STARTED Attempt 검증 + S->>V: FOR UPDATE + S->>D: FOR UPDATE + S->>S: 최신 Version·Model·전체 Set 검증 + alt 이전 current Version이 다름 + S->>E: ACTIVE → STALE bulk update + end + S->>V: INDEXED + indexedAt + S->>D: currentVersion 교체 + INDEXED + S->>A: Attempt SUCCESS + duration + S->>J: Job INDEXED + completedAt + S->>A: INDEXED Event insert + S-->>C: 200 완료 응답 +``` + +### 6.3 동시 완료 Sequence + +```mermaid +sequenceDiagram + participant R1 as Request 1 + participant R2 as Request 2 + participant DB as PostgreSQL + + R1->>DB: Job FOR UPDATE 획득 + R2->>DB: 같은 Job FOR UPDATE 대기 + R1->>DB: 전체 상태 전환 + Event insert + R1->>DB: Commit + DB-->>R2: Job Lock 획득, status=INDEXED + R2->>DB: 완료 실행 식별자·최종 상태 검증 + R2-->>R2: 변경 없이 재생 +``` + +두 번째 요청이 Job 잠금을 얻었을 때 Lease가 만료됐더라도 첫 요청의 완료가 커밋됐다면 재생할 수 있다. +반대로 첫 요청이 Rollback돼 Job이 계속 `PROCESSING`이면 두 번째 요청은 현재 시각의 Lease 검증을 +통과해야 최초 완료를 수행할 수 있다. + +--- + +## 7. 최초 완료 검증 규칙 + +### 7.1 Job + +- ID가 존재한다. +- 상태가 `PROCESSING`이다. +- `lockedByWorker`, `claimToken`, `lockedAt`, `lockExpiresAt`이 모두 존재한다. +- 요청 Worker와 Token이 저장값과 같다. +- `lockExpiresAt`은 Job 잠금 뒤 계산한 `completedAt`보다 뒤다. +- `documentVersion`과 `embeddingModel` 연관 ID가 존재한다. +- 대상 Version의 `PENDING`·`PROCESSING` Job은 현재 Job 하나뿐이다. + +마지막 조건은 잘못된 직접 데이터 입력이나 과거 버그로 동일 Version에 활성 Job이 둘 생긴 경우 +두 완료 흐름이 각각 상태를 확정하는 것을 막는다. + +### 7.2 Attempt + +- Job ID와 Claim Token으로 조회된다. +- Path Attempt ID와 같다. +- Attempt의 Job ID가 잠근 Job과 같다. +- Worker ID가 요청 및 Job Worker와 같다. +- 상태가 `STARTED`다. +- `startedAt`이 존재하고 `completedAt` 이후가 아니다. + +Attempt는 별도 Pessimistic Lock을 추가하지 않는다. 모든 Attempt 상태 변경이 Job 잠금을 먼저 +획득한다는 규칙으로 같은 Job의 변경을 직렬화한다. + +### 7.3 Version과 Document + +- Job이 직접 참조하는 Version을 사용한다. +- Version 상태가 `EMBEDDING`이다. +- Version의 Document ID가 존재한다. +- Version이 가리키는 Document를 잠금 조회한다. +- Document가 `DELETED` 또는 `ARCHIVED`가 아니다. +- Document 상태가 `UPLOADED`, `INDEXING`, `INDEXED` 중 하나다. +- current Version이 존재하고 같은 Document 소속이다. +- current Version 번호가 완료 대상 Version 번호보다 크지 않다. +- Document 잠금 뒤 조회한 최신 Version ID가 완료 대상 Version ID와 같다. + +대상보다 최신 Version이 이미 존재하면 stale 완료 `409`로 처리한다. 관계 필드가 서로 다른 +Document를 가리키면 내부 데이터 불일치 `500`이다. + +### 7.4 Model + +- Job Model ID가 존재한다. +- Dimension이 양수다. +- `isActive`와 `isSearchable`이 모두 true다. +- 완료 대상 모든 Embedding의 Model ID와 Dimension이 Job Model과 같다. + +### 7.5 Chunk와 Embedding + +다음 개수를 한 번에 비교한다. + +```text +chunkCount > 0 +allEmbeddingsForVersion = chunkCount +jobModelEmbeddingsForVersion = chunkCount +activeJobModelEmbeddingsForVersion = chunkCount +inconsistentEmbeddingRows = 0 +``` + +`inconsistentEmbeddingRows`는 Vector 본문을 JVM으로 읽지 않는 집계 Query로 검증한다. + +- Embedding의 `document_id`가 대상 Document와 다름 +- Embedding의 `document_version_id`가 대상 Version과 다름 +- Embedding의 Chunk가 대상 Version 소속이 아님 +- Embedding의 Model이 Job Model과 다름 +- 저장 Dimension 또는 실제 `vector_dims(vector)`가 Model Dimension과 다름 +- Vector Hash가 null이거나 64자리 소문자 SHA-256 Hex가 아님 + +`(chunk_id, embedding_model_id)` Unique Constraint와 위 개수·연관 검증을 결합하면 각 Chunk가 +Job Model의 ACTIVE Embedding을 정확히 하나 가진다는 결론을 얻을 수 있다. + +### 7.6 이벤트 사전 상태 + +최초 완료 전 현재 Job의 `INDEXED` 이벤트 수는 0이어야 한다. 이미 이벤트가 있는데 Job이 +`PROCESSING`이면 상태와 이력 로그가 모순이므로 새 이벤트를 추가하지 않고 내부 오류로 거부한다. + +--- + +## 8. current Version 교체와 STALE 처리 + +### 8.1 최초 Version + +```text +document.currentVersion.id == targetVersion.id +``` + +이 경우 이전 Version 비활성화 Query를 실행하지 않는다. 대상 Embedding은 ACTIVE를 유지하고, +Version과 Document 상태만 `INDEXED`로 바뀐다. + +### 8.2 새 Version + +```text +document.currentVersion.id != targetVersion.id +``` + +다음 순서로 변경한다. + +```text +1. previousCurrentVersion의 ACTIVE Embedding을 STALE로 bulk update +2. targetVersion을 INDEXED로 전환 +3. document.currentVersion을 targetVersion으로 교체 +4. document를 INDEXED로 전환 +``` + +SQL 실행 순서는 존재하지만 다른 Transaction은 Commit 전 중간 결과를 볼 수 없다. +검색 요청은 Commit 전에는 기존 current Version과 ACTIVE Embedding을 보고, Commit 후에는 새 +current Version과 새 ACTIVE Embedding을 본다. + +### 8.3 과거 Version 자체는 INDEXED를 유지한다 + +이전 Version의 상태를 `FAILED`나 별도 상태로 바꾸지 않는다. Version은 과거 한 시점에 성공적으로 +색인됐다는 이력과 Citation 관계를 보존한다. + +검색 제외는 다음 두 값으로 표현한다. + +- Document의 current Version이 더 최신 Version으로 이동 +- 과거 Version의 Embedding이 `STALE` + +--- + +## 9. 완료 재생 규칙 + +### 9.1 재생이 허용되는 조건 + +Job이 `INDEXED`일 때 다음 조건을 모두 만족해야 한다. + +- Job Worker와 요청 Worker가 같다. +- Job Claim Token과 요청 Token이 같다. +- Attempt ID가 Path와 같다. +- Attempt Job·Worker·Token이 Job 및 요청과 같다. +- Attempt 상태가 `SUCCESS`다. +- Job completedAt, Attempt endedAt·durationMs가 존재한다. +- Version 상태가 `INDEXED`이고 indexedAt이 존재한다. +- Job의 `INDEXED` 이벤트가 정확히 한 건이다. + +Lease, latest Version, current Version과 Embedding ACTIVE 상태는 재생 조건이 아니다. + +### 9.2 재생에서 금지되는 변경 + +- Attempt endedAt·durationMs 재계산 +- Job completedAt 덮어쓰기 +- Version indexedAt 덮어쓰기 +- Document current Version 교체 +- Embedding ACTIVE·STALE 변경 +- INDEXED 이벤트 추가 + +응답은 저장된 최초 완료 시각과 처리 시간을 사용한다. + +### 9.3 재생이 Lease를 무시하는 이유 + +Lease는 아직 끝나지 않은 작업의 상태 변경 권한을 제한한다. 이미 성공한 결과를 읽는 데까지 Lease를 +요구하면 다음 상황에서 클라이언트가 성공 여부를 확인할 수 없다. + +```text +서버: 완료 Commit 성공 +네트워크: 응답 유실 +시간: Lease 만료 +클라이언트: 같은 Token으로 결과 재요청 +``` + +재생은 새 상태를 만들지 않으며 ADMIN API와 저장된 Claim Token 비교로 보호된다. + +--- + +## 10. Domain 변경 + +### 10.1 EmbeddingJob + +`markIndexed`는 `PROCESSING`에서만 호출할 수 있도록 Guard를 추가한다. + +```text +PROCESSING → INDEXED 허용 +그 외 → IllegalStateException +``` + +completedAt을 함께 기록한다. 기존 Worker, Claim Token과 Lease 필드는 완료 실행 식별 및 감사 근거로 +유지하고 API에는 노출하지 않는다. + +### 10.2 EmbeddingJobAttempt + +`markSuccess`는 `STARTED`에서만 허용한다. + +- 상태를 `SUCCESS`로 변경 +- endedAt 기록 +- durationMs 기록 +- errorCode와 errorMessage는 설정하지 않음 + +재생은 이 메서드를 다시 호출하지 않는다. + +### 10.3 DocumentVersion + +`markIndexed`는 `EMBEDDING`에서만 허용한다. + +- 상태를 `INDEXED`로 변경 +- indexedAt 기록 + +과거에 이미 `INDEXED`인 Version의 재생은 메서드를 다시 호출하지 않는다. + +### 10.4 Document + +완료 전용 메서드 `activateIndexedVersion`을 추가한다. + +```text +입력 Version이 이 Document 소속인지 검증 +입력 Version 상태가 INDEXED인지 검증 +currentVersion = 입력 Version +status = INDEXED +``` + +초기 업로드에서 미완료 Version 포인터를 설정하는 `updateCurrentVersion`은 기존 용도를 유지한다. + +--- + +## 11. Repository 변경 + +### 11.1 EmbeddingRepository + +추가 계약은 다음과 같다. + +```java +long countByDocumentVersionId(Long versionId); + +long countByDocumentVersionIdAndEmbeddingModelIdAndStatus( + Long versionId, + Long modelId, + EmbeddingStatus status +); + +int markActiveAsStaleByDocumentVersionId(Long versionId); + +long countInvalidCompletionRows( + Long documentId, + Long versionId, + Long modelId, + int dimension +); +``` + +`markActiveAsStaleByDocumentVersionId`는 `@Modifying` bulk update를 사용한다. 완료 Service는 이전 +Version Embedding Entity를 영속성 Context에 올리지 않으므로 bulk update와 관리 Entity 값이 +충돌하지 않는다. + +`countInvalidCompletionRows`는 OpenSQL/pgvector Native Query로 구현해 Vector 배열을 JVM으로 +전송하지 않고 연관·Dimension·Hash만 집계한다. + +### 11.2 EmbeddingJobRepository + +동일 Version의 활성 Job이 하나인지 검증할 수 있는 개수 조회를 추가한다. + +```java +long countByDocumentVersionIdAndStatusIn( + Long versionId, + Collection statuses +); +``` + +활성 상태는 `PENDING`, `PROCESSING`이다. 최초 완료 시 현재 `PROCESSING` Job 한 건만 있어야 한다. + +### 11.3 IndexingEventRepository + +완료 이벤트의 사전 상태와 재생 정합성을 검증한다. + +```java +long countByEmbeddingJobIdAndEventType( + Long jobId, + IndexingEventType eventType +); +``` + +### 11.4 DocumentVersionRepository + +기존 `findTopByDocumentIdOrderByVersionNoDesc`를 Document 잠금 뒤 호출한다. +새 Version 업로드는 Document 잠금을 먼저 획득하므로 애플리케이션을 통한 동시 Insert는 최신 Version +검증과 경합한다. + +별도 Version 목록 잠금이나 새 Migration은 추가하지 않는다. + +--- + +## 12. Service 구조 + +### 12.1 DocumentIndexingCompletionService + +경로: + +```text +src/main/java/com/opensource/docgrid/domain/embedding/service/command/ +└── DocumentIndexingCompletionService.java +``` + +책임: + +- 완료 Transaction 시작과 종료 +- Job → Version → Document 잠금 순서 +- 최초 완료와 재생 분기 +- 실행 Context·전체 Set·최신 Version 검증 +- 이전 Embedding STALE 처리 +- 상태 전이와 INDEXED 이벤트 저장 +- 안정적인 완료 응답 생성 + +책임이 아닌 것: + +- Claim 발급 +- Attempt 생성 +- Chunk·Vector 생성 +- 실패·Retry 처리 +- 외부 호출 +- 일반 사용자 문서 상태 조회 + +별도 Facade는 만들지 않는다. 외부 I/O가 없고 Transaction이 하나이므로 Controller가 Command Service를 +직접 호출한다. + +### 12.2 내부 메서드 분리 + +한 번만 쓰이는 범용 추상화는 만들지 않고 검증 의도가 보이는 private 메서드로 나눈다. + +```text +complete +├── findLockedJob +├── resolveAttempt +├── completeProcessingJob +│ ├── validateOwnership +│ ├── findLockedVersionAndDocument +│ ├── validateLatestTarget +│ ├── validateEmbeddingSet +│ ├── stalePreviousEmbeddings +│ └── transitionAndRecordEvent +└── replayIndexedJob + ├── validateCompletedIdentity + └── validateCompletedState +``` + +Service의 순차 실행 지점에는 `1.`, `2.`, `3.` 형태의 번호 주석을 사용한다. + +--- + +## 13. 이벤트 계약 + +최초 완료에서 다음 이벤트를 한 건 저장한다. + +| 필드 | 값 | +|---|---| +| `eventType` | `INDEXED` | +| `fromStatus` | `EMBEDDING` | +| `toStatus` | `INDEXED` | +| `message` | `Document Version 인덱싱을 완료했습니다.` | +| `metadataJson` | null | +| `occurredAt` | completedAt | + +Claim Token, Worker 내부 정보, Chunk 본문, Vector, Vector Hash와 File Object 위치는 이벤트에 넣지 않는다. + +재생은 이벤트를 추가하지 않고 기존 이벤트가 정확히 한 건인지 검증한다. + +--- + +## 14. 오류 계약 + +기존 오류를 우선 재사용한다. + +| ErrorCode | 사용 조건 | +|---|---| +| `EMBEDDING_JOB_NOT_FOUND` | Job 없음 | +| `EMBEDDING_JOB_OWNERSHIP_INVALID` | Worker·Token 불일치 | +| `EMBEDDING_JOB_LEASE_EXPIRED` | 최초 완료 시 Lease 만료 | +| `EMBEDDING_JOB_OWNERSHIP_INCONSISTENT` | PROCESSING Job 소유권 필드 모순 | +| `EMBEDDING_JOB_ATTEMPT_INVALID` | Attempt ID·Worker·Token·상태 불일치 | +| `EMBEDDING_MODEL_NOT_CONFIGURED` | Job Model이 완료 시점에 active·searchable이 아님 | + +완료 기능 전용 오류를 추가한다. + +| 신규 ErrorCode | HTTP | 조건 | +|---|---:|---| +| `DOCUMENT_INDEXING_COMPLETION_NOT_ALLOWED` | 409 | 허용되지 않은 Job·Version·Document 상태 | +| `DOCUMENT_INDEXING_STALE_COMPLETION` | 409 | 대상보다 최신 Version 존재 또는 current 포인터가 더 최신 | +| `DOCUMENT_INDEXING_COMPLETION_INCONSISTENT` | 500 | 연관·개수·Dimension·Hash·완료 시각·이벤트 모순 | + +로그에는 Job ID, Attempt ID, Document ID, Version ID와 개수만 남긴다. +Claim Token, Chunk Text와 Vector 값은 기록하지 않는다. + +--- + +## 15. 동시성과 교착 분석 + +### 15.1 같은 Job의 동시 완료 + +Job Pessimistic Lock으로 직렬화한다. + +- 첫 요청: `PROCESSING`을 보고 최초 완료 +- 두 번째 요청: Commit 뒤 `INDEXED`를 보고 재생 +- INDEXED 이벤트: 한 건 +- completedAt·durationMs: 첫 요청 값 유지 + +### 15.2 완료와 새 Version 업로드 + +새 Version 업로드는 Document를 먼저 잠근다. + +가능한 순서는 두 가지다. + +#### 업로드가 Document Lock을 먼저 얻음 + +```text +업로드: Document Lock +완료: Job → Version Lock 후 Document 대기 +업로드: 처리 중 Version 확인 후 거부 또는 새 Version 생성 후 Commit +완료: Document Lock 획득 후 최신 Version 재검증 +``` + +업로드가 더 최신 Version을 만들었다면 완료는 stale 완료로 거부한다. + +#### 완료가 Document Lock을 먼저 얻음 + +```text +완료: Job → Version → Document Lock +업로드: Document Lock 대기 +완료: current Version 교체 후 Commit +업로드: 새 current Version 기준으로 동일 파일·상태 검증 +``` + +어느 순서에서도 두 Transaction이 서로 반대 방향의 같은 Lock을 기다리지 않는다. + +### 15.3 다른 Version 완료 경쟁 + +부분 Unique Index는 한 Document에 `UPLOADED`, `PARSING`, `CHUNKED`, `EMBEDDING` Version을 하나만 +허용한다. 직접 데이터 입력이나 미래 재처리 기능으로 두 완료 후보가 생겨도 Document Lock과 최신 +Version 검증으로 하나만 current Version을 바꿀 수 있다. + +### 15.4 검색 요청과 완료 + +검색은 행 잠금을 획득하지 않는다. PostgreSQL `READ COMMITTED`에서 검색 Statement는 완료 +Transaction 커밋 전 또는 후의 일관된 Snapshot을 읽는다. + +- 커밋 전: 기존 current Version + 기존 ACTIVE Embedding +- 커밋 후: 새 current Version + 새 ACTIVE Embedding + +중간의 포인터만 바뀌고 Embedding이 아직 ACTIVE/STALE 전환되지 않은 상태는 다른 Transaction에 +노출되지 않는다. + +--- + +## 16. 보안과 운영 관점 + +### 16.1 민감 데이터 경계 + +다음 값은 API 응답, 이벤트와 일반 로그에 넣지 않는다. + +- Claim Token +- Chunk 본문 +- Embedding Vector +- Vector Hash +- MinIO Bucket·Object Key +- 내부 예외 Stack Trace의 요청 Body + +Claim Token은 요청 검증과 저장값 비교에만 사용한다. + +### 16.2 완료 후 Claim 정보 유지 + +Job과 Attempt의 Claim Token 및 Worker 연관은 완료 재생과 감사 근거로 유지한다. +Job 상태가 `INDEXED`이므로 새로운 실행 권한으로 사용할 수 없고 일반 사용자 API에 노출되지 않는다. + +향후 보존 정책에서 Token 삭제가 필요하면 완료 결과용 별도 Idempotency Key 또는 완료 Receipt를 +먼저 설계해야 한다. + +### 16.3 관측 가능성 + +정상 완료 로그: + +```text +jobId, attemptId, documentId, versionId, chunkCount, embeddingCount, durationMs +``` + +정상 재생 로그: + +```text +jobId, attemptId, completedAt, replay=true +``` + +데이터 불일치 로그는 실제 개수와 식별자만 남기고 본문과 Vector는 남기지 않는다. + +--- + +## 17. 파일별 변경 계획 + +### 17.1 신규 파일 + +```text +src/main/java/com/opensource/docgrid/domain/embedding/dto/request/ +└── CompleteDocumentIndexingRequest.java + +src/main/java/com/opensource/docgrid/domain/embedding/dto/response/ +└── DocumentIndexingCompletionResponse.java + +src/main/java/com/opensource/docgrid/domain/embedding/service/command/ +└── DocumentIndexingCompletionService.java + +src/test/java/com/opensource/docgrid/domain/embedding/service/command/ +└── DocumentIndexingCompletionServiceTest.java + +src/test/java/com/opensource/docgrid/domain/document/entity/ +└── DocumentTest.java + +src/test/java/com/opensource/docgrid/domain/embedding/repository/ +└── IndexingCompletionRepositoryTest.java + +src/test/java/com/opensource/docgrid/domain/embedding/integration/ +└── DocumentIndexingCompletionIntegrationTest.java +``` + +모든 신규 Class와 Record에는 역할·책임·경계를 설명하는 class-level comment를 작성한다. +검색 전환·동시 완료·Rollback은 같은 OpenSQL Schema와 Fixture를 공유하므로 별도 Rollback Class로 +분리하지 않고 `DocumentIndexingCompletionIntegrationTest` 한 곳에 모은다. + +### 17.2 수정 파일 + +```text +src/main/java/com/opensource/docgrid/domain/embedding/controller/ +└── IndexingJobAdminController.java + +src/main/java/com/opensource/docgrid/domain/embedding/entity/ +└── EmbeddingJob.java + +src/main/java/com/opensource/docgrid/domain/worker/entity/ +└── EmbeddingJobAttempt.java + +src/main/java/com/opensource/docgrid/domain/document/entity/ +├── Document.java +└── DocumentVersion.java + +src/main/java/com/opensource/docgrid/domain/embedding/repository/ +├── EmbeddingJobRepository.java +└── EmbeddingRepository.java + +src/main/java/com/opensource/docgrid/domain/worker/repository/ +└── IndexingEventRepository.java + +src/main/java/com/opensource/docgrid/global/exception/ +└── ErrorCode.java +``` + +관련 Entity·Repository·Controller Test의 주석과 기존 전이 테스트를 새 Guard에 맞게 갱신한다. + +### 17.3 변경하지 않는 파일 + +- Flyway Migration +- SecurityConfig의 `/admin/**` 정책 +- EmbeddingClient와 외부 Server 설정 +- VectorSearchRepository Query +- 일반 사용자 Document 상태 API 응답 계약 +- Upload API와 File Storage + +--- + +## 18. 테스트 설계 + +### 18.1 Entity 테스트 + +#### EmbeddingJob + +- `PROCESSING → INDEXED` 성공과 completedAt 기록 +- `PENDING`, `INDEXED`, `FAILED`, `CANCELED`에서 markIndexed 거부 + +#### EmbeddingJobAttempt + +- `STARTED → SUCCESS`와 endedAt·durationMs 기록 +- `SUCCESS`, `FAILED`에서 중복 markSuccess 거부 + +#### DocumentVersion + +- `EMBEDDING → INDEXED`와 indexedAt 기록 +- 다른 상태에서 markIndexed 거부 + +#### Document + +- 같은 Document의 INDEXED Version 활성화 +- 다른 Document Version과 미완료 Version 거부 + +### 18.2 Service 단위 테스트 + +정상: + +- 최초 Version 완료 +- 새 Version 완료와 이전 Embedding STALE +- 이미 INDEXED인 같은 실행 재생 +- 재생은 Lease 만료 뒤에도 성공 +- 완료 시각과 durationMs가 모든 응답에서 유지 + +소유권·Attempt: + +- 다른 Worker +- 다른 Claim Token +- 다른 Attempt ID +- Attempt Worker 불일치 +- Attempt `FAILED` 또는 이미 `SUCCESS`인데 Job은 PROCESSING +- 최초 완료 Lease 만료 +- PROCESSING Job 소유권 필드 누락 + +상태·연관: + +- Job이 PENDING·FAILED·CANCELED +- Version이 EMBEDDING이 아님 +- Document가 DELETED·ARCHIVED +- Job Version과 잠근 Version 불일치 +- Version과 Document 관계 불일치 +- current Version이 다른 Document 소속 +- 대상보다 최신 Version 존재 + +Model·전체 Set: + +- Job Model ID 또는 Dimension 누락 +- Job Model 비활성 또는 검색 불가 +- Chunk 0건 +- 부분 Embedding +- 다른 Model Embedding 혼입 +- ACTIVE 수 불일치 +- Dimension·Hash·역정규화 불일치 +- 기존 INDEXED 이벤트 존재 +- 동일 Version 활성 Job 중복 + +재생 불일치: + +- INDEXED Job의 다른 Worker·Token·Attempt +- Attempt가 SUCCESS가 아님 +- completedAt·endedAt·durationMs 누락 +- Version이 INDEXED가 아님 +- INDEXED 이벤트 0건 또는 2건 이상 + +### 18.3 Controller 테스트 + +- 유효한 ADMIN 완료 요청 `200` +- 완료 재생 `200` +- 잘못된 Job·Attempt ID `400` +- workerId null·0·음수 `400` +- Claim Token null·공백·비정규 UUID `400` +- USER 권한 `403` +- 미인증 `403` +- Service의 `404`, `409`, `500` ErrorResponse 매핑 +- 응답에 Claim Token, Chunk, Vector 필드가 없음 + +### 18.4 OpenSQL 통합 테스트 + +#### 시나리오 A: 최초 Version 활성화 + +1. UPLOADED Document와 current Version을 생성한다. +2. Version·Job·Attempt·Chunk·ACTIVE Embedding Set을 완료 직전 상태로 준비한다. +3. 완료 API 또는 Service를 호출한다. +4. Attempt SUCCESS, Job·Version·Document INDEXED를 확인한다. +5. current Version이 대상 자신을 유지하는지 확인한다. +6. 대상 Embedding이 ACTIVE인지 확인한다. +7. INDEXED 이벤트 한 건과 동일 완료 시각을 확인한다. +8. 권한 pre-filter와 Vector Search에서 해당 Document가 검색되는지 확인한다. + +#### 시나리오 B: 새 Version 교체 + +1. Version 1을 INDEXED current Version과 ACTIVE Embedding으로 준비한다. +2. Version 2를 EMBEDDING과 전체 ACTIVE Set으로 준비한다. +3. 완료 전 검색이 Version 1 Chunk만 반환하는지 확인한다. +4. Version 2 완료를 호출한다. +5. current Version이 Version 2로 바뀌는지 확인한다. +6. Version 1 Embedding은 STALE, Version 2는 ACTIVE인지 확인한다. +7. 완료 후 검색이 Version 2 Chunk만 반환하는지 확인한다. + +#### 시나리오 C: 두 Thread 동시 완료 + +1. 같은 Job·Attempt·Worker·Token 요청 두 개를 준비한다. +2. 두 Thread를 Barrier로 동시에 시작한다. +3. 두 요청이 모두 같은 완료 응답으로 성공하는지 확인한다. +4. Job·Attempt·Version 완료 시각이 한 번만 결정됐는지 확인한다. +5. INDEXED 이벤트가 정확히 한 건인지 확인한다. +6. current Version과 Embedding 상태가 단일 결과로 수렴하는지 확인한다. + +#### 시나리오 D: 완료 중 실패 Rollback + +실제 OpenSQL의 `indexing_events`에 테스트용 Trigger를 설치해 `INDEXED` 이벤트 Insert만 실패시킨다. + +1. 실제 OpenSQL에 완료 직전 전체 데이터를 준비한다. +2. 이전 Embedding bulk update와 Entity 상태 변경 뒤 이벤트 Insert에서 DB 예외를 발생시킨다. +3. 새 Transaction으로 DB를 다시 조회한다. +4. Attempt STARTED, Job PROCESSING, Version EMBEDDING을 확인한다. +5. Document current Version과 이전 ACTIVE Embedding이 그대로인지 확인한다. +6. INDEXED 이벤트가 0건인지 확인한다. +7. `finally`에서 테스트용 Trigger와 Function을 제거한다. + +Repository Stub을 쓰면 실제 DB Transaction의 마지막 Flush 실패를 검증할 수 없으므로, 이 테스트는 +Mockito가 아니라 실제 OpenSQL Trigger와 DB 재조회로 전체 Rollback을 확인한다. + +### 18.5 전체 회귀 + +```bash +./gradlew clean build +git diff --check +``` + +OpenSQL 통합 테스트는 실제 `vector(1024)` Schema와 `vector_dims` 함수를 사용하는 환경에서 실행한다. + +--- + +## 19. 구현 순서 + +1. Entity 성공 전이 Guard와 단위 테스트 +2. Repository 개수·불일치·STALE·이벤트 조회 계약과 테스트 +3. 완료 Request·Response 계약 +4. `DocumentIndexingCompletionService` 최초 완료 경로 +5. 완료 재생 경로와 멱등성 테스트 +6. 관리자 Controller와 Security 계약 테스트 +7. OpenSQL 최초·새 Version 검색 가시성 테스트 +8. 동시 완료와 Rollback 통합 테스트 +9. 전체 회귀와 설계·구현 정합성 갱신 + +각 단계는 기능 코드와 해당 계약 테스트가 함께 검증되도록 나눈다. + +--- + +## 20. 선택지와 Trade-off + +### 20.1 완료 전용 Receipt 테이블을 만들지 않는다 + +별도 Receipt는 완료 응답을 영구 Snapshot으로 저장할 수 있지만 Migration과 새 데이터 수명주기가 필요하다. +현재 Job·Attempt·Version에 안정적인 완료 시각과 실행 식별 정보가 있으므로 이를 재생 근거로 사용한다. + +### 20.2 완료 재생에서 Lease를 검사하지 않는다 + +Lease를 검사하면 단순하지만 응답 유실 뒤 완료 확인이 불가능해질 수 있다. 재생은 상태를 바꾸지 않고 +ADMIN 및 저장된 실행 식별자를 모두 검증하므로 Lease 없이 허용한다. + +### 20.3 이전 Embedding을 삭제하지 않는다 + +삭제하면 저장 공간은 줄지만 과거 Citation과 운영 분석 근거가 사라진다. `STALE`로 전환해 검색에서 +제외하면서 이력을 유지한다. + +### 20.4 Version 전체 Vector를 다시 읽지 않는다 + +완료 시 Vector 전체를 JVM으로 읽고 Hash를 재계산하면 가장 강한 검증이 가능하지만 문서 크기에 비례한 +메모리와 DB 전송 비용이 다시 발생한다. 생성 Transaction이 Vector와 Hash를 검증했으므로 완료에서는 +DB 집계로 수·연관·상태·Dimension·Hash 형식을 검증한다. + +### 20.5 current Version만 바꾸지 않고 Embedding도 STALE로 만든다 + +Query는 current Version 조건으로 구버전을 이미 차단한다. 그럼에도 상태를 함께 갱신해 검색 Query의 +이중 방어와 운영 데이터 의미를 일치시킨다. 두 변경이 같은 Transaction이므로 중간 불일치는 노출되지 않는다. + +--- + +## 21. 최종 완료 체크리스트 + +- [x] Job → Version → Document 잠금 순서가 코드와 주석에 명시돼 있다. +- [x] 최초 완료와 완료 재생이 Job 상태로 명확히 분리된다. +- [x] 최초 완료는 현재 소유권·Lease·STARTED Attempt를 검증한다. +- [x] 완료 재생은 같은 성공 실행만 허용하고 Lease를 요구하지 않는다. +- [x] 대상은 최신 Version이고 Job Model이 active·searchable이다. +- [x] Chunk와 ACTIVE Embedding 전체 Set이 정확히 일치한다. +- [x] 최초 Version은 자기 Embedding을 STALE로 바꾸지 않는다. +- [x] 새 Version은 이전 ACTIVE Embedding을 STALE로 바꾸고 current Version이 된다. +- [x] Attempt·Job·Version·Document·Embedding·이벤트가 한 Transaction으로 커밋된다. +- [x] 같은 완료 시각이 Attempt·Job·Version·이벤트에 사용된다. +- [x] INDEXED 이벤트는 한 건만 생성된다. +- [x] Claim Token, Chunk 본문과 Vector가 외부로 노출되지 않는다. +- [x] 최초·새 Version의 실제 검색 가시성이 OpenSQL에서 검증된다. +- [x] 동시 완료와 완료 중 실패 Rollback이 실제 DB에서 검증된다. +- [x] 전체 빌드와 회귀 테스트가 통과한다. + +실행 환경, 명령, 검증 시나리오와 제외 범위는 +[문서 인덱싱 완료 구현 검증 결과](../test-results/Gimini-3-%2384-document-indexing-completion.md)에 기록한다. diff --git a/docs/test-results/Gimini-3-#84-document-indexing-completion.md b/docs/test-results/Gimini-3-#84-document-indexing-completion.md new file mode 100644 index 0000000..a9e25f9 --- /dev/null +++ b/docs/test-results/Gimini-3-#84-document-indexing-completion.md @@ -0,0 +1,100 @@ +# 문서 인덱싱 완료 구현 검증 결과 (#84) + +## 1. 검증 개요 + +- 실행일: 2026-07-31 (Asia/Seoul) +- 대상 브랜치: `feature/84` +- 데이터베이스: 격리된 OpenSQL/PostgreSQL 14 + pgvector +- Vector Schema: `vector(1024)` +- 결과: 전체 빌드와 432개 테스트 성공, 실패 0건 + +기존 개발 데이터 볼륨은 사용하거나 변경하지 않았다. 검증 전용 컨테이너와 전용 볼륨에서 Flyway +Migration 및 Seed를 적용하고, 테스트 Class별 격리 Schema를 사용했다. + +## 2. 실행 결과 + +### 2.1 Domain 전이와 완료 Service 단위 테스트 + +다음 계약의 성공·거부 경로가 통과했다. + +- `EmbeddingJob`: `PROCESSING → INDEXED` +- `EmbeddingJobAttempt`: `STARTED → SUCCESS` +- `DocumentVersion`: `EMBEDDING → INDEXED` +- `Document`: 같은 Document의 완료 Version 활성화 +- 최초 완료의 최신 Version, 전체 Embedding Set, Model, 이벤트 사전 상태 검증 +- 완료 재생의 Worker·Token·Attempt 및 저장 완료 상태 검증 +- Lease 만료와 후속 Version 활성화 뒤에도 최초 완료 결과 재생 +- 관리자 API의 요청 Validation, ADMIN 권한, 오류 응답과 민감 필드 비노출 + +실행한 대표 Test Class: + +```text +DocumentTest +DocumentVersionTest +EmbeddingJobTest +EmbeddingJobAttemptTest +DocumentIndexingCompletionServiceTest +IndexingJobAdminControllerTest +``` + +### 2.2 Repository와 OpenSQL 검색 전환 + +실제 OpenSQL에서 다음 Test Class가 통과했다. + +```text +IndexingCompletionRepositoryTest +DocumentIndexingCompletionIntegrationTest +``` + +검증한 내용: + +1. Version 전체·Model별·ACTIVE Embedding 개수 집계 +2. Vector를 JVM으로 읽지 않는 `vector_dims`·관계·Hash 불일치 집계 +3. 이전 Version ACTIVE Embedding의 STALE bulk update +4. 최초 Version 완료 전 권한 pre-filter·Vector Search 제외 +5. 최초 Version 완료 후 current ACTIVE Version 검색 노출 +6. 새 Version 완료 전 이전 본문만 검색 +7. 새 Version 완료 후 이전 STALE·새 ACTIVE 및 새 본문만 검색 +8. Attempt·Job·Version·INDEXED 이벤트의 동일 완료 시각 + +### 2.3 동시 완료와 실패 Rollback + +- 같은 Job·Attempt·Worker·Token의 두 Thread를 Barrier로 동시에 시작했다. +- 두 요청은 Job Pessimistic Lock으로 직렬화돼 같은 완료 응답으로 수렴했다. +- `INDEXED` 이벤트는 한 건만 저장됐다. +- PostgreSQL `TIMESTAMP` 정밀도에 맞춰 완료 시각을 microsecond로 고정해 최초 응답과 재생 응답을 + 동일하게 유지했다. + +Rollback 검증은 실제 OpenSQL의 테스트용 Trigger가 마지막 `INDEXED` 이벤트 Insert를 실패시키도록 +구성했다. 실패 뒤 새 조회에서 다음 원상태를 확인했다. + +- 이전 current Version Embedding: `ACTIVE` +- 대상 Version Embedding: `ACTIVE` +- 대상 Version: `EMBEDDING` +- Document current Version: 이전 Version +- Attempt: `STARTED` +- Job: `PROCESSING` +- `INDEXED` 이벤트: 0건 + +테스트용 Trigger와 Function은 검증 직후 제거했다. + +## 3. 전체 회귀 + +실행: + +```bash +./gradlew clean build +git diff --check +``` + +결과: + +```text +BUILD SUCCESSFUL +tests=432 failures=0 errors=0 +git diff --check: 통과 +``` + +Gradle 기본 `test` 설정이 제외하는 `benchmark`, `minio-integration`, `claim-concurrency` 태그는 이번 +전체 빌드 범위에도 포함되지 않았다. 이번 변경 전용 OpenSQL 검색 전환·동시 완료·Rollback 테스트는 +기본 `test` 범위에 포함되어 실행됐다. diff --git a/src/main/java/com/opensource/docgrid/domain/document/entity/Document.java b/src/main/java/com/opensource/docgrid/domain/document/entity/Document.java index f61e999..8bf6810 100644 --- a/src/main/java/com/opensource/docgrid/domain/document/entity/Document.java +++ b/src/main/java/com/opensource/docgrid/domain/document/entity/Document.java @@ -5,6 +5,7 @@ import com.opensource.docgrid.domain.document.enums.DocumentSourceType; import com.opensource.docgrid.domain.document.enums.DocumentStatus; import com.opensource.docgrid.domain.document.enums.DocumentType; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; import com.opensource.docgrid.domain.document.enums.VisibilityType; import com.opensource.docgrid.domain.user.entity.User; import com.opensource.docgrid.global.common.entity.BaseEntity; @@ -75,8 +76,8 @@ public class Document extends BaseEntity { @JoinColumn(name = "owner_user_id", nullable = false) private User owner; - // 현재 활성 버전. documents <-> document_versions 순환 FK이므로 반드시 nullable. - // 최초 버전은 업로드 접수 시 설정하며, 후속 버전은 색인 완료 후 갱신한다. + // 현재 버전 포인터. 최초 업로드 중에는 처리 대상을, 완료 후에는 검색 가능한 최신 Version을 가리킨다. + // documents <-> document_versions 순환 FK이므로 반드시 nullable이다. @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "current_version_id") private DocumentVersion currentVersion; @@ -125,6 +126,34 @@ public void updateCurrentVersion(DocumentVersion currentVersion) { this.currentVersion = currentVersion; } + /** + * 같은 문서에 속하고 인덱싱을 마친 Version을 현재 검색 대상으로 활성화한다. + * + *

업로드 접수 단계의 포인터 설정은 {@link #updateCurrentVersion(DocumentVersion)}이 담당하고, + * 이 메서드는 완료 Transaction 경계에서 Version 포인터와 문서 상태를 함께 변경한다. + * + * @param documentVersion 새 검색 대상이 될 완료 Version + */ + public void activateIndexedVersion(DocumentVersion documentVersion) { + // 1. 영속 식별자를 기준으로 다른 문서의 Version이 연결되는 것을 차단한다. + if (id == null + || documentVersion == null + || documentVersion.getDocument() == null + || documentVersion.getDocument().getId() == null + || !id.equals(documentVersion.getDocument().getId())) { + throw new IllegalArgumentException("현재 문서에 속한 Version만 활성화할 수 있습니다."); + } + + // 2. 검색 준비가 끝난 Version만 현재 포인터로 승격한다. + if (documentVersion.getStatus() != DocumentVersionStatus.INDEXED) { + throw new IllegalStateException("INDEXED 상태의 문서 버전만 활성화할 수 있습니다."); + } + + // 3. 포인터와 문서 상태를 함께 변경해 검색 조건이 중간 상태를 관찰하지 않게 한다. + this.currentVersion = documentVersion; + this.status = DocumentStatus.INDEXED; + } + public void markIndexing() { this.status = DocumentStatus.INDEXING; } 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 e416c5d..83765e4 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 @@ -142,7 +142,14 @@ public void markEmbedding() { this.status = DocumentVersionStatus.EMBEDDING; } + /** + * Embedding Set이 완성된 Version을 검색 가능한 완료 상태로 전환한다. + */ public void markIndexed(LocalDateTime indexedAt) { + // Embedding 저장 단계를 거치지 않은 Version이 검색 대상으로 노출되지 않도록 전이를 제한한다. + if (status != DocumentVersionStatus.EMBEDDING) { + throw new IllegalStateException("EMBEDDING 상태의 문서 버전만 INDEXED로 전환할 수 있습니다."); + } this.status = DocumentVersionStatus.INDEXED; this.indexedAt = indexedAt; } 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 413bf61..9af1b64 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 @@ -12,20 +12,23 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import com.opensource.docgrid.domain.embedding.dto.request.StartEmbeddingJobAttemptRequest; +import com.opensource.docgrid.domain.embedding.dto.request.CompleteDocumentIndexingRequest; import com.opensource.docgrid.domain.embedding.dto.request.CreateDocumentChunksRequest; import com.opensource.docgrid.domain.embedding.dto.request.CreateDocumentEmbeddingsRequest; +import com.opensource.docgrid.domain.embedding.dto.request.StartEmbeddingJobAttemptRequest; import com.opensource.docgrid.domain.embedding.dto.response.ClaimedEmbeddingJobResponse; import com.opensource.docgrid.domain.embedding.dto.response.DocumentChunksResponse; import com.opensource.docgrid.domain.embedding.dto.response.DocumentEmbeddingsResponse; +import com.opensource.docgrid.domain.embedding.dto.response.DocumentIndexingCompletionResponse; import com.opensource.docgrid.domain.embedding.dto.response.StartedEmbeddingJobAttemptResponse; import com.opensource.docgrid.domain.document.service.DocumentParsingService; import com.opensource.docgrid.domain.document.service.command.DocumentChunkTransactionService.ChunkResult; import com.opensource.docgrid.domain.embedding.service.DocumentEmbeddingService; import com.opensource.docgrid.domain.embedding.service.DocumentEmbeddingService.EmbeddingResult; -import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobClaimService; +import com.opensource.docgrid.domain.embedding.service.command.DocumentIndexingCompletionService; import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobAttemptService; import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobAttemptService.StartResult; +import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobClaimService; import com.opensource.docgrid.global.common.response.ApiResponse; import com.opensource.docgrid.global.common.response.ErrorResponse; import com.opensource.docgrid.global.common.response.ResponseUtils; @@ -40,7 +43,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에 위임한다. @@ -56,6 +59,7 @@ public class IndexingJobAdminController { private final EmbeddingJobAttemptService embeddingJobAttemptService; private final DocumentParsingService documentParsingService; private final DocumentEmbeddingService documentEmbeddingService; + private final DocumentIndexingCompletionService documentIndexingCompletionService; @Operation( summary = "PENDING Job Claim", @@ -298,4 +302,57 @@ public ResponseEntity> createEmbeddings( } return ResponseUtils.ok(result.response()); } + + @Operation( + summary = "Document 인덱싱 완료", + description = "현재 PROCESSING Job의 소유권과 Attempt, 최신 Version 및 전체 ACTIVE Embedding Set을 " + + "검증한 뒤 Version과 Document를 검색 가능한 INDEXED 상태로 확정합니다. " + + "같은 완료 실행의 재요청은 저장된 최초 결과를 멱등 재생합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "Document 인덱싱 최초 완료 또는 기존 완료 결과 재생" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "Job ID, Attempt ID, Worker ID 또는 Claim Token 형식 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "인증되지 않았거나 ADMIN 권한 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "Embedding Job 없음", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "409", + description = "현재 소유권, Attempt, Lease 또는 최신 Version 상태 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "Model, Chunk, Embedding, 완료 시각 또는 이벤트 데이터 불일치", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + @PostMapping( + value = "/{jobId}/attempts/{attemptId}/complete", + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE + ) + public ResponseEntity> completeIndexing( + @PathVariable @Positive Long jobId, + @PathVariable @Positive Long attemptId, + @Valid @RequestBody CompleteDocumentIndexingRequest request + ) { + // 최초 완료와 멱등 재생 모두 같은 안정적인 완료 응답을 200 OK로 반환한다. + return ResponseUtils.ok( + documentIndexingCompletionService.complete(jobId, attemptId, request) + ); + } } diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/dto/request/CompleteDocumentIndexingRequest.java b/src/main/java/com/opensource/docgrid/domain/embedding/dto/request/CompleteDocumentIndexingRequest.java new file mode 100644 index 0000000..f5f66bb --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/dto/request/CompleteDocumentIndexingRequest.java @@ -0,0 +1,31 @@ +package com.opensource.docgrid.domain.embedding.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +/** + * 현재 Embedding Job Attempt의 소유권으로 문서 인덱싱 완료를 확정하는 요청 DTO. + * + *

Worker ID와 Claim Token은 최초 완료 및 멱등 재생의 실행 식별에만 사용하며 응답에는 노출하지 않는다. + */ +public record CompleteDocumentIndexingRequest( + @Schema(description = "현재 Job을 소유한 Worker 식별자", example = "7") + @NotNull + @Positive + Long workerId, + + @Schema(description = "현재 Claim의 canonical UUID Token", + example = "34c19d16-6ae1-4f6a-a35d-0123456789ab") + @NotBlank + @Size(max = 36) + @Pattern( + regexp = "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + message = "canonical UUID 형식이어야 합니다." + ) + String claimToken +) { +} diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/DocumentIndexingCompletionResponse.java b/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/DocumentIndexingCompletionResponse.java new file mode 100644 index 0000000..3e563c3 --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/dto/response/DocumentIndexingCompletionResponse.java @@ -0,0 +1,48 @@ +package com.opensource.docgrid.domain.embedding.dto.response; + +import java.time.LocalDateTime; + +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.worker.enums.AttemptStatus; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 최초 완료 또는 멱등 재생된 문서 인덱싱 결과의 안정적인 실행·대상·시각 정보를 전달한다. + * + *

이후 새 Version이 활성화돼도 바뀌지 않는 완료 실행 정보만 반환하며 Claim Token, Vector와 + * 현재 Document 포인터 같은 가변 내부 상태는 노출하지 않는다. + */ +public record DocumentIndexingCompletionResponse( + @Schema(description = "완료된 Embedding Job 식별자", example = "41") + Long jobId, + + @Schema(description = "완료된 Attempt 식별자", example = "103") + Long attemptId, + + @Schema(description = "인덱싱된 Document 식별자", example = "10") + Long documentId, + + @Schema(description = "인덱싱된 Document Version 식별자", example = "22") + Long documentVersionId, + + @Schema(description = "Job에 고정된 Embedding Model 식별자", example = "1") + Long embeddingModelId, + + @Schema(description = "완료된 Job 상태", example = "INDEXED") + EmbeddingJobStatus jobStatus, + + @Schema(description = "완료된 Attempt 상태", example = "SUCCESS") + AttemptStatus attemptStatus, + + @Schema(description = "완료된 Version 상태", example = "INDEXED") + DocumentVersionStatus versionStatus, + + @Schema(description = "최초 완료 시각", example = "2026-07-31T16:00:00") + LocalDateTime completedAt, + + @Schema(description = "Attempt 시작부터 완료까지 걸린 시간(ms)", example = "8421") + long durationMs +) { +} 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 c115030..e8ddf27 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 @@ -157,7 +157,16 @@ public void claim(WorkerNode workerNode, String claimToken, LocalDateTime claime } } + /** + * 현재 처리 중인 Job을 최종 인덱싱 완료 상태로 전환한다. + * + *

Claim 소유권 정보는 완료 재생과 감사에 사용하므로 완료 후에도 보존한다. + */ public void markIndexed(LocalDateTime completedAt) { + // 완료 Transaction만 PROCESSING Job을 종결할 수 있어야 늦은 요청이 결과를 덮어쓰지 않는다. + if (status != EmbeddingJobStatus.PROCESSING) { + throw new IllegalStateException("PROCESSING 상태의 Job만 INDEXED로 전환할 수 있습니다."); + } this.status = EmbeddingJobStatus.INDEXED; this.completedAt = completedAt; } 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 fc45cf9..2b27174 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.util.Collection; import java.util.Optional; import jakarta.persistence.LockModeType; @@ -10,6 +11,7 @@ import org.springframework.data.repository.query.Param; import com.opensource.docgrid.domain.embedding.entity.EmbeddingJob; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; /** * Embedding Job Queue의 영속성과 Claim 후보 행 잠금을 담당하는 Repository. @@ -19,6 +21,14 @@ */ public interface EmbeddingJobRepository extends JpaRepository { + /** + * 같은 Version에 동시에 살아 있는 Job이 하나뿐인지 완료 직전에 확인한다. + */ + long countByDocumentVersionIdAndStatusIn( + Long documentVersionId, + Collection statuses + ); + /** * 우선순위 Queue 정책에 따라 다음 PENDING Job 한 건을 잠금 상태로 조회한다. * 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 6d4e4f6..fc5d60b 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 @@ -1,19 +1,81 @@ package com.opensource.docgrid.domain.embedding.repository; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.opensource.docgrid.domain.embedding.entity.Embedding; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingStatus; /** - * 문서 Chunk Embedding Set의 영속화와 Version·Model 단위 저장 개수 조회를 담당한다. + * 문서 Chunk Embedding Set의 영속화와 인덱싱 완료 검증·상태 전환을 담당한다. * - *

Embedding 생성 Transaction은 Job에 고정된 Model 범위의 저장 개수로 최초 실행, 재개, - * 완료 재생과 부분 저장 모순을 구분하고 신규 Set은 한 Transaction에서 전체 저장한다. + *

생성 Transaction은 Version·Model 단위 저장 개수로 부분 저장을 구분한다. 완료 Transaction은 + * Vector를 Java Heap으로 역직렬화하지 않고 DB 집계로 관계·차원·Hash 불변식을 검증하고, + * 이전 현재 Version의 ACTIVE Set을 STALE로 일괄 전환한다. */ public interface EmbeddingRepository extends JpaRepository { + long countByDocumentVersionId(Long documentVersionId); + long countByDocumentVersionIdAndEmbeddingModelId( Long documentVersionId, Long embeddingModelId ); + + long countByDocumentVersionIdAndEmbeddingModelIdAndStatus( + Long documentVersionId, + Long embeddingModelId, + EmbeddingStatus status + ); + + /** + * 이전 현재 Version의 검색 가능한 Embedding을 한 SQL로 비활성화한다. + * + * @return 실제 STALE로 변경된 행 수 + */ + @Modifying(flushAutomatically = true) + @Query(value = """ + UPDATE embeddings + SET status = 'STALE', + updated_at = CURRENT_TIMESTAMP + WHERE document_version_id = :documentVersionId + AND status = 'ACTIVE' + """, nativeQuery = true) + int markActiveAsStaleByDocumentVersionId( + @Param("documentVersionId") Long documentVersionId + ); + + /** + * 대상 Chunk Set과 연결됐거나 대상 Version으로 역정규화된 Model 행 중 완료 불변식 위반 수를 계산한다. + * + *

양쪽 범위를 함께 조회해야 잘못된 역정규화 Version ID로 누락된 행과 다른 Chunk를 대상 Version으로 + * 잘못 표시한 행을 모두 잡을 수 있다. + */ + @Query(value = """ + SELECT COUNT(*) + FROM embeddings embedding + JOIN document_chunks chunk ON chunk.id = embedding.chunk_id + WHERE embedding.embedding_model_id = :embeddingModelId + AND ( + embedding.document_version_id = :documentVersionId + OR chunk.document_version_id = :documentVersionId + ) + AND ( + embedding.document_id <> :documentId + OR embedding.document_version_id <> :documentVersionId + OR chunk.document_version_id <> :documentVersionId + OR embedding.dimension <> :dimension + OR vector_dims(embedding.vector) <> :dimension + OR embedding.vector_hash IS NULL + OR embedding.vector_hash !~ '^[0-9a-f]{64}$' + ) + """, nativeQuery = true) + long countInvalidCompletionRows( + @Param("documentId") Long documentId, + @Param("documentVersionId") Long documentVersionId, + @Param("embeddingModelId") Long embeddingModelId, + @Param("dimension") int dimension + ); } diff --git a/src/main/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingCompletionService.java b/src/main/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingCompletionService.java new file mode 100644 index 0000000..b01f92c --- /dev/null +++ b/src/main/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingCompletionService.java @@ -0,0 +1,505 @@ +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.EnumSet; +import java.util.Objects; +import java.util.Set; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +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.DocumentChunkRepository; +import com.opensource.docgrid.domain.document.repository.DocumentRepository; +import com.opensource.docgrid.domain.document.repository.DocumentVersionRepository; +import com.opensource.docgrid.domain.embedding.dto.request.CompleteDocumentIndexingRequest; +import com.opensource.docgrid.domain.embedding.dto.response.DocumentIndexingCompletionResponse; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingJob; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingStatus; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingJobRepository; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingRepository; +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; + +/** + * 완성된 Embedding Set을 검증하고 문서 Version을 검색 가능한 현재 Version으로 확정한다. + * + *

외부 I/O 없이 하나의 짧은 Transaction에서 Job → Version → Document 순서로 잠근다. + * 모든 개수·관계·상태 불변식이 확인된 뒤 이전 Embedding 비활성화, 상태 전이와 INDEXED 이벤트를 + * 함께 커밋하므로 검색 요청은 완료 전이나 완료 후의 일관된 상태만 관찰한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class DocumentIndexingCompletionService { + + private static final String INDEXED_MESSAGE = "Document Version 인덱싱을 완료했습니다."; + private static final Set ACTIVE_JOB_STATUSES = EnumSet.of( + EmbeddingJobStatus.PENDING, + EmbeddingJobStatus.PROCESSING + ); + private static final Set COMPLETABLE_DOCUMENT_STATUSES = EnumSet.of( + DocumentStatus.UPLOADED, + DocumentStatus.INDEXING, + DocumentStatus.INDEXED + ); + + private final EmbeddingJobRepository embeddingJobRepository; + private final EmbeddingJobAttemptRepository embeddingJobAttemptRepository; + private final DocumentVersionRepository documentVersionRepository; + private final DocumentRepository documentRepository; + private final DocumentChunkRepository documentChunkRepository; + private final EmbeddingRepository embeddingRepository; + private final IndexingEventRepository indexingEventRepository; + private final EmbeddingJobOwnershipValidator ownershipValidator; + private final Clock clock; + + /** + * 현재 Claim 실행이 생성한 전체 Embedding Set을 문서의 검색 가능 상태로 확정한다. + */ + public DocumentIndexingCompletionResponse complete( + Long jobId, + Long attemptId, + CompleteDocumentIndexingRequest request + ) { + // 1. Claim 교체와 같은 Job의 중복 완료를 직렬화하고 완료 기준 시각을 고정한다. + EmbeddingJob embeddingJob = findLockedJob(jobId); + // PostgreSQL TIMESTAMP 정밀도와 맞춰 최초 응답과 DB에서 읽은 재생 응답의 시각을 동일하게 유지한다. + LocalDateTime completedAt = LocalDateTime.now(clock).truncatedTo(ChronoUnit.MICROS); + + // 2. 이미 완료된 같은 실행은 저장된 최초 결과를 재생하고 Lease와 가변 검색 상태는 다시 검증하지 않는다. + if (embeddingJob.getStatus() == EmbeddingJobStatus.INDEXED) { + return replayIndexedJob(embeddingJob, attemptId, request); + } + if (embeddingJob.getStatus() != EmbeddingJobStatus.PROCESSING) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_COMPLETION_NOT_ALLOWED); + } + + // 3. 소유권과 Attempt 실행 Context를 검증한 뒤 Version과 Document를 정해진 순서로 잠근다. + ownershipValidator.validate( + embeddingJob, + request.workerId(), + request.claimToken(), + completedAt + ); + EmbeddingJobAttempt attempt = resolveStartedAttempt( + embeddingJob, + attemptId, + request.workerId(), + request.claimToken(), + completedAt + ); + DocumentVersion documentVersion = findLockedVersion(embeddingJob); + Document document = findLockedDocument(documentVersion); + EmbeddingModel embeddingModel = validateModel(embeddingJob); + + // 4. 최신 대상과 전체 Embedding Set 및 완료 이벤트 사전 상태를 변경 전에 모두 검증한다. + validateCompletionTarget(embeddingJob, documentVersion, document); + CompletionCounts counts = validateEmbeddingSet( + embeddingJob, + documentVersion, + document, + embeddingModel + ); + validateNoIndexedEvent(embeddingJob); + + // 5. 이전 검색 Set을 비활성화한 뒤 모든 완료 상태와 이벤트를 같은 시각으로 기록한다. + stalePreviousEmbeddings(document, documentVersion); + long durationMs = Duration.between(attempt.getStartedAt(), completedAt).toMillis(); + transitionAndRecordEvent( + embeddingJob, + attempt, + documentVersion, + document, + completedAt, + durationMs + ); + + log.info( + "문서 인덱싱 완료: jobId={}, attemptId={}, documentId={}, versionId={}, " + + "chunkCount={}, embeddingCount={}, durationMs={}", + embeddingJob.getId(), + attempt.getId(), + document.getId(), + documentVersion.getId(), + counts.chunkCount(), + counts.embeddingCount(), + durationMs + ); + return response( + embeddingJob, + attempt, + documentVersion, + document, + embeddingModel, + completedAt, + durationMs + ); + } + + private DocumentIndexingCompletionResponse replayIndexedJob( + EmbeddingJob embeddingJob, + Long attemptId, + CompleteDocumentIndexingRequest request + ) { + // 1. 완료 시 보존한 Worker와 Token이 같은 실행의 재요청인지 확인한다. + validateCompletedIdentity(embeddingJob, request.workerId(), request.claimToken()); + EmbeddingJobAttempt attempt = resolveCompletedAttempt( + embeddingJob, + attemptId, + request.workerId(), + request.claimToken() + ); + + // 2. 최초 완료와 같은 Job → Version → Document 잠금 순서를 유지하되 최신 Version 여부는 요구하지 않는다. + DocumentVersion documentVersion = findLockedVersion(embeddingJob); + Document document = findLockedDocument(documentVersion); + EmbeddingModel embeddingModel = findCompletedModel(embeddingJob); + + // 3. 최초 완료의 저장 시각·상태·단일 이벤트가 온전한지 검증하고 어떠한 값도 다시 계산하지 않는다. + validateCompletedState( + embeddingJob, + attempt, + documentVersion, + document + ); + log.info( + "문서 인덱싱 완료 재생: jobId={}, attemptId={}, completedAt={}, replay=true", + embeddingJob.getId(), + attempt.getId(), + embeddingJob.getCompletedAt() + ); + return response( + embeddingJob, + attempt, + documentVersion, + document, + embeddingModel, + embeddingJob.getCompletedAt(), + attempt.getDurationMs() + ); + } + + private EmbeddingJob findLockedJob(Long jobId) { + return embeddingJobRepository.findByIdForUpdate(jobId) + .orElseThrow(() -> new DocGridException(ErrorCode.EMBEDDING_JOB_NOT_FOUND)); + } + + private EmbeddingJobAttempt resolveStartedAttempt( + EmbeddingJob embeddingJob, + Long attemptId, + Long workerId, + String claimToken, + LocalDateTime completedAt + ) { + EmbeddingJobAttempt attempt = embeddingJobAttemptRepository + .findByEmbeddingJobIdAndClaimToken(embeddingJob.getId(), claimToken) + .orElseThrow(() -> new DocGridException(ErrorCode.EMBEDDING_JOB_ATTEMPT_INVALID)); + + if (!Objects.equals(attempt.getId(), attemptId) + || attempt.getEmbeddingJob() == null + || !Objects.equals(attempt.getEmbeddingJob().getId(), embeddingJob.getId()) + || attempt.getWorkerNode() == null + || !Objects.equals(attempt.getWorkerNode().getId(), workerId) + || attempt.getStatus() != AttemptStatus.STARTED + || attempt.getStartedAt() == null) { + throw new DocGridException(ErrorCode.EMBEDDING_JOB_ATTEMPT_INVALID); + } + if (attempt.getStartedAt().isAfter(completedAt)) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_COMPLETION_INCONSISTENT); + } + return attempt; + } + + private DocumentVersion findLockedVersion(EmbeddingJob embeddingJob) { + if (embeddingJob.getDocumentVersion() == null + || embeddingJob.getDocumentVersion().getId() == null) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_COMPLETION_INCONSISTENT); + } + return documentVersionRepository.findByIdForUpdate(embeddingJob.getDocumentVersion().getId()) + .orElseThrow(() -> + new DocGridException(ErrorCode.DOCUMENT_INDEXING_COMPLETION_INCONSISTENT)); + } + + private Document findLockedDocument(DocumentVersion documentVersion) { + if (documentVersion.getDocument() == null + || documentVersion.getDocument().getId() == null) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_COMPLETION_INCONSISTENT); + } + return documentRepository.findByIdForUpdate(documentVersion.getDocument().getId()) + .orElseThrow(() -> + new DocGridException(ErrorCode.DOCUMENT_INDEXING_COMPLETION_INCONSISTENT)); + } + + private EmbeddingModel validateModel(EmbeddingJob embeddingJob) { + EmbeddingModel embeddingModel = embeddingJob.getEmbeddingModel(); + if (embeddingModel == null + || embeddingModel.getId() == null + || embeddingModel.getDimension() <= 0 + || !embeddingModel.isActive() + || !embeddingModel.isSearchable()) { + throw new DocGridException(ErrorCode.EMBEDDING_MODEL_NOT_CONFIGURED); + } + return embeddingModel; + } + + private EmbeddingModel findCompletedModel(EmbeddingJob embeddingJob) { + EmbeddingModel embeddingModel = embeddingJob.getEmbeddingModel(); + if (embeddingModel == null || embeddingModel.getId() == null) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_COMPLETION_INCONSISTENT); + } + return embeddingModel; + } + + private void validateCompletedIdentity( + EmbeddingJob embeddingJob, + Long workerId, + String claimToken + ) { + if (embeddingJob.getLockedByWorker() == null + || embeddingJob.getLockedByWorker().getId() == null + || !StringUtils.hasText(embeddingJob.getClaimToken())) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_COMPLETION_INCONSISTENT); + } + if (!Objects.equals(embeddingJob.getLockedByWorker().getId(), workerId) + || !Objects.equals(embeddingJob.getClaimToken(), claimToken)) { + throw new DocGridException(ErrorCode.EMBEDDING_JOB_OWNERSHIP_INVALID); + } + } + + private EmbeddingJobAttempt resolveCompletedAttempt( + EmbeddingJob embeddingJob, + Long attemptId, + Long workerId, + String claimToken + ) { + EmbeddingJobAttempt attempt = embeddingJobAttemptRepository + .findByEmbeddingJobIdAndClaimToken(embeddingJob.getId(), claimToken) + .orElseThrow(() -> new DocGridException(ErrorCode.EMBEDDING_JOB_ATTEMPT_INVALID)); + if (!Objects.equals(attempt.getId(), attemptId) + || attempt.getEmbeddingJob() == null + || !Objects.equals(attempt.getEmbeddingJob().getId(), embeddingJob.getId()) + || attempt.getWorkerNode() == null + || !Objects.equals(attempt.getWorkerNode().getId(), workerId) + || !Objects.equals(attempt.getClaimToken(), claimToken) + || attempt.getStatus() != AttemptStatus.SUCCESS) { + throw new DocGridException(ErrorCode.EMBEDDING_JOB_ATTEMPT_INVALID); + } + return attempt; + } + + private void validateCompletedState( + EmbeddingJob embeddingJob, + EmbeddingJobAttempt attempt, + DocumentVersion documentVersion, + Document document + ) { + if (embeddingJob.getCompletedAt() == null + || attempt.getStartedAt() == null + || attempt.getEndedAt() == null + || attempt.getDurationMs() == null + || attempt.getDurationMs() < 0 + || documentVersion.getStatus() != DocumentVersionStatus.INDEXED + || documentVersion.getIndexedAt() == null + || documentVersion.getDocument() == null + || !Objects.equals(documentVersion.getDocument().getId(), document.getId()) + || !Objects.equals(embeddingJob.getCompletedAt(), attempt.getEndedAt()) + || !Objects.equals(embeddingJob.getCompletedAt(), documentVersion.getIndexedAt()) + || Duration.between(attempt.getStartedAt(), attempt.getEndedAt()).toMillis() + != attempt.getDurationMs() + || indexingEventRepository.countByEmbeddingJobIdAndEventType( + embeddingJob.getId(), + IndexingEventType.INDEXED + ) != 1) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_COMPLETION_INCONSISTENT); + } + } + + private void validateCompletionTarget( + EmbeddingJob embeddingJob, + DocumentVersion documentVersion, + Document document + ) { + if (documentVersion.getStatus() != DocumentVersionStatus.EMBEDDING) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_COMPLETION_NOT_ALLOWED); + } + if (!Objects.equals(embeddingJob.getDocumentVersion().getId(), documentVersion.getId()) + || !Objects.equals(documentVersion.getDocument().getId(), document.getId())) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_COMPLETION_INCONSISTENT); + } + if (!COMPLETABLE_DOCUMENT_STATUSES.contains(document.getStatus()) + || document.getDeletedAt() != null) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_COMPLETION_NOT_ALLOWED); + } + + 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_COMPLETION_INCONSISTENT); + } + if (currentVersion.getVersionNo() > documentVersion.getVersionNo()) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_STALE_COMPLETION); + } + + DocumentVersion latestVersion = documentVersionRepository + .findTopByDocumentIdOrderByVersionNoDesc(document.getId()) + .orElseThrow(() -> + new DocGridException(ErrorCode.DOCUMENT_INDEXING_COMPLETION_INCONSISTENT)); + if (!Objects.equals(latestVersion.getId(), documentVersion.getId())) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_STALE_COMPLETION); + } + } + + private CompletionCounts validateEmbeddingSet( + EmbeddingJob embeddingJob, + DocumentVersion documentVersion, + Document document, + EmbeddingModel embeddingModel + ) { + long activeJobCount = embeddingJobRepository.countByDocumentVersionIdAndStatusIn( + documentVersion.getId(), + ACTIVE_JOB_STATUSES + ); + long chunkCount = documentChunkRepository.countByDocumentVersionId(documentVersion.getId()); + long allEmbeddingCount = embeddingRepository.countByDocumentVersionId(documentVersion.getId()); + long modelEmbeddingCount = + embeddingRepository.countByDocumentVersionIdAndEmbeddingModelId( + documentVersion.getId(), + embeddingModel.getId() + ); + long activeEmbeddingCount = + embeddingRepository.countByDocumentVersionIdAndEmbeddingModelIdAndStatus( + documentVersion.getId(), + embeddingModel.getId(), + EmbeddingStatus.ACTIVE + ); + long invalidEmbeddingCount = embeddingRepository.countInvalidCompletionRows( + document.getId(), + documentVersion.getId(), + embeddingModel.getId(), + embeddingModel.getDimension() + ); + + if (activeJobCount != 1 + || chunkCount <= 0 + || allEmbeddingCount != chunkCount + || modelEmbeddingCount != chunkCount + || activeEmbeddingCount != chunkCount + || invalidEmbeddingCount != 0) { + log.error( + "문서 인덱싱 완료 데이터 불일치: jobId={}, documentId={}, versionId={}, " + + "activeJobCount={}, chunkCount={}, allEmbeddingCount={}, modelEmbeddingCount={}, " + + "activeEmbeddingCount={}, invalidEmbeddingCount={}", + embeddingJob.getId(), + document.getId(), + documentVersion.getId(), + activeJobCount, + chunkCount, + allEmbeddingCount, + modelEmbeddingCount, + activeEmbeddingCount, + invalidEmbeddingCount + ); + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_COMPLETION_INCONSISTENT); + } + return new CompletionCounts(chunkCount, modelEmbeddingCount); + } + + private void validateNoIndexedEvent(EmbeddingJob embeddingJob) { + if (indexingEventRepository.countByEmbeddingJobIdAndEventType( + embeddingJob.getId(), + IndexingEventType.INDEXED + ) != 0) { + throw new DocGridException(ErrorCode.DOCUMENT_INDEXING_COMPLETION_INCONSISTENT); + } + } + + private void stalePreviousEmbeddings( + Document document, + DocumentVersion documentVersion + ) { + DocumentVersion previousCurrentVersion = document.getCurrentVersion(); + if (!Objects.equals(previousCurrentVersion.getId(), documentVersion.getId())) { + embeddingRepository.markActiveAsStaleByDocumentVersionId( + previousCurrentVersion.getId() + ); + } + } + + private void transitionAndRecordEvent( + EmbeddingJob embeddingJob, + EmbeddingJobAttempt attempt, + DocumentVersion documentVersion, + Document document, + LocalDateTime completedAt, + long durationMs + ) { + // 1. 새 검색 대상을 완성한 뒤 Document 포인터가 INDEXED Version만 가리키게 한다. + documentVersion.markIndexed(completedAt); + document.activateIndexedVersion(documentVersion); + + // 2. 실행 이력과 Queue 상태를 같은 완료 시각으로 종결한다. + attempt.markSuccess(completedAt, durationMs); + embeddingJob.markIndexed(completedAt); + + // 3. 완료 전이와 동일 Transaction에서 단일 INDEXED 이벤트를 append한다. + indexingEventRepository.save(IndexingEvent.builder() + .embeddingJob(embeddingJob) + .eventType(IndexingEventType.INDEXED) + .fromStatus(DocumentVersionStatus.EMBEDDING.name()) + .toStatus(DocumentVersionStatus.INDEXED.name()) + .message(INDEXED_MESSAGE) + .occurredAt(completedAt) + .build()); + } + + private DocumentIndexingCompletionResponse response( + EmbeddingJob embeddingJob, + EmbeddingJobAttempt attempt, + DocumentVersion documentVersion, + Document document, + EmbeddingModel embeddingModel, + LocalDateTime completedAt, + long durationMs + ) { + return new DocumentIndexingCompletionResponse( + embeddingJob.getId(), + attempt.getId(), + document.getId(), + documentVersion.getId(), + embeddingModel.getId(), + embeddingJob.getStatus(), + attempt.getStatus(), + documentVersion.getStatus(), + completedAt, + durationMs + ); + } + + /** + * 완료 전 검증된 Chunk와 Embedding 집계 결과를 로그와 후속 응답 처리에 전달한다. + */ + private record CompletionCounts(long chunkCount, long embeddingCount) { + } +} 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 cc14d3c..e42844c 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 @@ -116,7 +116,14 @@ public EmbeddingJobAttempt(EmbeddingJob embeddingJob, WorkerNode workerNode, int this.errorMessage = errorMessage; } + /** + * 실행 중인 Attempt를 성공 상태로 종결한다. + */ public void markSuccess(LocalDateTime endedAt, Long durationMs) { + // 한 Attempt가 두 번 종결되면 완료 재생의 기준 시각과 소요 시간이 변하므로 차단한다. + if (status != AttemptStatus.STARTED) { + throw new IllegalStateException("STARTED 상태의 Attempt만 SUCCESS로 전환할 수 있습니다."); + } this.status = AttemptStatus.SUCCESS; this.endedAt = endedAt; this.durationMs = durationMs; diff --git a/src/main/java/com/opensource/docgrid/domain/worker/repository/IndexingEventRepository.java b/src/main/java/com/opensource/docgrid/domain/worker/repository/IndexingEventRepository.java index f4b2f5e..46ba1c2 100644 --- a/src/main/java/com/opensource/docgrid/domain/worker/repository/IndexingEventRepository.java +++ b/src/main/java/com/opensource/docgrid/domain/worker/repository/IndexingEventRepository.java @@ -3,12 +3,15 @@ import org.springframework.data.jpa.repository.JpaRepository; import com.opensource.docgrid.domain.worker.entity.IndexingEvent; +import com.opensource.docgrid.domain.worker.enums.IndexingEventType; /** - * Embedding Job의 인덱싱 상태 변경 이벤트를 append-only 방식으로 저장하는 Repository. + * Embedding Job의 인덱싱 상태 변경 이벤트를 append-only 방식으로 저장하고 완료 이벤트 수를 검증하는 Repository. * *

Job Claim에서는 PROCESSING 전환과 같은 Transaction 안에 LOCKED 이벤트를 남겨 상태 변경 원인을 * 추적할 수 있게 한다. */ public interface IndexingEventRepository extends JpaRepository { + + long countByEmbeddingJobIdAndEventType(Long embeddingJobId, IndexingEventType eventType); } 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 f43ccbc..5987e1f 100644 --- a/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java +++ b/src/main/java/com/opensource/docgrid/global/exception/ErrorCode.java @@ -127,6 +127,21 @@ public enum ErrorCode { "DOCUMENT-EMBEDDING-002", "생성된 Embedding Vector가 올바르지 않습니다." ), + DOCUMENT_INDEXING_COMPLETION_NOT_ALLOWED( + HttpStatus.CONFLICT, + "DOCUMENT-INDEXING-001", + "현재 상태에서는 문서 인덱싱을 완료할 수 없습니다." + ), + DOCUMENT_INDEXING_STALE_COMPLETION( + HttpStatus.CONFLICT, + "DOCUMENT-INDEXING-002", + "최신 문서 버전이 아니므로 인덱싱을 완료할 수 없습니다." + ), + DOCUMENT_INDEXING_COMPLETION_INCONSISTENT( + HttpStatus.INTERNAL_SERVER_ERROR, + "DOCUMENT-INDEXING-003", + "문서 인덱싱 완료 데이터를 확인할 수 없습니다." + ), // PERMISSION INVALID_TARGET_TYPE(HttpStatus.BAD_REQUEST, "PERMISSION-001", "target_type과 ID 필드 조합이 올바르지 않습니다."), diff --git a/src/test/java/com/opensource/docgrid/domain/document/entity/DocumentTest.java b/src/test/java/com/opensource/docgrid/domain/document/entity/DocumentTest.java new file mode 100644 index 0000000..3cc834f --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/document/entity/DocumentTest.java @@ -0,0 +1,76 @@ +package com.opensource.docgrid.domain.document.entity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import com.opensource.docgrid.domain.document.enums.DocumentStatus; +import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; + +/** + * Document가 인덱싱 완료 Version만 같은 문서의 현재 검색 대상으로 활성화하는지 검증한다. + */ +@DisplayName("Document 테스트") +class DocumentTest { + + private static final Long DOCUMENT_ID = 1L; + + @Test + @DisplayName("같은 문서의 INDEXED Version을 현재 검색 대상으로 활성화한다") + void activateIndexedVersion_updatesCurrentVersionAndStatus() { + Document document = document(DOCUMENT_ID); + DocumentVersion version = version(document, DocumentVersionStatus.EMBEDDING); + version.markIndexed(java.time.LocalDateTime.of(2026, 7, 31, 12, 0)); + + document.activateIndexedVersion(version); + + assertThat(document.getCurrentVersion()).isSameAs(version); + assertThat(document.getStatus()).isEqualTo(DocumentStatus.INDEXED); + } + + @Test + @DisplayName("다른 문서의 Version은 활성화할 수 없다") + void activateIndexedVersion_rejectsForeignVersion() { + Document document = document(DOCUMENT_ID); + Document otherDocument = document(2L); + DocumentVersion version = version(otherDocument, DocumentVersionStatus.INDEXED); + + assertThatThrownBy(() -> document.activateIndexedVersion(version)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("현재 문서에 속한 Version만 활성화할 수 있습니다."); + assertThat(document.getCurrentVersion()).isNull(); + } + + @Test + @DisplayName("INDEXED가 아닌 Version은 활성화할 수 없다") + void activateIndexedVersion_rejectsIncompleteVersion() { + Document document = document(DOCUMENT_ID); + DocumentVersion version = version(document, DocumentVersionStatus.EMBEDDING); + + assertThatThrownBy(() -> document.activateIndexedVersion(version)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("INDEXED 상태의 문서 버전만 활성화할 수 있습니다."); + assertThat(document.getCurrentVersion()).isNull(); + assertThat(document.getStatus()).isEqualTo(DocumentStatus.INDEXING); + } + + private Document document(Long id) { + Document document = Document.builder() + .title("문서") + .status(DocumentStatus.INDEXING) + .build(); + ReflectionTestUtils.setField(document, "id", id); + return document; + } + + private DocumentVersion version(Document document, DocumentVersionStatus status) { + return DocumentVersion.builder() + .document(document) + .versionNo(1) + .status(status) + .build(); + } +} 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 67b77a4..dca49fb 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,7 @@ import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus; /** - * Document Version 파이프라인의 PARSING·CHUNKED·EMBEDDING 상태 전이 Guard를 검증한다. + * Document Version 파이프라인의 PARSING·CHUNKED·EMBEDDING·INDEXED 상태 전이 Guard를 검증한다. * *

Command Service를 우회한 잘못된 상태 변경은 즉시 실패하고 정상 순서만 허용되는지 확인한다. */ @@ -76,20 +76,27 @@ void markEmbedding_rejectsUnexpectedStatus(DocumentVersionStatus status) { } @Test - @DisplayName("EMBEDDING 이후 INDEXED·FAILED 전이는 유지된다") - void laterPipelineTransitions_arePreserved() { - DocumentVersion version = version(DocumentVersionStatus.CHUNKED); + @DisplayName("EMBEDDING Version을 INDEXED로 전환하고 완료 시각을 기록한다") + void markIndexed_transitionsFromEmbedding() { + DocumentVersion version = version(DocumentVersionStatus.EMBEDDING); LocalDateTime indexedAt = LocalDateTime.of(2026, 7, 29, 12, 0); - version.markEmbedding(); - assertThat(version.getStatus()).isEqualTo(DocumentVersionStatus.EMBEDDING); - version.markIndexed(indexedAt); + assertThat(version.getStatus()).isEqualTo(DocumentVersionStatus.INDEXED); assertThat(version.getIndexedAt()).isEqualTo(indexedAt); + } - version.markFailed(); - assertThat(version.getStatus()).isEqualTo(DocumentVersionStatus.FAILED); + @ParameterizedTest + @EnumSource(value = DocumentVersionStatus.class, names = "EMBEDDING", mode = EnumSource.Mode.EXCLUDE) + @DisplayName("EMBEDDING이 아닌 상태에서는 INDEXED 전이를 거부한다") + void markIndexed_rejectsUnexpectedStatus(DocumentVersionStatus status) { + DocumentVersion version = version(status); + + assertThatThrownBy(() -> version.markIndexed(LocalDateTime.of(2026, 7, 29, 12, 0))) + .isInstanceOf(IllegalStateException.class); + assertThat(version.getStatus()).isEqualTo(status); + assertThat(version.getIndexedAt()).isNull(); } private DocumentVersion version(DocumentVersionStatus status) { 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 5f87da2..fe65b0c 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 @@ -33,10 +33,12 @@ import com.opensource.docgrid.domain.embedding.dto.response.ClaimedEmbeddingJobResponse; import com.opensource.docgrid.domain.embedding.dto.response.DocumentChunksResponse; import com.opensource.docgrid.domain.embedding.dto.response.DocumentEmbeddingsResponse; +import com.opensource.docgrid.domain.embedding.dto.response.DocumentIndexingCompletionResponse; import com.opensource.docgrid.domain.embedding.dto.response.StartedEmbeddingJobAttemptResponse; import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; 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.EmbeddingJobAttemptService; import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobAttemptService.StartResult; import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobClaimService; @@ -46,7 +48,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 경계에서 확인한다. @@ -60,6 +62,7 @@ class IndexingJobAdminControllerTest { private static final String ATTEMPT_URL = "/admin/indexing-jobs/10/attempts"; 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 Long JOB_ID = 10L; private static final Long ATTEMPT_ID = 100L; private static final Long WORKER_ID = 1L; @@ -77,6 +80,7 @@ class IndexingJobAdminControllerTest { @MockitoBean private EmbeddingJobAttemptService embeddingJobAttemptService; @MockitoBean private DocumentParsingService documentParsingService; @MockitoBean private DocumentEmbeddingService documentEmbeddingService; + @MockitoBean private DocumentIndexingCompletionService documentIndexingCompletionService; @MockitoBean private JpaMetamodelMappingContext jpaMetamodelMappingContext; @MockitoBean private JwtProvider jwtProvider; @MockitoBean private CorsConfigurationSource corsConfigurationSource; @@ -422,6 +426,83 @@ void createEmbeddings_returnsForbidden_withoutAdminRole() throws Exception { .andExpect(status().isForbidden()); } + @Test + @DisplayName("ADMIN 사용자의 최초 완료와 멱등 재생은 안정적인 응답으로 200을 반환한다") + void completeIndexing_returnsOkWithoutSensitiveFields() throws Exception { + DocumentIndexingCompletionResponse response = createCompletionResponse(); + given(documentIndexingCompletionService.complete(eq(JOB_ID), eq(ATTEMPT_ID), any())) + .willReturn(response); + + mockMvc.perform(post(COMPLETE_URL) + .contentType("application/json") + .content(VALID_ATTEMPT_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.documentId").value(10)) + .andExpect(jsonPath("$.data.documentVersionId").value(22)) + .andExpect(jsonPath("$.data.embeddingModelId").value(1)) + .andExpect(jsonPath("$.data.jobStatus").value("INDEXED")) + .andExpect(jsonPath("$.data.attemptStatus").value("SUCCESS")) + .andExpect(jsonPath("$.data.versionStatus").value("INDEXED")) + .andExpect(jsonPath("$.data.completedAt").value("2026-07-31T16:00:00")) + .andExpect(jsonPath("$.data.durationMs").value(8421)) + .andExpect(jsonPath("$.data.claimToken").doesNotExist()) + .andExpect(jsonPath("$.data.chunkText").doesNotExist()) + .andExpect(jsonPath("$.data.vector").doesNotExist()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidCompletionRequests") + @DisplayName("잘못된 인덱싱 완료 요청은 400을 반환한다") + void completeIndexing_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()); + } + + @ParameterizedTest + @MethodSource("completionBusinessErrors") + @DisplayName("인덱싱 완료 비즈니스 오류를 정의된 HTTP 상태와 코드로 반환한다") + void completeIndexing_returnsDefinedError( + ErrorCode errorCode, + int expectedStatus, + String expectedCode + ) throws Exception { + given(documentIndexingCompletionService.complete(eq(JOB_ID), eq(ATTEMPT_ID), any())) + .willThrow(new DocGridException(errorCode)); + + mockMvc.perform(post(COMPLETE_URL) + .contentType("application/json") + .content(VALID_ATTEMPT_BODY) + .with(user("admin").roles("ADMIN"))) + .andExpect(status().is(expectedStatus)) + .andExpect(jsonPath("$.code").value(expectedCode)); + } + + @Test + @DisplayName("일반 사용자와 미인증 사용자는 인덱싱을 완료할 수 없다") + void completeIndexing_returnsForbidden_withoutAdminRole() throws Exception { + mockMvc.perform(post(COMPLETE_URL) + .contentType("application/json") + .content(VALID_ATTEMPT_BODY) + .with(user("user").roles("USER"))) + .andExpect(status().isForbidden()); + + mockMvc.perform(post(COMPLETE_URL) + .contentType("application/json") + .content(VALID_ATTEMPT_BODY)) + .andExpect(status().isForbidden()); + } + private static Stream invalidAttemptRequests() { return Stream.of( Arguments.of("Job ID가 양수가 아님", "/admin/indexing-jobs/0/attempts", VALID_ATTEMPT_BODY), @@ -516,6 +597,60 @@ private static Stream embeddingBusinessErrors() { ); } + private static Stream invalidCompletionRequests() { + return Stream.of( + Arguments.of( + "Job ID가 양수가 아님", + "/admin/indexing-jobs/0/attempts/100/complete", + VALID_ATTEMPT_BODY + ), + Arguments.of( + "Attempt ID가 양수가 아님", + "/admin/indexing-jobs/10/attempts/0/complete", + VALID_ATTEMPT_BODY + ), + Arguments.of("Worker ID 누락", COMPLETE_URL, """ + {"claimToken": "%s"} + """.formatted(CLAIM_TOKEN)), + Arguments.of("Worker ID가 0", COMPLETE_URL, """ + {"workerId": 0, "claimToken": "%s"} + """.formatted(CLAIM_TOKEN)), + Arguments.of("Worker ID가 음수", COMPLETE_URL, """ + {"workerId": -1, "claimToken": "%s"} + """.formatted(CLAIM_TOKEN)), + Arguments.of("Claim Token 누락", COMPLETE_URL, """ + {"workerId": 1} + """), + Arguments.of("Claim Token 공백", COMPLETE_URL, """ + {"workerId": 1, "claimToken": " "} + """), + Arguments.of("Claim Token UUID 형식 오류", COMPLETE_URL, """ + {"workerId": 1, "claimToken": "not-a-uuid"} + """) + ); + } + + private static Stream completionBusinessErrors() { + return Stream.of( + Arguments.of(ErrorCode.EMBEDDING_JOB_NOT_FOUND, 404, "EMBEDDING-JOB-001"), + Arguments.of( + ErrorCode.DOCUMENT_INDEXING_COMPLETION_NOT_ALLOWED, + 409, + "DOCUMENT-INDEXING-001" + ), + Arguments.of( + ErrorCode.DOCUMENT_INDEXING_STALE_COMPLETION, + 409, + "DOCUMENT-INDEXING-002" + ), + Arguments.of( + ErrorCode.DOCUMENT_INDEXING_COMPLETION_INCONSISTENT, + 500, + "DOCUMENT-INDEXING-003" + ) + ); + } + private StartedEmbeddingJobAttemptResponse createAttemptResponse() { return new StartedEmbeddingJobAttemptResponse( 100L, @@ -548,4 +683,19 @@ private DocumentEmbeddingsResponse createEmbeddingsResponse() { DocumentVersionStatus.EMBEDDING ); } + + private DocumentIndexingCompletionResponse createCompletionResponse() { + return new DocumentIndexingCompletionResponse( + JOB_ID, + ATTEMPT_ID, + 10L, + 22L, + 1L, + EmbeddingJobStatus.INDEXED, + AttemptStatus.SUCCESS, + DocumentVersionStatus.INDEXED, + LocalDateTime.of(2026, 7, 31, 16, 0), + 8_421L + ); + } } 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 bc7ba1e..a385963 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·인덱싱 완료 상태 전이와 소유권 불변식을 검증하는 Entity 단위 테스트. * *

PENDING Job이 PROCESSING으로 바뀔 때 Worker, Token, Lease, 최초 시작 시각이 함께 기록되는지와 * 이미 Claim된 Job의 소유권 덮어쓰기가 차단되는지 확인한다. @@ -57,6 +57,25 @@ void claim_throws_when_jobIsNotPending() { .hasMessage("PENDING 상태의 Job만 Claim할 수 있습니다."); } + @Test + @DisplayName("PROCESSING Job만 INDEXED로 완료할 수 있다") + void markIndexed_acceptsOnlyProcessingJob() { + EmbeddingJob processingJob = createPendingJob(); + processingJob.claim(createActiveWorker(), CLAIM_TOKEN, CLAIMED_AT, EXPIRES_AT); + LocalDateTime completedAt = CLAIMED_AT.plusSeconds(3); + + processingJob.markIndexed(completedAt); + + assertThat(processingJob.getStatus()).isEqualTo(EmbeddingJobStatus.INDEXED); + assertThat(processingJob.getCompletedAt()).isEqualTo(completedAt); + + EmbeddingJob pendingJob = createPendingJob(); + assertThatThrownBy(() -> pendingJob.markIndexed(completedAt)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("PROCESSING 상태의 Job만 INDEXED로 전환할 수 있습니다."); + 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/integration/DocumentIndexingCompletionIntegrationTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/integration/DocumentIndexingCompletionIntegrationTest.java new file mode 100644 index 0000000..a5f6acb --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/integration/DocumentIndexingCompletionIntegrationTest.java @@ -0,0 +1,548 @@ +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.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 com.opensource.docgrid.domain.document.repository.DocumentRepository; +import com.opensource.docgrid.domain.embedding.dto.request.CompleteDocumentIndexingRequest; +import com.opensource.docgrid.domain.embedding.dto.response.DocumentIndexingCompletionResponse; +import com.opensource.docgrid.domain.embedding.service.command.DocumentIndexingCompletionService; +import com.opensource.docgrid.domain.search.dto.VectorSearchCandidate; +import com.opensource.docgrid.domain.search.service.query.VectorSearchQueryService; + +/** + * 실제 OpenSQL에서 문서 인덱싱 완료가 검색 가시성과 Version별 Embedding 상태를 원자적으로 전환하는지 검증한다. + * + *

격리 Schema에 완료 직전 상태를 직접 구성하고 완료 Service를 호출해 최초 Version 공개와 새 Version + * 교체가 권한 pre-filter 및 pgvector 검색 조건까지 일관되게 반영되는지 확인한다. + */ +@Tag("integration") +@ActiveProfiles("test") +@SpringBootTest +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@DisplayName("Document 인덱싱 완료 OpenSQL 통합 테스트") +class DocumentIndexingCompletionIntegrationTest { + + private static final String TEST_SCHEMA = "docgrid_index_completion_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 DocumentIndexingCompletionService completionService; + @Autowired private DocumentRepository documentRepository; + @Autowired private VectorSearchQueryService vectorSearchQueryService; + + @DynamicPropertySource + static void configureDatabase(DynamicPropertyRegistry registry) { + registry.add("TEST_DB_SCHEMA", () -> TEST_SCHEMA); + registry.add("jwt.secret", () -> "docgrid-index-completion-integration-test-secret-key-2026"); + } + + @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("최초 Version 완료 전에는 검색되지 않고 완료 후 현재 ACTIVE Version으로 검색된다") + void completeFirstVersion_makesDocumentSearchable() { + ExecutionContext context = insertFirstVersionExecution(); + + assertThat(documentRepository.findReadableDocumentIds(context.userId())).isEmpty(); + assertThat(search(context)).isEmpty(); + + DocumentIndexingCompletionResponse response = completionService.complete( + context.jobId(), + context.attemptId(), + new CompleteDocumentIndexingRequest(context.workerId(), CLAIM_TOKEN) + ); + + assertThat(response.jobStatus().name()).isEqualTo("INDEXED"); + assertThat(response.attemptStatus().name()).isEqualTo("SUCCESS"); + assertThat(response.versionStatus().name()).isEqualTo("INDEXED"); + assertThat(documentRepository.findReadableDocumentIds(context.userId())) + .containsExactly(context.documentId()); + assertThat(search(context)) + .extracting(VectorSearchCandidate::chunkText) + .containsExactly("최초 검색 본문"); + 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.targetVersionId()); + assertThat(queryString( + "SELECT status FROM embeddings WHERE document_version_id = ?", + context.targetVersionId() + )).isEqualTo("ACTIVE"); + assertThat(indexedEventCount(context.jobId())).isOne(); + assertThat(completionTimestampsMatch(context)).isTrue(); + } + + @Test + @DisplayName("새 Version 완료 전에는 이전 본문을 검색하고 완료 후 새 ACTIVE 본문만 검색한다") + void completeReplacementVersion_switchesCurrentSearchSet() { + ExecutionContext context = insertReplacementVersionExecution(); + + assertThat(search(context)) + .extracting(VectorSearchCandidate::chunkText) + .containsExactly("이전 검색 본문"); + + completionService.complete( + context.jobId(), + context.attemptId(), + new CompleteDocumentIndexingRequest(context.workerId(), CLAIM_TOKEN) + ); + + assertThat(queryLong( + "SELECT current_version_id FROM documents WHERE id = ?", + context.documentId() + )).isEqualTo(context.targetVersionId()); + assertThat(queryString( + "SELECT status FROM embeddings WHERE document_version_id = ?", + context.previousVersionId() + )).isEqualTo("STALE"); + assertThat(queryString( + "SELECT status FROM embeddings WHERE document_version_id = ?", + context.targetVersionId() + )).isEqualTo("ACTIVE"); + assertThat(search(context)) + .extracting(VectorSearchCandidate::chunkText) + .containsExactly("새 검색 본문"); + assertThat(indexedEventCount(context.jobId())).isOne(); + } + + @Test + @DisplayName("같은 실행의 두 완료 요청은 동일 응답과 단일 INDEXED 이벤트로 수렴한다") + void completeConcurrently_convergesToStoredResponse() throws Exception { + ExecutionContext context = insertFirstVersionExecution(); + CompleteDocumentIndexingRequest request = + new CompleteDocumentIndexingRequest(context.workerId(), CLAIM_TOKEN); + CyclicBarrier startBarrier = new CyclicBarrier(CONCURRENT_REQUESTS); + ExecutorService executor = Executors.newFixedThreadPool(CONCURRENT_REQUESTS); + + List responses; + try { + List> futures = List.of( + executor.submit(() -> completeAfterBarrier(context, request, startBarrier)), + executor.submit(() -> completeAfterBarrier(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).hasSize(2); + assertThat(responses.get(1)).isEqualTo(responses.get(0)); + assertThat(indexedEventCount(context.jobId())).isOne(); + assertThat(queryString("SELECT status FROM embedding_jobs WHERE id = ?", context.jobId())) + .isEqualTo("INDEXED"); + assertThat(queryString( + "SELECT status FROM embedding_job_attempts WHERE id = ?", + context.attemptId() + )).isEqualTo("SUCCESS"); + } + + @Test + @DisplayName("INDEXED 이벤트 저장 실패 시 이전 STALE 처리와 모든 완료 상태를 Rollback한다") + void complete_rollsBackAllChanges_whenFinalEventInsertFails() { + ExecutionContext context = insertReplacementVersionExecution(); + installFailingIndexedEventTrigger(); + + try { + assertThatThrownBy(() -> completionService.complete( + context.jobId(), + context.attemptId(), + new CompleteDocumentIndexingRequest(context.workerId(), CLAIM_TOKEN) + )).isInstanceOf(RuntimeException.class); + } finally { + removeFailingIndexedEventTrigger(); + } + + assertThat(queryLong( + "SELECT current_version_id FROM documents WHERE id = ?", + context.documentId() + )).isEqualTo(context.previousVersionId()); + 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("ACTIVE"); + assertThat(queryString( + "SELECT status FROM document_versions WHERE id = ?", + context.targetVersionId() + )).isEqualTo("EMBEDDING"); + assertThat(queryString("SELECT status FROM embedding_jobs WHERE id = ?", context.jobId())) + .isEqualTo("PROCESSING"); + assertThat(queryString( + "SELECT status FROM embedding_job_attempts WHERE id = ?", + context.attemptId() + )).isEqualTo("STARTED"); + assertThat(indexedEventCount(context.jobId())).isZero(); + } + + private ExecutionContext insertFirstVersionExecution() { + BaseContext base = insertBase("UPLOADED"); + Long versionId = insertVersion(base.documentId(), base.userId(), 1, "EMBEDDING"); + setCurrentVersion(base.documentId(), versionId); + 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', 'Completion Test User', 'ACTIVE', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, "index-completion-" + 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 ('completion-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 (?, 'Completion 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 (?, ?, 'Completion 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 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 DocumentIndexingCompletionResponse completeAfterBarrier( + ExecutionContext context, + CompleteDocumentIndexingRequest request, + CyclicBarrier startBarrier + ) throws Exception { + startBarrier.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + return completionService.complete(context.jobId(), context.attemptId(), request); + } + + private void installFailingIndexedEventTrigger() { + jdbcTemplate.execute(""" + CREATE OR REPLACE FUNCTION fail_indexed_event_insert() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF NEW.event_type = 'INDEXED' THEN + RAISE EXCEPTION 'forced indexed event failure'; + END IF; + RETURN NEW; + END; + $$ + """); + jdbcTemplate.execute(""" + CREATE TRIGGER trg_fail_indexed_event_insert + BEFORE INSERT ON indexing_events + FOR EACH ROW + EXECUTE FUNCTION fail_indexed_event_insert() + """); + } + + private void removeFailingIndexedEventTrigger() { + jdbcTemplate.execute(""" + DROP TRIGGER IF EXISTS trg_fail_indexed_event_insert ON indexing_events + """); + jdbcTemplate.execute("DROP FUNCTION IF EXISTS fail_indexed_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 int indexedEventCount(Long jobId) { + return jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM indexing_events + WHERE embedding_job_id = ? AND event_type = 'INDEXED' + """, Integer.class, jobId); + } + + private boolean completionTimestampsMatch(ExecutionContext context) { + return Boolean.TRUE.equals(jdbcTemplate.queryForObject(""" + SELECT job.completed_at = attempt.ended_at + AND job.completed_at = version.indexed_at + AND job.completed_at = event.occurred_at + FROM embedding_jobs job + JOIN embedding_job_attempts attempt ON attempt.embedding_job_id = job.id + JOIN document_versions version ON version.id = job.document_version_id + JOIN indexing_events event + ON event.embedding_job_id = job.id AND event.event_type = 'INDEXED' + WHERE job.id = ? + """, Boolean.class, context.jobId())); + } + + /** + * 공통 사용자·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 + ) { + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/repository/IndexingCompletionRepositoryTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/repository/IndexingCompletionRepositoryTest.java new file mode 100644 index 0000000..bbb8c41 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/repository/IndexingCompletionRepositoryTest.java @@ -0,0 +1,258 @@ +package com.opensource.docgrid.domain.embedding.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +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 com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingStatus; +import com.opensource.docgrid.domain.worker.enums.IndexingEventType; +import com.opensource.docgrid.domain.worker.repository.IndexingEventRepository; + +/** + * 인덱싱 완료용 Repository 집계와 Embedding 일괄 상태 전환을 실제 OpenSQL에서 검증한다. + * + *

Vector 본문을 Entity로 읽지 않고도 Version·Model·상태 개수와 관계·차원·Hash 불변식을 판별하고, + * 이전 ACTIVE Set을 STALE로 전환하는 계약을 격리 Schema에서 확인한다. + */ +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@DisplayName("인덱싱 완료 Repository 테스트") +class IndexingCompletionRepositoryTest { + + private static final String TEST_SCHEMA = "docgrid_index_completion_repository_test"; + private static final int VECTOR_DIMENSION = 1024; + private static final String VECTOR_HASH = + "26e4a23eec4241e034f1b4631f0222f1895847637c35e77687d5945f75edb42c"; + + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private EmbeddingRepository embeddingRepository; + @Autowired private EmbeddingJobRepository embeddingJobRepository; + @Autowired private IndexingEventRepository indexingEventRepository; + + private Long documentId; + private Long versionId; + private Long embeddingModelId; + + @DynamicPropertySource + static void configureDatabase(DynamicPropertyRegistry registry) { + registry.add("TEST_DB_SCHEMA", () -> TEST_SCHEMA); + registry.add("jwt.secret", () -> "docgrid-index-completion-repository-test-secret-key-2026"); + } + + @BeforeEach + void setUp() { + jdbcTemplate.execute(""" + TRUNCATE TABLE + embeddings, + indexing_events, + document_chunks, + embedding_job_attempts, + embedding_jobs, + document_versions, + documents, + users + RESTART IDENTITY CASCADE + """); + + String suffix = UUID.randomUUID().toString(); + Long userId = insertUser(suffix); + documentId = insertDocument(userId, "Repository Contract Document"); + versionId = insertVersion(documentId, userId, 1); + embeddingModelId = jdbcTemplate.queryForObject(""" + SELECT id + FROM embedding_models + WHERE is_active = TRUE AND is_searchable = TRUE + """, Long.class); + } + + @AfterAll + void dropSchema() { + jdbcTemplate.execute("DROP SCHEMA IF EXISTS " + TEST_SCHEMA + " CASCADE"); + } + + @Test + @DisplayName("Version의 전체·Model·ACTIVE 개수를 집계하고 ACTIVE Set을 STALE로 전환한다") + void countsAndMarksActiveSetStale() { + List chunkIds = insertChunks(versionId, 2); + insertEmbedding(chunkIds.get(0), documentId, versionId, embeddingModelId, VECTOR_HASH); + insertEmbedding(chunkIds.get(1), documentId, versionId, embeddingModelId, VECTOR_HASH); + + assertThat(embeddingRepository.countByDocumentVersionId(versionId)).isEqualTo(2); + assertThat(embeddingRepository.countByDocumentVersionIdAndEmbeddingModelId( + versionId, + embeddingModelId + )).isEqualTo(2); + assertThat(embeddingRepository.countByDocumentVersionIdAndEmbeddingModelIdAndStatus( + versionId, + embeddingModelId, + EmbeddingStatus.ACTIVE + )).isEqualTo(2); + assertThat(embeddingRepository.countInvalidCompletionRows( + documentId, + versionId, + embeddingModelId, + VECTOR_DIMENSION + )).isZero(); + + int updatedRows = embeddingRepository.markActiveAsStaleByDocumentVersionId(versionId); + + assertThat(updatedRows).isEqualTo(2); + assertThat(embeddingRepository.countByDocumentVersionIdAndEmbeddingModelIdAndStatus( + versionId, + embeddingModelId, + EmbeddingStatus.ACTIVE + )).isZero(); + } + + @Test + @DisplayName("역정규화 관계나 Vector Hash가 잘못된 행을 완료 불변식 위반으로 집계한다") + void countInvalidCompletionRows_detectsBrokenInvariant() { + Long chunkId = insertChunks(versionId, 1).get(0); + insertEmbedding(chunkId, documentId, versionId, embeddingModelId, "INVALID"); + + long invalidRows = embeddingRepository.countInvalidCompletionRows( + documentId, + versionId, + embeddingModelId, + VECTOR_DIMENSION + ); + + assertThat(invalidRows).isOne(); + } + + @Test + @DisplayName("Version의 진행 Job과 Job의 INDEXED 이벤트 수를 집계한다") + void countsActiveJobsAndIndexedEvents() { + Long processingJobId = insertJob(EmbeddingJobStatus.PROCESSING); + insertJob(EmbeddingJobStatus.INDEXED); + jdbcTemplate.update(""" + INSERT INTO indexing_events ( + embedding_job_id, event_type, from_status, to_status, message, + occurred_at, created_at, updated_at + ) + VALUES (?, 'INDEXED', 'EMBEDDING', 'INDEXED', '완료', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, processingJobId); + + assertThat(embeddingJobRepository.countByDocumentVersionIdAndStatusIn( + versionId, + List.of(EmbeddingJobStatus.PENDING, EmbeddingJobStatus.PROCESSING) + )).isOne(); + assertThat(indexingEventRepository.countByEmbeddingJobIdAndEventType( + processingJobId, + IndexingEventType.INDEXED + )).isOne(); + } + + private Long insertUser(String suffix) { + return jdbcTemplate.queryForObject(""" + INSERT INTO users (email, password_hash, name, status, created_at, updated_at) + VALUES (?, 'password-hash', 'Repository Test User', 'ACTIVE', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, "index-completion-repository-" + suffix + "@example.com"); + } + + private Long insertDocument(Long userId, String title) { + return jdbcTemplate.queryForObject(""" + INSERT INTO documents ( + owner_user_id, title, document_type, source_type, status, visibility, + created_at, updated_at + ) + VALUES (?, ?, 'TXT', 'UPLOAD', 'INDEXING', 'PRIVATE', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, userId, title); + } + + private Long insertVersion(Long targetDocumentId, Long userId, int versionNo) { + return jdbcTemplate.queryForObject(""" + INSERT INTO document_versions ( + document_id, version_no, title_snapshot, content_type, status, + created_by, created_at, updated_at + ) + VALUES (?, ?, 'Repository Test Version', 'text/plain', 'EMBEDDING', ?, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, targetDocumentId, versionNo, userId); + } + + private List insertChunks(Long targetVersionId, int count) { + return java.util.stream.IntStream.range(0, count) + .mapToObj(index -> 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 (?, ?, ?, 1, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, + targetVersionId, + index, + "본문-" + index, + index, + index + 1, + VECTOR_HASH + )) + .toList(); + } + + private void insertEmbedding( + Long chunkId, + Long targetDocumentId, + Long targetVersionId, + Long modelId, + String vectorHash + ) { + 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, + targetDocumentId, + targetVersionId, + modelId, + zeroVector(), + VECTOR_DIMENSION, + vectorHash + ); + } + + private Long insertJob(EmbeddingJobStatus status) { + return jdbcTemplate.queryForObject(""" + INSERT INTO embedding_jobs ( + document_version_id, embedding_model_id, status, priority, retry_count, + max_retry_count, created_at, updated_at + ) + VALUES (?, ?, ?, 0, 0, 3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id + """, Long.class, versionId, embeddingModelId, status.name()); + } + + private String zeroVector() { + return "[" + "0,".repeat(VECTOR_DIMENSION - 1) + "0]"; + } +} diff --git a/src/test/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingCompletionServiceTest.java b/src/test/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingCompletionServiceTest.java new file mode 100644 index 0000000..ddb0dc4 --- /dev/null +++ b/src/test/java/com/opensource/docgrid/domain/embedding/service/command/DocumentIndexingCompletionServiceTest.java @@ -0,0 +1,433 @@ +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.never; + +import java.time.Clock; +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.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.DocumentChunkRepository; +import com.opensource.docgrid.domain.document.repository.DocumentRepository; +import com.opensource.docgrid.domain.document.repository.DocumentVersionRepository; +import com.opensource.docgrid.domain.embedding.dto.request.CompleteDocumentIndexingRequest; +import com.opensource.docgrid.domain.embedding.dto.response.DocumentIndexingCompletionResponse; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingJob; +import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingJobStatus; +import com.opensource.docgrid.domain.embedding.enums.EmbeddingStatus; +import com.opensource.docgrid.domain.embedding.fixture.EmbeddingModelFixture; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingJobRepository; +import com.opensource.docgrid.domain.embedding.repository.EmbeddingRepository; +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.fixture.WorkerNodeFixture; +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; + +/** + * 문서 인덱싱 최초 완료 Transaction의 잠금 순서, 검증, 상태 전이와 이전 검색 Set 비활성화를 검증한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("DocumentIndexingCompletionService 테스트") +class DocumentIndexingCompletionServiceTest { + + 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 MODEL_ID = 1L; + private static final Long WORKER_ID = WorkerNodeFixture.WORKER_ID; + private static final String CLAIM_TOKEN = "34c19d16-6ae1-4f6a-a35d-0123456789ab"; + private static final LocalDateTime COMPLETED_AT = LocalDateTime.of(2026, 7, 31, 16, 0); + + @Mock private EmbeddingJobRepository embeddingJobRepository; + @Mock private EmbeddingJobAttemptRepository embeddingJobAttemptRepository; + @Mock private DocumentVersionRepository documentVersionRepository; + @Mock private DocumentRepository documentRepository; + @Mock private DocumentChunkRepository documentChunkRepository; + @Mock private EmbeddingRepository embeddingRepository; + @Mock private IndexingEventRepository indexingEventRepository; + @Mock private EmbeddingJobOwnershipValidator ownershipValidator; + + private DocumentIndexingCompletionService service; + private Document document; + private DocumentVersion documentVersion; + private EmbeddingModel embeddingModel; + private WorkerNode workerNode; + private EmbeddingJob embeddingJob; + private EmbeddingJobAttempt attempt; + + @BeforeEach + void setUp() { + Clock clock = Clock.fixed( + Instant.parse("2026-07-31T07:00:00Z"), + ZoneId.of("Asia/Seoul") + ); + service = new DocumentIndexingCompletionService( + embeddingJobRepository, + embeddingJobAttemptRepository, + documentVersionRepository, + documentRepository, + documentChunkRepository, + embeddingRepository, + indexingEventRepository, + ownershipValidator, + clock + ); + prepareExecution(); + } + + @Test + @DisplayName("최초 Version의 전체 Embedding Set을 검증하고 모든 완료 상태를 같은 시각으로 전환한다") + void complete_transitionsFirstVersionAtomically() { + givenLockedExecution(); + givenValidCompletionState(); + + DocumentIndexingCompletionResponse response = service.complete( + JOB_ID, + ATTEMPT_ID, + request() + ); + + assertThat(response.jobId()).isEqualTo(JOB_ID); + assertThat(response.attemptId()).isEqualTo(ATTEMPT_ID); + assertThat(response.documentId()).isEqualTo(DOCUMENT_ID); + assertThat(response.documentVersionId()).isEqualTo(VERSION_ID); + assertThat(response.embeddingModelId()).isEqualTo(MODEL_ID); + assertThat(response.jobStatus()).isEqualTo(EmbeddingJobStatus.INDEXED); + assertThat(response.attemptStatus()).isEqualTo(AttemptStatus.SUCCESS); + assertThat(response.versionStatus()).isEqualTo(DocumentVersionStatus.INDEXED); + assertThat(response.completedAt()).isEqualTo(COMPLETED_AT); + assertThat(response.durationMs()).isEqualTo(8_000L); + + assertThat(document.getCurrentVersion()).isSameAs(documentVersion); + assertThat(document.getStatus()).isEqualTo(DocumentStatus.INDEXED); + assertThat(documentVersion.getIndexedAt()).isEqualTo(COMPLETED_AT); + assertThat(embeddingJob.getCompletedAt()).isEqualTo(COMPLETED_AT); + assertThat(attempt.getEndedAt()).isEqualTo(COMPLETED_AT); + then(embeddingRepository).should(never()) + .markActiveAsStaleByDocumentVersionId(VERSION_ID); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(IndexingEvent.class); + then(indexingEventRepository).should().save(eventCaptor.capture()); + assertThat(eventCaptor.getValue().getEventType()).isEqualTo(IndexingEventType.INDEXED); + assertThat(eventCaptor.getValue().getFromStatus()).isEqualTo("EMBEDDING"); + assertThat(eventCaptor.getValue().getToStatus()).isEqualTo("INDEXED"); + assertThat(eventCaptor.getValue().getOccurredAt()).isEqualTo(COMPLETED_AT); + } + + @Test + @DisplayName("새 Version 완료 시 이전 현재 Version의 ACTIVE Embedding을 STALE로 전환한다") + void complete_stalesPreviousCurrentVersion() { + DocumentVersion previousVersion = version(21L, 1, DocumentVersionStatus.INDEXED); + document.updateCurrentVersion(previousVersion); + documentVersion = version(VERSION_ID, 2, DocumentVersionStatus.EMBEDDING); + prepareJobAndAttempt(); + givenLockedExecution(); + givenValidCompletionState(); + given(embeddingRepository.markActiveAsStaleByDocumentVersionId(21L)).willReturn(2); + + service.complete(JOB_ID, ATTEMPT_ID, request()); + + then(embeddingRepository).should().markActiveAsStaleByDocumentVersionId(21L); + assertThat(previousVersion.getStatus()).isEqualTo(DocumentVersionStatus.INDEXED); + assertThat(document.getCurrentVersion()).isSameAs(documentVersion); + } + + @Test + @DisplayName("최신 Version이 아닌 완료 요청은 상태 변경 전에 거부한다") + void complete_rejectsStaleVersion() { + givenLockedExecution(); + DocumentVersion newerVersion = version(23L, 2, DocumentVersionStatus.UPLOADED); + given(documentVersionRepository.findTopByDocumentIdOrderByVersionNoDesc(DOCUMENT_ID)) + .willReturn(Optional.of(newerVersion)); + + assertThatThrownBy(() -> service.complete(JOB_ID, ATTEMPT_ID, request())) + .isInstanceOfSatisfying(DocGridException.class, + exception -> assertThat(exception.getErrorCode()) + .isEqualTo(ErrorCode.DOCUMENT_INDEXING_STALE_COMPLETION)); + + assertThat(embeddingJob.getStatus()).isEqualTo(EmbeddingJobStatus.PROCESSING); + assertThat(documentVersion.getStatus()).isEqualTo(DocumentVersionStatus.EMBEDDING); + then(embeddingRepository).shouldHaveNoInteractions(); + then(indexingEventRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("Embedding Set 개수가 Chunk 수와 다르면 완료 상태를 만들지 않는다") + void complete_rejectsIncompleteEmbeddingSet() { + givenLockedExecution(); + given(documentVersionRepository.findTopByDocumentIdOrderByVersionNoDesc(DOCUMENT_ID)) + .willReturn(Optional.of(documentVersion)); + given(embeddingJobRepository.countByDocumentVersionIdAndStatusIn( + VERSION_ID, + java.util.Set.of(EmbeddingJobStatus.PENDING, EmbeddingJobStatus.PROCESSING) + )).willReturn(1L); + given(documentChunkRepository.countByDocumentVersionId(VERSION_ID)).willReturn(2L); + given(embeddingRepository.countByDocumentVersionId(VERSION_ID)).willReturn(2L); + given(embeddingRepository.countByDocumentVersionIdAndEmbeddingModelId(VERSION_ID, MODEL_ID)) + .willReturn(2L); + given(embeddingRepository.countByDocumentVersionIdAndEmbeddingModelIdAndStatus( + VERSION_ID, + MODEL_ID, + EmbeddingStatus.ACTIVE + )).willReturn(1L); + + assertThatThrownBy(() -> service.complete(JOB_ID, ATTEMPT_ID, request())) + .isInstanceOfSatisfying(DocGridException.class, + exception -> assertThat(exception.getErrorCode()) + .isEqualTo(ErrorCode.DOCUMENT_INDEXING_COMPLETION_INCONSISTENT)); + + assertThat(embeddingJob.getStatus()).isEqualTo(EmbeddingJobStatus.PROCESSING); + then(indexingEventRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("PROCESSING이 아닌 Job은 소유권 검증 전에 완료를 거부한다") + void complete_rejectsUnexpectedJobStatus() { + EmbeddingJob pendingJob = EmbeddingJob.builder() + .documentVersion(documentVersion) + .embeddingModel(embeddingModel) + .status(EmbeddingJobStatus.PENDING) + .priority(0) + .maxRetryCount(3) + .build(); + ReflectionTestUtils.setField(pendingJob, "id", JOB_ID); + given(embeddingJobRepository.findByIdForUpdate(JOB_ID)) + .willReturn(Optional.of(pendingJob)); + + assertThatThrownBy(() -> service.complete(JOB_ID, ATTEMPT_ID, request())) + .isInstanceOfSatisfying(DocGridException.class, + exception -> assertThat(exception.getErrorCode()) + .isEqualTo(ErrorCode.DOCUMENT_INDEXING_COMPLETION_NOT_ALLOWED)); + + then(ownershipValidator).shouldHaveNoInteractions(); + then(embeddingJobAttemptRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("완료된 같은 실행은 Lease와 현재 Version이 바뀌어도 최초 완료 결과를 재생한다") + void complete_replaysStoredCompletionAfterLeaseExpiryAndNewerVersion() { + prepareCompletedExecution(); + ReflectionTestUtils.setField( + embeddingJob, + "lockExpiresAt", + COMPLETED_AT.minusSeconds(1) + ); + EmbeddingModel inactiveModel = + EmbeddingModelFixture.createModel("inactive-completed-model", false, false); + ReflectionTestUtils.setField(inactiveModel, "id", MODEL_ID); + ReflectionTestUtils.setField(embeddingJob, "embeddingModel", inactiveModel); + DocumentVersion newerVersion = version(23L, 2, DocumentVersionStatus.INDEXED); + document.updateCurrentVersion(newerVersion); + givenCompletedExecution(); + given(indexingEventRepository.countByEmbeddingJobIdAndEventType( + JOB_ID, + IndexingEventType.INDEXED + )).willReturn(1L); + + DocumentIndexingCompletionResponse response = service.complete( + JOB_ID, + ATTEMPT_ID, + request() + ); + + assertThat(response.completedAt()).isEqualTo(COMPLETED_AT); + assertThat(response.durationMs()).isEqualTo(8_000L); + assertThat(response.documentVersionId()).isEqualTo(VERSION_ID); + assertThat(document.getCurrentVersion()).isSameAs(newerVersion); + then(ownershipValidator).shouldHaveNoInteractions(); + then(documentChunkRepository).shouldHaveNoInteractions(); + then(embeddingRepository).shouldHaveNoInteractions(); + then(indexingEventRepository).should(never()).save(org.mockito.ArgumentMatchers.any()); + } + + @Test + @DisplayName("완료 재생의 Worker나 Claim Token이 다르면 소유권 오류로 거부한다") + void complete_replayRejectsDifferentIdentity() { + prepareCompletedExecution(); + given(embeddingJobRepository.findByIdForUpdate(JOB_ID)) + .willReturn(Optional.of(embeddingJob)); + CompleteDocumentIndexingRequest differentToken = new CompleteDocumentIndexingRequest( + WORKER_ID, + "8d242ac5-0916-4e1c-a781-1f7b932f989b" + ); + + assertThatThrownBy(() -> service.complete(JOB_ID, ATTEMPT_ID, differentToken)) + .isInstanceOfSatisfying(DocGridException.class, + exception -> assertThat(exception.getErrorCode()) + .isEqualTo(ErrorCode.EMBEDDING_JOB_OWNERSHIP_INVALID)); + + then(embeddingJobAttemptRepository).shouldHaveNoInteractions(); + then(documentVersionRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("완료 재생의 Attempt가 SUCCESS가 아니면 실행 Context 오류로 거부한다") + void complete_replayRejectsIncompleteAttempt() { + prepareCompletedExecution(); + ReflectionTestUtils.setField(attempt, "status", AttemptStatus.FAILED); + given(embeddingJobRepository.findByIdForUpdate(JOB_ID)) + .willReturn(Optional.of(embeddingJob)); + given(embeddingJobAttemptRepository.findByEmbeddingJobIdAndClaimToken(JOB_ID, CLAIM_TOKEN)) + .willReturn(Optional.of(attempt)); + + assertThatThrownBy(() -> service.complete(JOB_ID, ATTEMPT_ID, request())) + .isInstanceOfSatisfying(DocGridException.class, + exception -> assertThat(exception.getErrorCode()) + .isEqualTo(ErrorCode.EMBEDDING_JOB_ATTEMPT_INVALID)); + + then(documentVersionRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("완료 재생의 저장 시각이나 INDEXED 이벤트 수가 모순이면 결과를 반환하지 않는다") + void complete_replayRejectsInconsistentStoredState() { + prepareCompletedExecution(); + ReflectionTestUtils.setField(attempt, "durationMs", null); + givenCompletedExecution(); + + assertThatThrownBy(() -> service.complete(JOB_ID, ATTEMPT_ID, request())) + .isInstanceOfSatisfying(DocGridException.class, + exception -> assertThat(exception.getErrorCode()) + .isEqualTo(ErrorCode.DOCUMENT_INDEXING_COMPLETION_INCONSISTENT)); + + then(embeddingRepository).shouldHaveNoInteractions(); + then(indexingEventRepository).should(never()).save(org.mockito.ArgumentMatchers.any()); + } + + private void prepareExecution() { + document = Document.builder() + .title("완료 대상 문서") + .status(DocumentStatus.INDEXING) + .build(); + ReflectionTestUtils.setField(document, "id", DOCUMENT_ID); + documentVersion = version(VERSION_ID, 1, DocumentVersionStatus.EMBEDDING); + document.updateCurrentVersion(documentVersion); + embeddingModel = EmbeddingModelFixture.createDefaultModel(); + ReflectionTestUtils.setField(embeddingModel, "id", MODEL_ID); + workerNode = WorkerNodeFixture.createActiveWorker(COMPLETED_AT.minusSeconds(1)); + prepareJobAndAttempt(); + } + + private void prepareJobAndAttempt() { + embeddingJob = EmbeddingJob.builder() + .documentVersion(documentVersion) + .embeddingModel(embeddingModel) + .status(EmbeddingJobStatus.PENDING) + .priority(0) + .maxRetryCount(3) + .build(); + ReflectionTestUtils.setField(embeddingJob, "id", JOB_ID); + embeddingJob.claim( + workerNode, + CLAIM_TOKEN, + COMPLETED_AT.minusMinutes(1), + COMPLETED_AT.plusMinutes(5) + ); + attempt = EmbeddingJobAttempt.builder() + .embeddingJob(embeddingJob) + .workerNode(workerNode) + .attemptNo(1) + .claimToken(CLAIM_TOKEN) + .status(AttemptStatus.STARTED) + .startedAt(COMPLETED_AT.minusSeconds(8)) + .build(); + ReflectionTestUtils.setField(attempt, "id", ATTEMPT_ID); + } + + private void prepareCompletedExecution() { + documentVersion.markIndexed(COMPLETED_AT); + document.activateIndexedVersion(documentVersion); + attempt.markSuccess(COMPLETED_AT, 8_000L); + embeddingJob.markIndexed(COMPLETED_AT); + } + + private DocumentVersion version(Long id, int versionNo, DocumentVersionStatus status) { + DocumentVersion version = DocumentVersion.builder() + .document(document) + .versionNo(versionNo) + .status(status) + .build(); + ReflectionTestUtils.setField(version, "id", id); + return version; + } + + 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 void givenValidCompletionState() { + given(documentVersionRepository.findTopByDocumentIdOrderByVersionNoDesc(DOCUMENT_ID)) + .willReturn(Optional.of(documentVersion)); + given(embeddingJobRepository.countByDocumentVersionIdAndStatusIn( + VERSION_ID, + java.util.Set.of(EmbeddingJobStatus.PENDING, EmbeddingJobStatus.PROCESSING) + )).willReturn(1L); + given(documentChunkRepository.countByDocumentVersionId(VERSION_ID)).willReturn(2L); + given(embeddingRepository.countByDocumentVersionId(VERSION_ID)).willReturn(2L); + given(embeddingRepository.countByDocumentVersionIdAndEmbeddingModelId(VERSION_ID, MODEL_ID)) + .willReturn(2L); + given(embeddingRepository.countByDocumentVersionIdAndEmbeddingModelIdAndStatus( + VERSION_ID, + MODEL_ID, + EmbeddingStatus.ACTIVE + )).willReturn(2L); + given(embeddingRepository.countInvalidCompletionRows( + DOCUMENT_ID, + VERSION_ID, + MODEL_ID, + EmbeddingModelFixture.DIMENSION + )).willReturn(0L); + given(indexingEventRepository.countByEmbeddingJobIdAndEventType( + JOB_ID, + IndexingEventType.INDEXED + )).willReturn(0L); + } + + private void givenCompletedExecution() { + 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 CompleteDocumentIndexingRequest request() { + return new CompleteDocumentIndexingRequest(WORKER_ID, CLAIM_TOKEN); + } +} 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 ce97758..5e40c59 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 @@ -1,6 +1,7 @@ package com.opensource.docgrid.domain.worker.entity; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.time.LocalDateTime; @@ -13,7 +14,7 @@ import com.opensource.docgrid.domain.worker.enums.WorkerStatus; /** - * Embedding Job Attempt의 시작 상태 생성 계약과 기존 성공·실패 상태 전이를 검증하는 Entity 단위 테스트. + * Embedding Job Attempt의 시작 상태 생성 계약과 성공·실패 상태 전이 Guard를 검증하는 Entity 단위 테스트. * *

신규 시작 경로가 Job, Worker, Claim Token, 번호와 시각을 함께 보존하며 종료 정보는 시작 시점에 * 비어 있는지 확인한다. @@ -63,6 +64,20 @@ void markSuccess_recordsCompletion() { assertThat(attempt.getDurationMs()).isEqualTo(3_000L); } + @Test + @DisplayName("종결된 Attempt는 다시 성공 처리할 수 없다") + void markSuccess_rejectsCompletedAttempt() { + EmbeddingJobAttempt attempt = createStartedAttempt(); + LocalDateTime firstEndedAt = STARTED_AT.plusSeconds(3); + attempt.markSuccess(firstEndedAt, 3_000L); + + assertThatThrownBy(() -> attempt.markSuccess(STARTED_AT.plusSeconds(5), 5_000L)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("STARTED 상태의 Attempt만 SUCCESS로 전환할 수 있습니다."); + assertThat(attempt.getEndedAt()).isEqualTo(firstEndedAt); + assertThat(attempt.getDurationMs()).isEqualTo(3_000L); + } + @Test @DisplayName("시작된 Attempt를 실패 상태와 오류 정보로 종료한다") void markFailed_recordsFailure() {