-
Notifications
You must be signed in to change notification settings - Fork 1
[Fix] RAG 답변 생성이 PROCESSING 상태로 무기한 대기하는 문제 수정 #287
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 6 commits
4b69fc3
f50874c
52d195b
c91d89f
243a48b
9f13340
bb99d09
85117fc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| package com.opensource.docgrid.domain.rag.service; | ||
|
|
||
| import java.time.Duration; | ||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
|
|
||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.scheduling.annotation.Scheduled; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import com.opensource.docgrid.domain.rag.controller.RagWebSocketController; | ||
| import com.opensource.docgrid.domain.rag.entity.RagResponse; | ||
| import com.opensource.docgrid.domain.rag.repository.RagResponseRepository; | ||
| import com.opensource.docgrid.domain.search.enums.ResultStatus; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import lombok.extern.slf4j.Slf4j; | ||
|
|
||
| /** | ||
| * PROCESSING 상태로 너무 오래 남아있는 RagResponse를 강제로 FAILED 종료시키는 안전망 (#286). | ||
| * | ||
| * <p>RagJobWorker는 Ollama가 GPU 1개로 순차 처리된다는 전제 위에서 동작하는데(RagJobWorker | ||
| * 클래스 Javadoc 참고), 이 순차 처리 자체에는 대기 시간 상한이 없다 — 앞선 job이 정상 흐름이든 | ||
| * hang이든 끝나야 다음 job이 처리된다. 이 스위퍼는 RagJobWorker와 별도의 주기로 폴링하며, | ||
| * 일정 시간(rag.worker.stale-threshold) 이상 PROCESSING인 job을 찾아 기존 extractive fallback | ||
| * 답변({@link RagFacade#failIfStillProcessing})으로 강제 종료시켜, 사용자가 무기한 대기하지 | ||
| * 않도록 상한을 만든다. | ||
| * | ||
| * <p>RagJobWorker가 같은 job을 이 스위퍼와 거의 동시에 정상 완료할 수 있는 경합은 | ||
| * {@link RagFacade#failIfStillProcessing}이 내부적으로 쓰는 조건부 UPDATE로 방지된다 — 이미 | ||
| * 끝난 job이면 아무 일도 일어나지 않는다. | ||
| */ | ||
| @Component | ||
| @RequiredArgsConstructor | ||
| @Slf4j | ||
| public class RagJobTimeoutSweeper { | ||
|
|
||
| private final RagResponseRepository ragResponseRepository; | ||
| private final RagFacade ragFacade; | ||
| private final RagWebSocketController ragWebSocketController; | ||
|
|
||
| @Value("${rag.worker.stale-threshold:90s}") | ||
| private Duration staleThreshold; | ||
|
|
||
| /** | ||
| * stale-threshold 이상 PROCESSING으로 남아있는 job을 전부 찾아 하나씩 강제 종료한다. | ||
| * 이 메서드 자체는 트랜잭션이 아니다 — {@link RagFacade#failIfStillProcessing}이 job마다 | ||
| * 독립된 트랜잭션으로 실행되므로, 하나가 실패해도 나머지 job 처리에 영향을 주지 않는다. | ||
| */ | ||
| @Scheduled(fixedDelayString = "${rag.worker.timeout-sweep-interval:15s}") | ||
| public void sweep() { | ||
| LocalDateTime cutoff = LocalDateTime.now().minus(staleThreshold); | ||
| List<RagResponse> staleJobs = | ||
| ragResponseRepository.findByStatusAndCreatedAtBefore(ResultStatus.PROCESSING, cutoff); | ||
|
|
||
| for (RagResponse job : staleJobs) { | ||
| Long queryId = job.getQuery().getId(); | ||
| String userEmail = job.getQuery().getUser().getEmail(); | ||
|
|
||
| if (ragFacade.failIfStillProcessing(job.getId(), queryId)) { | ||
| log.warn("[RAG-SWEEP] stale job force-failed queryId={} responseId={}", queryId, job.getId()); | ||
| ragWebSocketController.notifyAnswerReady(userEmail, queryId); | ||
| } | ||
|
Comment on lines
+58
to
+70
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 한 job의 예외가 이후 sweep을 중단하지 않게 하십시오.
각 job 처리를 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| package com.opensource.docgrid.domain.rag.repository; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
|
|
||
| import org.junit.jupiter.api.DisplayName; | ||
| import org.junit.jupiter.api.Test; | ||
| 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.test.context.ActiveProfiles; | ||
|
|
||
| import com.opensource.docgrid.domain.embedding.entity.EmbeddingModel; | ||
| import com.opensource.docgrid.domain.embedding.fixture.EmbeddingModelFixture; | ||
| import com.opensource.docgrid.domain.embedding.repository.EmbeddingModelRepository; | ||
| import com.opensource.docgrid.domain.rag.entity.RagResponse; | ||
| import com.opensource.docgrid.domain.search.entity.SearchQuery; | ||
| import com.opensource.docgrid.domain.search.enums.ResultStatus; | ||
| import com.opensource.docgrid.domain.search.enums.SearchType; | ||
| import com.opensource.docgrid.domain.search.repository.SearchQueryRepository; | ||
| import com.opensource.docgrid.domain.user.entity.User; | ||
| import com.opensource.docgrid.domain.user.enums.UserStatus; | ||
| import com.opensource.docgrid.domain.user.repository.UserRepository; | ||
|
|
||
| /** | ||
| * RagJobTimeoutSweeper(#286)가 의존하는 두 쿼리를 실제 PostgreSQL Repository 계층에서 | ||
| * 검증한다. 특히 {@code forceFailIfProcessing()}의 "이미 끝난 job은 절대 덮어쓰지 않는다"는 | ||
| * 조건부 UPDATE 정합성은 이번 수정의 핵심 안전장치라 Mockito 단위 테스트로는 증명할 수 없고, | ||
| * 실제 SQL이 실행되는 이 계층에서만 검증할 수 있다. | ||
| */ | ||
| @DataJpaTest | ||
| @ActiveProfiles("test") | ||
| @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) | ||
| @DisplayName("RagResponseRepository 테스트") | ||
| class RagResponseRepositoryTest { | ||
|
|
||
| @Autowired | ||
| private RagResponseRepository ragResponseRepository; | ||
|
|
||
| @Autowired | ||
| private SearchQueryRepository searchQueryRepository; | ||
|
|
||
| @Autowired | ||
| private UserRepository userRepository; | ||
|
|
||
| @Autowired | ||
| private EmbeddingModelRepository embeddingModelRepository; | ||
|
|
||
| @Test | ||
| @DisplayName("forceFailIfProcessing: PROCESSING인 job은 FAILED로 강제 종료되고 영향받은 행이 1건이다") | ||
| void forceFailIfProcessing_processingJob_updatesToFailedAndReturnsOne() { | ||
| RagResponse job = saveRagResponse(ResultStatus.PROCESSING); | ||
|
|
||
| int updated = ragResponseRepository.forceFailIfProcessing(job.getId(), "fallback 답변", "타임아웃"); | ||
|
|
||
| assertThat(updated).isEqualTo(1); | ||
| RagResponse reloaded = ragResponseRepository.findById(job.getId()).orElseThrow(); | ||
| assertThat(reloaded.getStatus()).isEqualTo(ResultStatus.FAILED); | ||
| assertThat(reloaded.getAnswerText()).isEqualTo("fallback 답변"); | ||
| assertThat(reloaded.getErrorMessage()).isEqualTo("타임아웃"); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("forceFailIfProcessing: 이미 SUCCESS로 끝난 job은 덮어쓰지 않고 영향받은 행이 0건이다") | ||
| void forceFailIfProcessing_alreadySucceededJob_doesNotOverwriteAndReturnsZero() { | ||
| RagResponse job = saveRagResponse(ResultStatus.PROCESSING); | ||
| job.markSuccess("실제 답변", "qwen2.5:7b", 100, 20, 900); | ||
| ragResponseRepository.saveAndFlush(job); | ||
|
|
||
| // RagJobWorker가 이 순간 이미 SUCCESS로 커밋한 상황을 재현한다 — 스위퍼의 강제 종료는 | ||
| // 이 시점 이후 실행돼도 status 조건이 안 맞아 아무것도 바꾸면 안 된다. | ||
| int updated = ragResponseRepository.forceFailIfProcessing(job.getId(), "fallback 답변", "타임아웃"); | ||
|
|
||
| assertThat(updated).isEqualTo(0); | ||
| RagResponse reloaded = ragResponseRepository.findById(job.getId()).orElseThrow(); | ||
| assertThat(reloaded.getStatus()).isEqualTo(ResultStatus.SUCCESS); | ||
| assertThat(reloaded.getAnswerText()).isEqualTo("실제 답변"); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("findByStatusAndCreatedAtBefore: cutoff 이전에 생성된 PROCESSING만 찾고, 상태가 다른 job은 제외한다") | ||
| void findByStatusAndCreatedAtBefore_filtersOnStatusAndCreatedAt() { | ||
| LocalDateTime beforeAnyCreation = LocalDateTime.now(); | ||
| RagResponse processingJob = saveRagResponse(ResultStatus.PROCESSING); | ||
| saveRagResponse(ResultStatus.SUCCESS); | ||
|
|
||
| // cutoff가 두 job이 생성되기 전 시점이면(=아직 아무 job도 이 시간만큼 오래 기다리지 않음) | ||
| // 아무것도 찾지 못해야 한다. | ||
| assertThat(ragResponseRepository.findByStatusAndCreatedAtBefore(ResultStatus.PROCESSING, beforeAnyCreation)) | ||
| .isEmpty(); | ||
|
|
||
| LocalDateTime afterCreation = LocalDateTime.now(); | ||
| List<RagResponse> result = | ||
| ragResponseRepository.findByStatusAndCreatedAtBefore(ResultStatus.PROCESSING, afterCreation); | ||
|
|
||
| assertThat(result).extracting(RagResponse::getId).containsExactly(processingJob.getId()); | ||
| } | ||
|
|
||
| private RagResponse saveRagResponse(ResultStatus status) { | ||
| User user = userRepository.save(User.builder() | ||
| .email("rag-repo-test-" + System.nanoTime() + "@test.local") | ||
| .passwordHash("x") | ||
| .name("RAG저장소테스트유저") | ||
| .status(UserStatus.ACTIVE) | ||
| .build()); | ||
| EmbeddingModel model = embeddingModelRepository.save( | ||
| EmbeddingModelFixture.createModel("rag-repo-test-" + System.nanoTime(), false, false)); | ||
| SearchQuery query = searchQueryRepository.save(SearchQuery.builder() | ||
| .user(user) | ||
| .queryText("테스트 질문") | ||
| .queryEmbeddingModel(model) | ||
| .queryVector(new float[1024]) | ||
| .searchType(SearchType.VECTOR) | ||
| .topK(5) | ||
| .status(ResultStatus.SUCCESS) | ||
| .build()); | ||
| return ragResponseRepository.save(RagResponse.builder() | ||
| .query(query) | ||
| .promptText("프롬프트") | ||
| .status(status) | ||
| .build()); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
Repository: DocGrid/docgrid
Length of output: 21327
🏁 Script executed:
Repository: DocGrid/docgrid
Length of output: 24524
🏁 Script executed:
Repository: DocGrid/docgrid
Length of output: 50371
Worker의 늦은 완료가 timeout 결과를 덮어쓰지 못하게 하십시오.
RagFacade.processJob()은 Ollama 호출 중에도 트랜잭션을 유지합니다.RagResponse에는@Version이 없고,completeSuccess()와completeFailed()는 일반 dirty checking으로 상태를 저장합니다.Worker가
PROCESSING행을 읽은 뒤 스위퍼가forceFailIfProcessing()으로FAILED를 커밋하면, Worker의 후속 저장이 timeout 결과를SUCCESS또는 다른FAILED결과로 덮어쓸 수 있습니다. Worker의 최종 저장을status = PROCESSING조건부 UPDATE로 통일하거나@Version기반 충돌 처리를 추가하고, 해당 경합을 재현하는 통합 테스트를 추가하십시오.🤖 Prompt for AI Agents