배경
#286(RAG 답변 생성이 PROCESSING 상태로 무기한 대기하는 문제 수정)에서 RagJobTimeoutSweeper를
추가하면서, "스위퍼 → Worker" 방향의 경합만 조건부 UPDATE(forceFailIfProcessing,
WHERE id=? AND status='PROCESSING')로 막았다. Worker가 이미 정상 완료한 job을 스위퍼가
뒤늦게 덮어쓰지 못하도록 하는 안전장치다.
반대 방향("Worker → 스위퍼", 즉 스위퍼가 먼저 타임아웃 처리한 job을 Worker가 뒤늦게 정상
완료 처리로 덮어쓰는 경우)은 막혀 있지 않다. RagResponseCommandService.completeSuccess()/
completeFailed()는 여전히 일반 dirty-checking UPDATE라 조건이 전혀 없다.
(#286 PR #287에 대한 CodeRabbit 리뷰에서 지적됨)
문제 시나리오
t=0s job 생성, PROCESSING. 큐에 밀려있어 Worker가 아직 못 집음
t=85s Worker가 이 job을 집어 처리 시작 (Ollama 호출, 오래 걸림)
t=90s RagJobTimeoutSweeper가 "90초 지남" 판단 → forceFailIfProcessing()으로
FAILED + fallback 답변 커밋 성공 (이 시점 status는 아직 PROCESSING이라 조건 통과)
→ WebSocket 알림 → 프론트는 폴링/소켓을 닫고 fallback 답변 표시, 대기 종료
t=115s Worker의 Ollama 호출이 뒤늦게 완료됨
→ completeSuccess() 호출, 조건 없이 그냥 UPDATE
→ DB의 FAILED 상태를 SUCCESS + 진짜 답변으로 무조건 덮어씀
→ RagJobWorker가 notifyAnswerReady()를 또 호출하지만, 프론트는 이미
t=90s에 구독을 닫아서 아무도 받지 못함
결과:
- DB엔 최종적으로 "정상 성공"이 남지만, 사용자는 실제로 fallback 답변을 보고 끝났다 —
"무슨 일이 실제로 있었는지"에 대한 기록 자체가 실제 사용자 경험과 어긋난다(감사/분석 시
잘못된 그림을 준다).
- GPU/Worker가 1개뿐인데, 이미 아무도 안 볼 답을 계산하느라 그 시간만큼 뒤에 대기 중인
다른 job의 처리가 늦어진다 — #286이 해결하려던 큐 적체 문제를 스스로 더 악화시키는 셈.
수정 방향
completeSuccess()/completeFailed()도 forceFailIfProcessing()과 동일한 패턴으로
WHERE status='PROCESSING' 조건부 UPDATE로 바꾼다.
RagResponseRepository: completeSuccessIfProcessing(...) 신규 추가(성공 시 채우는
필드 전용). completeFailed() 쪽은 이미 있는 forceFailIfProcessing()을 그대로 재사용
가능(같은 SQL 형태).
RagResponseCommandService.completeSuccess()/completeFailed(): 반환 타입을
void → boolean(영향받은 행 > 0)으로 변경.
RagFacade.processJob(): 반환값을 확인해 0건이면(=스위퍼가 이미 먼저 확정함) citation
저장을 스킵하고 반환값(boolean)을 그대로 상위로 전파.
RagJobWorker.processNext(): processJob()의 반환값이 false면 notifyAnswerReady()를
호출하지 않는다(이미 스위퍼가 알림을 보냈으므로 중복/불필요한 알림 방지).
영향 범위
RagResponseRepository, RagResponseCommandService, RagFacade, RagJobWorker와 이
네 곳에 딸린 기존 테스트(RagResponseCommandServiceTest, RagFacadeTest,
RagJobWorkerTest) 전부 — #286 이전부터 있던 정상 완료 경로를 건드리는 리팩터링이라
#286과 분리해서 진행한다.
Acceptance Criteria
배경
#286(RAG 답변 생성이 PROCESSING 상태로 무기한 대기하는 문제 수정)에서
RagJobTimeoutSweeper를추가하면서, "스위퍼 → Worker" 방향의 경합만 조건부 UPDATE(
forceFailIfProcessing,WHERE id=? AND status='PROCESSING')로 막았다. Worker가 이미 정상 완료한 job을 스위퍼가뒤늦게 덮어쓰지 못하도록 하는 안전장치다.
반대 방향("Worker → 스위퍼", 즉 스위퍼가 먼저 타임아웃 처리한 job을 Worker가 뒤늦게 정상
완료 처리로 덮어쓰는 경우)은 막혀 있지 않다.
RagResponseCommandService.completeSuccess()/completeFailed()는 여전히 일반 dirty-checking UPDATE라 조건이 전혀 없다.(#286 PR #287에 대한 CodeRabbit 리뷰에서 지적됨)
문제 시나리오
결과:
"무슨 일이 실제로 있었는지"에 대한 기록 자체가 실제 사용자 경험과 어긋난다(감사/분석 시
잘못된 그림을 준다).
다른 job의 처리가 늦어진다 — #286이 해결하려던 큐 적체 문제를 스스로 더 악화시키는 셈.
수정 방향
completeSuccess()/completeFailed()도forceFailIfProcessing()과 동일한 패턴으로WHERE status='PROCESSING'조건부 UPDATE로 바꾼다.RagResponseRepository:completeSuccessIfProcessing(...)신규 추가(성공 시 채우는필드 전용).
completeFailed()쪽은 이미 있는forceFailIfProcessing()을 그대로 재사용가능(같은 SQL 형태).
RagResponseCommandService.completeSuccess()/completeFailed(): 반환 타입을void→boolean(영향받은 행 > 0)으로 변경.RagFacade.processJob(): 반환값을 확인해 0건이면(=스위퍼가 이미 먼저 확정함) citation저장을 스킵하고 반환값(boolean)을 그대로 상위로 전파.
RagJobWorker.processNext():processJob()의 반환값이 false면notifyAnswerReady()를호출하지 않는다(이미 스위퍼가 알림을 보냈으므로 중복/불필요한 알림 방지).
영향 범위
RagResponseRepository,RagResponseCommandService,RagFacade,RagJobWorker와 이네 곳에 딸린 기존 테스트(
RagResponseCommandServiceTest,RagFacadeTest,RagJobWorkerTest) 전부 — #286 이전부터 있던 정상 완료 경로를 건드리는 리팩터링이라#286과 분리해서 진행한다.
Acceptance Criteria
completeSuccess/completeFailed가 조건부 UPDATE로 동작하며, 이미 FAILED로확정된 job에 대해서는 아무것도 덮어쓰지 않는다(영향받은 행 0건)
(통합 또는 @DataJpaTest 수준)