Skip to content

Commit 22e6ebf

Browse files
authored
[Feat] 인덱싱 Job Lease 갱신 및 만료 복구
[Feat] 인덱싱 Job Lease 갱신 및 만료 복구
2 parents 2512635 + e984953 commit 22e6ebf

29 files changed

Lines changed: 3101 additions & 261 deletions

docs/design/Gimini-3-#90-indexing-job-lease-recovery.md

Lines changed: 599 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# #90 인덱싱 Job Lease 갱신 및 만료 복구 검증 결과
2+
3+
## 1. 검증 정보
4+
5+
- 실행일: 2026-08-03 (Asia/Seoul)
6+
- 대상 브랜치: `feature/90`
7+
- 애플리케이션: Spring Boot 3.5.16, Java 17
8+
- 데이터베이스: 격리된 PostgreSQL 14(OpenSQL 호환) + pgvector
9+
- 검증 범위: 전체 기본 Build와 Lease 복구 전용 동시성 Test
10+
- 최종 결과: 기본 Test 547개와 동시성 Test 4개 통과, 실패·오류·Skip 0개
11+
12+
기존 개발 데이터와 영구 Volume은 사용하거나 변경하지 않았다. 검증에는 localhost에만 노출한 일회용
13+
PostgreSQL 컨테이너를 사용했고, Flyway Migration과 Test Class별 격리 Schema를 적용했다. 인증 관련
14+
환경 값은 테스트 전용 설정을 사용했으며 실제 값은 기록하지 않는다.
15+
16+
## 2. 전체 회귀 검증
17+
18+
실행 명령의 환경 값은 Placeholder로 대체한다.
19+
20+
```bash
21+
DB_HOST=localhost \
22+
DB_PORT='<isolated-test-port>' \
23+
DB_NAME=docgrid \
24+
DB_USER='<local-test-user>' \
25+
DB_PASSWORD='<local-test-password>' \
26+
DB_SSLMODE=disable \
27+
JWT_SECRET='<test-only-secret>' \
28+
./gradlew clean build
29+
```
30+
31+
결과:
32+
33+
```text
34+
BUILD SUCCESSFUL
35+
tests=547 failures=0 errors=0 skipped=0
36+
```
37+
38+
기본 `test` Task에서 다음 영역을 함께 회귀 검증했다.
39+
40+
- Lease 갱신 API의 정상·Token 불일치·만료·상태 불일치 응답
41+
- `EmbeddingJob`의 Lease 갱신과 Retry·최종 실패 전이
42+
- DEAD Worker 확정과 오래 만료된 Job 후보 조회
43+
- 복구 Service의 소유권 재확인, Attempt 처리와 실패 이벤트 기록
44+
- Scheduler의 단계별 실패 격리와 후보별 계속 진행
45+
46+
## 3. Lease 복구 동시성 검증
47+
48+
실행:
49+
50+
```bash
51+
DB_HOST=localhost \
52+
DB_PORT='<isolated-test-port>' \
53+
DB_NAME=docgrid \
54+
DB_USER='<local-test-user>' \
55+
DB_PASSWORD='<local-test-password>' \
56+
DB_SSLMODE=disable \
57+
./gradlew claimConcurrencyTest \
58+
--tests 'com.opensource.docgrid.domain.embedding.integration.EmbeddingJobLeaseRecoveryIntegrationTest'
59+
```
60+
61+
결과:
62+
63+
```text
64+
BUILD SUCCESSFUL
65+
tests=4 failures=0 errors=0 skipped=0
66+
```
67+
68+
| 검증 항목 | 결과 |
69+
| --- | --- |
70+
| 만료 후보를 오래된 Lease 순서로 제한 조회 | 통과 |
71+
| 두 복구 실행이 같은 Job에 경쟁할 때 단일 상태 전이 | 통과 |
72+
| Attempt가 없는 Job 복구 시 새 Attempt를 합성하지 않음 | 통과 |
73+
| Retry 소진 Job을 최종 실패 상태로 전이 | 통과 |
74+
75+
PostgreSQL `timestamp` 정밀도로 나노초 경계가 절삭되는 문제를 확인해, 경계 Fixture를 1초 뒤로
76+
조정했다. 이후 같은 조건에서 전용 동시성 Test 4개가 모두 통과했다.
77+
78+
## 4. 확인된 불변식
79+
80+
- Lease 갱신은 `PROCESSING` 상태, 일치하는 Worker·Claim Token, 만료 전 Lease에서만 성공한다.
81+
- 복구 후보 Snapshot 조회와 실제 복구 Transaction을 분리한다.
82+
- 실제 복구는 Job 행 잠금 뒤 Lease 만료와 상태를 다시 확인한다.
83+
- 같은 만료 Job에 여러 복구 실행이 경쟁해도 하나만 Retry 또는 최종 실패를 적용한다.
84+
- Retry 복구는 Worker·Claim Token·Lease를 제거하고 다음 실행을 `PENDING`으로 돌린다.
85+
- Retry를 모두 소진하면 Job과 관련 상태를 최종 실패 정책으로 전이한다.
86+
- 기존 Attempt가 없는 Job에는 복구 과정에서 임의의 Attempt를 만들지 않는다.
87+
- 한 후보의 복구 실패가 같은 Batch의 나머지 후보 처리를 중단하지 않는다.
88+
89+
## 5. 환경 진단 기록과 제한 사항
90+
91+
- 기존 Compose 데이터 Volume은 컨테이너 Image의 기대 Role과 달라 초기 접속에 실패했다. 기존
92+
Volume을 수정하지 않고 별도 일회용 데이터베이스로 전환했다.
93+
- Image 기본 Bootstrap 경로에서는 SSL·Host 인증 설정이 Test 접속 조건과 맞지 않았다. localhost
94+
전용 Test Instance로 재구성한 뒤 Flyway와 Test를 실행했다.
95+
- 최초 전체 Test의 Application Context 실패 11건은 테스트용 JWT 설정 누락 때문이었다. 테스트 전용
96+
설정을 주입한 재실행에서는 547개가 모두 통과했다.
97+
- 프로젝트 기본 Build가 제외하는 외부 MinIO 의존 태그와 Benchmark는 이번 검증 범위가 아니다.
98+
- 운영 부하에서의 복구 처리량, Scheduler 주기와 Batch 크기 적정성은 측정하지 않았다.

src/main/java/com/opensource/docgrid/domain/embedding/controller/IndexingJobAdminController.java

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,17 @@
1313
import org.springframework.web.bind.annotation.RestController;
1414

1515
import com.opensource.docgrid.domain.embedding.dto.request.CompleteDocumentIndexingRequest;
16-
import com.opensource.docgrid.domain.embedding.dto.request.FailDocumentIndexingRequest;
1716
import com.opensource.docgrid.domain.embedding.dto.request.CreateDocumentChunksRequest;
1817
import com.opensource.docgrid.domain.embedding.dto.request.CreateDocumentEmbeddingsRequest;
18+
import com.opensource.docgrid.domain.embedding.dto.request.FailDocumentIndexingRequest;
19+
import com.opensource.docgrid.domain.embedding.dto.request.RenewEmbeddingJobLeaseRequest;
1920
import com.opensource.docgrid.domain.embedding.dto.request.StartEmbeddingJobAttemptRequest;
2021
import com.opensource.docgrid.domain.embedding.dto.response.ClaimedEmbeddingJobResponse;
2122
import com.opensource.docgrid.domain.embedding.dto.response.DocumentChunksResponse;
2223
import com.opensource.docgrid.domain.embedding.dto.response.DocumentEmbeddingsResponse;
2324
import com.opensource.docgrid.domain.embedding.dto.response.DocumentIndexingCompletionResponse;
2425
import com.opensource.docgrid.domain.embedding.dto.response.DocumentIndexingFailureResponse;
26+
import com.opensource.docgrid.domain.embedding.dto.response.RenewedEmbeddingJobLeaseResponse;
2527
import com.opensource.docgrid.domain.embedding.dto.response.StartedEmbeddingJobAttemptResponse;
2628
import com.opensource.docgrid.domain.document.service.DocumentParsingService;
2729
import com.opensource.docgrid.domain.document.service.command.DocumentChunkTransactionService.ChunkResult;
@@ -32,6 +34,7 @@
3234
import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobAttemptService;
3335
import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobAttemptService.StartResult;
3436
import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobClaimService;
37+
import com.opensource.docgrid.domain.embedding.service.command.EmbeddingJobLeaseService;
3538
import com.opensource.docgrid.global.common.response.ApiResponse;
3639
import com.opensource.docgrid.global.common.response.ErrorResponse;
3740
import com.opensource.docgrid.global.common.response.ResponseUtils;
@@ -46,7 +49,8 @@
4649
import lombok.RequiredArgsConstructor;
4750

4851
/**
49-
* 관리자용 Embedding Job Claim, Attempt 시작과 문서 Chunk·Embedding·인덱싱 완료·실패 실행을 HTTP API로 제공한다.
52+
* 관리자용 Embedding Job Claim·Lease 갱신, Attempt 시작과 문서 Chunk·Embedding·인덱싱 완료·실패
53+
* 실행을 HTTP API로 제공한다.
5054
*
5155
* <p>HTTP 입력 검증과 성공 상태 변환만 담당한다. Job Claim 및 현재 소유권 기반 파이프라인 단계의
5256
* Transaction·외부 호출·동시성 규칙은 각 Service에 위임한다.
@@ -59,6 +63,7 @@
5963
public class IndexingJobAdminController {
6064

6165
private final EmbeddingJobClaimService embeddingJobClaimService;
66+
private final EmbeddingJobLeaseService embeddingJobLeaseService;
6267
private final EmbeddingJobAttemptService embeddingJobAttemptService;
6368
private final DocumentParsingService documentParsingService;
6469
private final DocumentEmbeddingService documentEmbeddingService;
@@ -111,6 +116,56 @@ public ResponseEntity<ApiResponse<ClaimedEmbeddingJobResponse>> claim(
111116
return ResponseUtils.ok(claimedJob.get());
112117
}
113118

119+
@Operation(
120+
summary = "PROCESSING Job Lease 갱신",
121+
description = "현재 Job의 Worker ID와 Claim Token 및 만료 전 Lease를 검증하고, "
122+
+ "Heartbeat가 유효한 ACTIVE 또는 IDLE Worker의 Lease 만료 시각만 연장합니다. "
123+
+ "Claim Token은 응답에 포함하지 않습니다."
124+
)
125+
@ApiResponses({
126+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
127+
responseCode = "200",
128+
description = "Job Lease 갱신 성공"
129+
),
130+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
131+
responseCode = "400",
132+
description = "Job ID, Worker ID 또는 Claim Token 형식 오류",
133+
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
134+
),
135+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
136+
responseCode = "403",
137+
description = "인증되지 않았거나 ADMIN 권한 없음",
138+
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
139+
),
140+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
141+
responseCode = "404",
142+
description = "Embedding Job 또는 Worker 없음",
143+
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
144+
),
145+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
146+
responseCode = "409",
147+
description = "Job 상태, 현재 소유권, Lease 또는 Worker 생존 상태 오류",
148+
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
149+
),
150+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
151+
responseCode = "500",
152+
description = "PROCESSING Job의 소유권 데이터 불일치",
153+
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
154+
)
155+
})
156+
@PostMapping(
157+
value = "/{jobId}/lease/renew",
158+
consumes = MediaType.APPLICATION_JSON_VALUE,
159+
produces = MediaType.APPLICATION_JSON_VALUE
160+
)
161+
public ResponseEntity<ApiResponse<RenewedEmbeddingJobLeaseResponse>> renewLease(
162+
@PathVariable @Positive Long jobId,
163+
@Valid @RequestBody RenewEmbeddingJobLeaseRequest request
164+
) {
165+
// Service가 Job → Worker 잠금과 소유권·생존 검증 및 Lease 갱신을 한 Transaction으로 처리한다.
166+
return ResponseUtils.ok(embeddingJobLeaseService.renew(jobId, request));
167+
}
168+
114169
@Operation(
115170
summary = "Embedding Job Attempt 시작",
116171
description = "현재 PROCESSING Job의 Worker ID와 Claim Token 및 유효한 Lease를 검증한 뒤 "

src/main/java/com/opensource/docgrid/domain/embedding/converter/EmbeddingJobConverter.java

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
package com.opensource.docgrid.domain.embedding.converter;
22

3+
import java.time.LocalDateTime;
4+
35
import org.springframework.stereotype.Component;
46

57
import com.opensource.docgrid.domain.embedding.dto.response.ClaimedEmbeddingJobResponse;
8+
import com.opensource.docgrid.domain.embedding.dto.response.RenewedEmbeddingJobLeaseResponse;
69
import com.opensource.docgrid.domain.embedding.entity.EmbeddingJob;
710

811
/**
912
* Embedding Job Entity를 API 전용 응답 DTO로 변환하는 Converter.
1013
*
11-
* <p>Controller에 Entity와 연관 Entity를 직접 노출하지 않고 Claim 이후 Worker가 필요한 식별자와
12-
* Lease 정보만 전달한다.
14+
* <p>Controller에 Entity와 연관 Entity를 직접 노출하지 않고 Claim과 Lease 갱신 이후 Worker가
15+
* 필요한 식별자와 소유권 시각만 전달한다.
1316
*/
1417
@Component
1518
public class EmbeddingJobConverter {
@@ -33,4 +36,23 @@ public ClaimedEmbeddingJobResponse toClaimedResponse(EmbeddingJob embeddingJob)
3336
embeddingJob.getLockExpiresAt()
3437
);
3538
}
39+
40+
/**
41+
* 갱신된 현재 Lease를 Claim Token 없이 API 응답으로 변환한다.
42+
*
43+
* @param embeddingJob PROCESSING 상태와 현재 Worker를 유지한 갱신 대상 Job
44+
* @param renewedAt 갱신 Transaction이 사용한 기준 시각
45+
* @return Worker가 다음 갱신 시점을 결정할 수 있는 안전한 Lease 응답
46+
*/
47+
public RenewedEmbeddingJobLeaseResponse toRenewedLeaseResponse(
48+
EmbeddingJob embeddingJob,
49+
LocalDateTime renewedAt
50+
) {
51+
return new RenewedEmbeddingJobLeaseResponse(
52+
embeddingJob.getId(),
53+
embeddingJob.getLockedByWorker().getId(),
54+
renewedAt,
55+
embeddingJob.getLockExpiresAt()
56+
);
57+
}
3658
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.opensource.docgrid.domain.embedding.dto.request;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.NotNull;
6+
import jakarta.validation.constraints.Pattern;
7+
import jakarta.validation.constraints.Positive;
8+
import jakarta.validation.constraints.Size;
9+
10+
/**
11+
* 현재 Embedding Job Claim의 Lease를 갱신하기 위한 요청 DTO.
12+
*
13+
* <p>Worker ID와 canonical UUID Claim Token은 현재 소유권 검증에만 사용하며 성공 응답, Event와 일반
14+
* 로그에는 Token을 다시 노출하지 않는다.
15+
*/
16+
public record RenewEmbeddingJobLeaseRequest(
17+
@Schema(description = "현재 Job을 소유한 Worker 식별자", example = "7")
18+
@NotNull
19+
@Positive
20+
Long workerId,
21+
22+
@Schema(description = "현재 Claim의 canonical UUID Token",
23+
example = "34c19d16-6ae1-4f6a-a35d-0123456789ab")
24+
@NotBlank
25+
@Size(max = 36)
26+
@Pattern(
27+
regexp = "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
28+
message = "canonical UUID 형식이어야 합니다."
29+
)
30+
String claimToken
31+
) {
32+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package com.opensource.docgrid.domain.embedding.dto.response;
2+
3+
import java.time.LocalDateTime;
4+
5+
import io.swagger.v3.oas.annotations.media.Schema;
6+
7+
/**
8+
* 현재 Embedding Job Claim의 갱신된 Lease 결과를 반환하는 응답 DTO.
9+
*
10+
* <p>Worker가 다음 갱신 시점을 결정하는 데 필요한 식별자와 시각만 제공한다. 소유권 증명 값인 Claim
11+
* Token과 내부 Job·Worker 상태는 응답 경계 밖에 유지한다.
12+
*/
13+
public record RenewedEmbeddingJobLeaseResponse(
14+
@Schema(description = "갱신된 Embedding Job 식별자", example = "101")
15+
Long jobId,
16+
17+
@Schema(description = "현재 Job을 소유한 Worker 식별자", example = "7")
18+
Long workerId,
19+
20+
@Schema(description = "Lease 갱신 기준 시각", example = "2026-08-03T15:00:00")
21+
LocalDateTime renewedAt,
22+
23+
@Schema(description = "갱신된 Lease 만료 시각", example = "2026-08-03T15:05:00")
24+
LocalDateTime lockExpiresAt
25+
) {
26+
}

src/main/java/com/opensource/docgrid/domain/embedding/entity/EmbeddingJob.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,41 @@ public boolean hasRemainingRetries() {
219219
return retryCount < maxRetryCount;
220220
}
221221

222+
/**
223+
* 현재 PROCESSING Claim의 최초 잠금 시각과 소유권은 유지하고 Lease 만료 시각만 연장한다.
224+
*
225+
* <p>Service가 Job 행 잠금, 현재 Worker·Token과 기존 Lease 유효성을 먼저 검증해야 한다. Entity는
226+
* 갱신이 기존 만료 시각을 줄이거나 이미 끝난 Job에 새 소유권처럼 적용되는 것을 마지막으로 방어한다.
227+
*
228+
* @param renewedAt Lease 갱신 기준 시각
229+
* @param renewedLockExpiresAt 새 Lease 만료 시각
230+
*/
231+
public void renewLease(
232+
LocalDateTime renewedAt,
233+
LocalDateTime renewedLockExpiresAt
234+
) {
235+
// 1. 현재 소유권을 가진 처리 중 Job 이외의 종료·대기 상태는 갱신하지 않는다.
236+
if (status != EmbeddingJobStatus.PROCESSING) {
237+
throw new IllegalStateException("PROCESSING 상태의 Job Lease만 갱신할 수 있습니다.");
238+
}
239+
// 2. 현재 Lease가 이미 만료됐거나 소유권 시간이 누락된 모순 상태를 갱신으로 숨기지 않는다.
240+
if (renewedAt == null
241+
|| lockedAt == null
242+
|| lockExpiresAt == null
243+
|| !lockExpiresAt.isAfter(renewedAt)) {
244+
throw new IllegalStateException("유효한 현재 Lease만 갱신할 수 있습니다.");
245+
}
246+
// 3. 새 만료 시각은 갱신 기준 이후이며 기존 만료 시각을 실제로 연장해야 한다.
247+
if (renewedLockExpiresAt == null
248+
|| !renewedLockExpiresAt.isAfter(renewedAt)
249+
|| !renewedLockExpiresAt.isAfter(lockExpiresAt)) {
250+
throw new IllegalArgumentException("새 Lease 만료 시각은 현재 Lease보다 늦어야 합니다.");
251+
}
252+
253+
// 4. lockedAt, Worker와 Claim Token은 같은 Claim 세대의 감사·소유권 정보이므로 보존한다.
254+
this.lockExpiresAt = renewedLockExpiresAt;
255+
}
256+
222257
public void markFailed(String errorCode, String errorMessage, LocalDateTime failedAt) {
223258
// 현재 Claim을 보유한 처리 중 Job만 최종 실패로 종결할 수 있다.
224259
if (status != EmbeddingJobStatus.PROCESSING) {

0 commit comments

Comments
 (0)