Skip to content

Commit ca1d3e0

Browse files
authored
Merge pull request #94 from DocGrid/feature/92
[Feat] Worker 자동 Polling 및 인덱싱 실행 오케스트레이션
2 parents 22e6ebf + 13263a7 commit ca1d3e0

25 files changed

Lines changed: 3116 additions & 2 deletions

docs/design/Gimini-3-#92-worker-polling-indexing-orchestration.md

Lines changed: 427 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# #92 Worker Polling 및 인덱싱 실행 오케스트레이션 검증 결과
2+
3+
## 1. 검증 정보
4+
5+
- 실행일: 2026-08-03 (Asia/Seoul)
6+
- 대상 브랜치: `feature/92`
7+
- 애플리케이션: Spring Boot 3.5.16, Java 17
8+
- 데이터베이스: Docker Desktop의 격리된 PostgreSQL 14.6(OpenSQL 호환) + pgvector
9+
- 스키마: Flyway V1~V35 적용, 통합 테스트별 격리 스키마 사용
10+
- 최종 결과: 정규 테스트 576개와 동시성 테스트 10개 통과, 실패·오류·Skip 0개
11+
12+
검증에는 localhost에만 노출한 작업 전용 컨테이너와 데이터 볼륨을 사용했다. 기존
13+
`local-opensql` 컨테이너와 `opensql_data` 공유 볼륨은 변경하지 않았다. 검증 종료 후 작업 전용
14+
컨테이너와 볼륨은 삭제했으며, 운영 Secret은 사용하거나 기록하지 않았다.
15+
16+
## 2. Swagger/OpenAPI 수동 검증
17+
18+
- 결과: 해당 없음
19+
- 근거: 이번 변경은 Worker 내부 Scheduler, 실행 Service와 설정만 추가하며 Controller, 요청·응답 DTO,
20+
Endpoint 및 OpenAPI 계약을 변경하지 않는다.
21+
22+
따라서 Swagger에서 호출할 신규·변경 API가 없으며, Worker 내부 실행 계약은 아래 자동 테스트와 실제
23+
PostgreSQL 동시성 테스트로 검증했다.
24+
25+
## 3. 전체 정규 테스트
26+
27+
실행 명령의 환경 값은 Placeholder로 대체한다.
28+
29+
```bash
30+
DB_HOST=localhost \
31+
DB_PORT='<isolated-test-port>' \
32+
DB_NAME=docgrid \
33+
DB_USER='<local-test-user>' \
34+
DB_PASSWORD='<local-test-password>' \
35+
DB_SSLMODE=disable \
36+
JWT_SECRET='<test-only-secret>' \
37+
./gradlew test
38+
```
39+
40+
결과:
41+
42+
```text
43+
BUILD SUCCESSFUL
44+
test suites=87 tests=576 failures=0 errors=0 skipped=0
45+
```
46+
47+
기본 `test` Task에서 Worker 등록·Heartbeat·상태 관리, Polling Scheduler, 실행 슬롯, 파이프라인,
48+
Lease 갱신, 실패 보고, 종료 절차와 기존 도메인 회귀 테스트를 함께 검증했다.
49+
50+
## 4. PostgreSQL 동시성 검증
51+
52+
실행:
53+
54+
```bash
55+
DB_HOST=localhost \
56+
DB_PORT='<isolated-test-port>' \
57+
DB_NAME=docgrid \
58+
DB_USER='<local-test-user>' \
59+
DB_PASSWORD='<local-test-password>' \
60+
DB_SSLMODE=disable \
61+
JWT_SECRET='<test-only-secret>' \
62+
./gradlew claimConcurrencyTest
63+
```
64+
65+
결과:
66+
67+
```text
68+
BUILD SUCCESSFUL
69+
test suites=3 tests=10 failures=0 errors=0 skipped=0
70+
```
71+
72+
| 테스트 클래스 | 테스트 수 | 결과 |
73+
| --- | ---: | --- |
74+
| `EmbeddingJobClaimConcurrencyIntegrationTest` | 2 | 통과 |
75+
| `EmbeddingJobLeaseRecoveryIntegrationTest` | 4 | 통과 |
76+
| `WorkerOrchestrationIntegrationTest` | 4 | 통과 |
77+
78+
새 Worker 오케스트레이션 통합 테스트는 다음 경쟁 조건을 실제 PostgreSQL 잠금과 트랜잭션으로
79+
검증했다.
80+
81+
| 검증 항목 | 결과 |
82+
| --- | --- |
83+
| 두 Poller가 한 Job에 경쟁할 때 Claim과 파이프라인 제출이 한 번만 발생 | 통과 |
84+
| 실행 슬롯 2개인 Worker가 5개 Job 중 2개만 PROCESSING으로 Claim | 통과 |
85+
| 활성 실행의 Lease 갱신이 원래 만료 시각의 복구를 차단 | 통과 |
86+
| Lease 갱신 중단 후 동시 복구가 Job을 정확히 한 번만 재예약 | 통과 |
87+
88+
## 5. 표준 빌드
89+
90+
동일한 격리 DB와 테스트 전용 인증 설정에서 다음 명령을 실행했다.
91+
92+
```bash
93+
./gradlew build
94+
```
95+
96+
결과: `BUILD SUCCESSFUL`. Compile, Test, Check, Boot JAR 및 JAR 생성 단계가 모두 성공했다.
97+
98+
## 6. 확인된 불변식
99+
100+
- 여러 Worker가 같은 대기 Job을 조회해도 PostgreSQL Claim은 한 Worker에만 귀속된다.
101+
- 한 Worker가 소유하는 PROCESSING Job 수는 로컬 실행 슬롯 수를 넘지 않는다.
102+
- 슬롯이 없을 때 Poller는 추가 Job을 Claim하지 않고 다음 주기를 기다린다.
103+
- 활성 파이프라인은 완료 전까지 Lease를 갱신하며, 갱신된 Job은 이전 만료 시각에 복구되지 않는다.
104+
- Lease 갱신이 멈춘 만료 Job에 여러 복구 실행이 경쟁해도 Retry 전이는 한 번만 적용된다.
105+
- 성공·재시도·최종 실패 경로에서 Lease 갱신과 실행 슬롯은 정리된다.
106+
- 종료 요청 후 새 Polling은 시작되지 않고, 대기 중인 실행은 제한 시간 정책에 따라 정리된다.
107+
108+
## 7. 환경 진단 기록과 제한 사항
109+
110+
- 기존 공유 OpenSQL 볼륨은 이미지가 기대하는 내부 Role과 초기화 상태가 달라 사용할 수 없었다.
111+
공유 데이터를 수정하지 않고 별도 작업 전용 컨테이너와 볼륨으로 전환했다.
112+
- 최초 일부 기존 통합 테스트 실행은 테스트용 JWT 환경 값 누락으로 Spring Context 구성 단계에서
113+
실패했다. 테스트 전용 값을 제공한 재실행과 최종 전체 실행은 모두 통과했다.
114+
- 프로젝트 기본 `test` Task가 제외하는 `claim-concurrency`는 전용 Task로 별도 실행했다.
115+
- 외부 MinIO 의존 테스트, 실제 임베딩 서버 네트워크 동작과 Benchmark는 이번 검증 범위가 아니다.
116+
- 테스트용 컨테이너와 볼륨은 종료 시 삭제했으므로 그 안의 데이터는 복구하지 않는다.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.opensource.docgrid.domain.document.service.query;
2+
3+
import org.springframework.stereotype.Service;
4+
import org.springframework.transaction.annotation.Transactional;
5+
6+
import com.opensource.docgrid.domain.document.enums.DocumentVersionStatus;
7+
import com.opensource.docgrid.domain.document.repository.DocumentVersionRepository;
8+
import com.opensource.docgrid.global.exception.DocGridException;
9+
import com.opensource.docgrid.global.exception.ErrorCode;
10+
11+
import lombok.RequiredArgsConstructor;
12+
13+
/**
14+
* Worker 파이프라인의 시작 단계를 결정할 문서 버전 상태 Snapshot을 조회한다.
15+
*
16+
* <p>조회 결과는 경로 선택에만 사용하며 Entity를 Worker 계층에 전달하지 않는다. 실제 상태 변경 가능
17+
* 여부와 소유권은 각 Command Service가 Job과 Version을 잠근 뒤 다시 검증한다.
18+
*/
19+
@Service
20+
@RequiredArgsConstructor
21+
@Transactional(readOnly = true)
22+
public class DocumentIndexingStageQueryService {
23+
24+
private final DocumentVersionRepository documentVersionRepository;
25+
26+
/**
27+
* Claim 응답이 가리키는 문서 버전의 현재 파이프라인 상태를 반환한다.
28+
*/
29+
public DocumentVersionStatus getStatus(Long documentVersionId) {
30+
return documentVersionRepository.findById(documentVersionId)
31+
.map(documentVersion -> documentVersion.getStatus())
32+
.orElseThrow(() -> new DocGridException(ErrorCode.INDEXING_STATUS_INCONSISTENT));
33+
}
34+
}

src/main/java/com/opensource/docgrid/domain/worker/config/IndexingWorkerProperties.java

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import lombok.Setter;
1515

1616
/**
17-
* 인덱싱 Worker의 실행 여부, Heartbeat, DEAD 판정, Job Lease와 만료 복구를 바인딩하는 설정 클래스.
17+
* 인덱싱 Worker의 실행 여부, Polling, 동시 실행, Heartbeat와 Job Lease 생명주기를 바인딩하는 설정 클래스.
1818
*
1919
* <p>{@code indexing.worker} 환경 설정을 타입 안전한 {@link Duration}으로 제공하고, 애플리케이션 시작
2020
* 단계에서 서로 모순되거나 0 이하인 시간 설정을 차단한다.
@@ -38,10 +38,21 @@ public class IndexingWorkerProperties {
3838
@NotNull
3939
private Duration deadThreshold = Duration.ofSeconds(30);
4040

41+
// 등록된 Worker가 실행 슬롯을 확인하고 새 Job을 찾는 주기다.
42+
@NotNull
43+
private Duration pollingInterval = Duration.ofSeconds(1);
44+
45+
@Min(1)
46+
private int maxConcurrency = 2;
47+
4148
// Claim 후 Worker가 소유권을 유지하는 기본 시간이다. 만료 복구는 후속 처리에서 사용한다.
4249
@NotNull
4350
private Duration leaseDuration = Duration.ofMinutes(5);
4451

52+
// 활성 실행은 Lease 만료 전에 이 주기로 소유권을 갱신한다.
53+
@NotNull
54+
private Duration leaseRenewalInterval = Duration.ofMinutes(1);
55+
4556
// 만료 Lease 복구 작업의 실행 주기와 한 번에 조회할 최대 Job 수다.
4657
@NotNull
4758
private Duration leaseRecoveryInterval = Duration.ofSeconds(30);
@@ -56,6 +67,10 @@ public class IndexingWorkerProperties {
5667
@NotNull
5768
private Duration retryMaxDelay = Duration.ofMinutes(5);
5869

70+
// 종료 시 신규 Claim을 막은 뒤 활성 실행이 스스로 끝나기를 기다리는 최대 시간이다.
71+
@NotNull
72+
private Duration shutdownGracePeriod = Duration.ofSeconds(30);
73+
5974
/**
6075
* Heartbeat가 양수이고 DEAD 기준보다 짧은지 검증한다.
6176
*/
@@ -68,6 +83,16 @@ public boolean isTimingValid() {
6883
&& deadThreshold.compareTo(heartbeatInterval) > 0;
6984
}
7085

86+
/**
87+
* 빈 작업 조회가 Busy Loop가 되지 않도록 Polling 주기가 양수인지 검증한다.
88+
*/
89+
@AssertTrue(message = "Job Polling 주기는 0보다 커야 합니다.")
90+
public boolean isPollingIntervalValid() {
91+
return pollingInterval != null
92+
&& !pollingInterval.isZero()
93+
&& !pollingInterval.isNegative();
94+
}
95+
7196
/**
7297
* 발급 즉시 만료되는 Lease가 만들어지지 않도록 Lease 기간이 양수인지 검증한다.
7398
*/
@@ -78,6 +103,18 @@ public boolean isLeaseDurationValid() {
78103
&& !leaseDuration.isNegative();
79104
}
80105

106+
/**
107+
* 활성 Job이 만료 전에 갱신될 수 있도록 갱신 주기가 양수이고 Lease 기간보다 짧은지 검증한다.
108+
*/
109+
@AssertTrue(message = "Lease 갱신 주기는 0보다 크고 Lease 기간보다 짧아야 합니다.")
110+
public boolean isLeaseRenewalIntervalValid() {
111+
return leaseRenewalInterval != null
112+
&& leaseDuration != null
113+
&& !leaseRenewalInterval.isZero()
114+
&& !leaseRenewalInterval.isNegative()
115+
&& leaseRenewalInterval.compareTo(leaseDuration) < 0;
116+
}
117+
81118
/**
82119
* 만료 Lease 복구 Scheduler가 과도하게 반복되지 않도록 실행 주기가 양수인지 검증한다.
83120
*/
@@ -99,4 +136,13 @@ public boolean isRetryDelayValid() {
99136
&& !retryInitialDelay.isNegative()
100137
&& retryMaxDelay.compareTo(retryInitialDelay) >= 0;
101138
}
139+
140+
/**
141+
* 즉시 종료는 허용하되 음수 대기 시간은 Executor 종료 계약으로 사용할 수 없으므로 차단한다.
142+
*/
143+
@AssertTrue(message = "Worker 종료 유예 시간은 0보다 작을 수 없습니다.")
144+
public boolean isShutdownGracePeriodValid() {
145+
return shutdownGracePeriod != null
146+
&& !shutdownGracePeriod.isNegative();
147+
}
102148
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package com.opensource.docgrid.domain.worker.config;
2+
3+
import java.util.concurrent.ScheduledThreadPoolExecutor;
4+
import java.util.concurrent.SynchronousQueue;
5+
import java.util.concurrent.ThreadPoolExecutor;
6+
import java.util.concurrent.TimeUnit;
7+
8+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
9+
import org.springframework.context.annotation.Bean;
10+
import org.springframework.context.annotation.Configuration;
11+
import org.springframework.scheduling.concurrent.CustomizableThreadFactory;
12+
13+
import com.opensource.docgrid.domain.worker.execution.WorkerExecutionSlotPool;
14+
15+
/**
16+
* Worker Job 실행과 Lease 갱신에 사용하는 제한된 Thread 자원을 구성한다.
17+
*
18+
* <p>Job Executor는 Queue에 Claim을 쌓지 않고 설정된 동시성만 즉시 실행한다. Lease Scheduler는 활성
19+
* 실행의 짧은 갱신 호출만 담당하며, Worker가 비활성화된 API 전용 실행에는 어떤 Thread도 만들지 않는다.
20+
*/
21+
@Configuration
22+
@ConditionalOnProperty(prefix = "indexing.worker", name = "enabled", havingValue = "true")
23+
public class WorkerExecutionConfig {
24+
25+
public static final String WORKER_JOB_EXECUTOR = "workerJobExecutor";
26+
public static final String WORKER_LEASE_SCHEDULER = "workerLeaseScheduler";
27+
28+
/**
29+
* 최대 동시 실행 수와 같은 크기의 무대기 Job Executor를 만든다.
30+
*/
31+
@Bean(name = WORKER_JOB_EXECUTOR, destroyMethod = "shutdownNow")
32+
public ThreadPoolExecutor workerJobExecutor(IndexingWorkerProperties properties) {
33+
int maxConcurrency = properties.getMaxConcurrency();
34+
return new ThreadPoolExecutor(
35+
maxConcurrency,
36+
maxConcurrency,
37+
0L,
38+
TimeUnit.MILLISECONDS,
39+
new SynchronousQueue<>(),
40+
new CustomizableThreadFactory("indexing-worker-job-"),
41+
new ThreadPoolExecutor.AbortPolicy()
42+
);
43+
}
44+
45+
/**
46+
* 모든 활성 실행의 Lease 갱신을 직렬로 예약하는 단일 Thread Scheduler를 만든다.
47+
*/
48+
@Bean(name = WORKER_LEASE_SCHEDULER, destroyMethod = "shutdownNow")
49+
public ScheduledThreadPoolExecutor workerLeaseScheduler() {
50+
ScheduledThreadPoolExecutor scheduler = new ScheduledThreadPoolExecutor(
51+
1,
52+
new CustomizableThreadFactory("indexing-worker-lease-")
53+
);
54+
// 취소된 실행별 갱신 작업이 Scheduler Queue에 남아 종료와 메모리 회수를 늦추지 않게 한다.
55+
scheduler.setRemoveOnCancelPolicy(true);
56+
scheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
57+
scheduler.setContinueExistingPeriodicTasksAfterShutdownPolicy(false);
58+
return scheduler;
59+
}
60+
61+
/**
62+
* Claim 전에 실행 가능 여부를 예약하는 프로세스 로컬 슬롯 풀을 만든다.
63+
*/
64+
@Bean
65+
public WorkerExecutionSlotPool workerExecutionSlotPool(IndexingWorkerProperties properties) {
66+
return new WorkerExecutionSlotPool(properties.getMaxConcurrency());
67+
}
68+
}

0 commit comments

Comments
 (0)