Repository navigation
Fix/#61 company follow question - #62
Conversation
kimwuju
left a comment
There was a problem hiding this comment.
TTS 복구 경로에 회귀 1건 남깁니다. 준비 요청 잠금·종료 기록 확인, 답변 재전송 처리, 진행 중 합성·업로드 취소는 확인했고 문제를 찾지 못했습니다.
|
|
||
| private Mono<Void> recoverState(AudioState state) { | ||
| return redisTemplate.opsForValue().get(SESSION_KEY_PREFIX + state.getSessionId()) | ||
| return operationLock.execute(state.getSessionId(), () -> |
There was a problem hiding this comment.
같은 세션의 음성 상태가 여러 개면 재시작 복구 때 하나만 복구됩니다
recover()는 flatMap(this::recoverState)로 상태들을 동시에 처리하는데, 이제 상태마다 세션 잠금을 잡습니다. InterviewOperationLock은 기다리지 않고 바로 AlreadyUsingException을 내므로, 같은 세션의 상태가 여러 개면 처음 잠금을 잡은 하나만 복구되고 나머지는 onErrorContinue에서 "TTS 작업 복구 실패" 경고만 남기고 버려집니다.
준비 시점에 registerOriginalQuestions가 원질문 전부를 등록하고 perSessionMax 때문에 대부분 QUEUED로 남아 있어서, 재시작 시 진행 중 세션에는 보통 이런 상태가 여러 개 있습니다. 복구되지 못한 상태는 Redis에 QUEUED/GENERATING으로 남고 작업은 없으니, 그 질문이 현재 질문이 되거나 클라이언트가 오디오를 요청할 때에야 합성이 시작됩니다. 즉 재시작 후에는 다음 질문 미리 생성이 사라져 질문마다 음성을 기다리게 됩니다(registerCurrentQuestion의 changePriority(..., 1)도 작업이 없어 효과가 없습니다). 이 PR 전에는 전부 복구됐습니다.
재현: 실제 InterviewOperationLock에 메모리 맵으로 흉내 낸 setIfAbsent를 붙이고 세션 조회에 50ms 지연을 준 뒤, 같은 세션 상태 3개로 recover()를 돌리면 scheduler.register가 1번만 호출되고 나머지 2개는 AlreadyUsingException으로 건너뜁니다.
세션별로 묶어서 한 번만 잠그거나(groupBy(AudioState::getSessionId) 후 세션 단위로 execute), 적어도 concatMap으로 순서대로 처리해야 할 것 같습니다.
danjee2009
left a comment
There was a problem hiding this comment.
회귀 1건을 인라인으로 남겼습니다. 같은 세션의 복구 상태를 세션 잠금 아래 개별 병렬 처리하면서 잠금 경합으로 일부 TTS 복구 작업이 누락될 수 있습니다. 다른 변경 범위에서는 추가 문제를 확인하지 못했습니다. GitHub Actions의 테스트 작업은 통과 상태이며, 로컬 테스트는 재실행하지 않았습니다.
|
|
||
| private Mono<Void> recoverState(AudioState state) { | ||
| return redisTemplate.opsForValue().get(SESSION_KEY_PREFIX + state.getSessionId()) | ||
| return operationLock.execute(state.getSessionId(), () -> |
There was a problem hiding this comment.
[P2] 같은 세션의 복구 항목이 잠금 경합으로 누락될 수 있습니다. recover()가 findRecoverable() 결과에 flatMap을 사용하므로 동일 세션의 여러 QUEUED/GENERATING 상태가 병렬 처리됩니다. 각 항목이 여기서 같은 세션 잠금을 각각 요청하지만 InterviewOperationLock은 잠금이 이미 있으면 기다리지 않고 AlreadyUsingException을 반환하고, 바깥 onErrorContinue가 해당 항목을 건너뜁니다. 그 결과 재시작 후 일부 상태는 Redis에 남아도 스케줄러 작업은 복원되지 않아 해당 질문의 TTS가 미리 생성되지 않고 사용자 요청 시점까지 지연됩니다. 세션별로 상태를 묶어 잠금을 한 번만 잡거나, 같은 세션의 항목을 순차 복구하도록 처리해 주세요.
|
|
||
| private Mono<Void> recoverState(AudioState state) { | ||
| return redisTemplate.opsForValue().get(SESSION_KEY_PREFIX + state.getSessionId()) | ||
| return operationLock.execute(state.getSessionId(), () -> |
There was a problem hiding this comment.
다른 요청이 잠금을 잡고 있으면 그 상태는 복구되지 않고 그대로 버려집니다
concatMap으로 같은 세션 상태끼리의 충돌은 없어졌지만, 잠금을 다른 쪽이 잡고 있으면 여전히 AlreadyUsingException이 나고, onErrorResume이 WARN 로그만 남긴 채 그 상태를 건너뜁니다. recover()는 ApplicationReadyEvent에서 한 번만 돌기 때문에 다시 시도되지도 않습니다.
재시작 직후에는 이런 충돌이 실제로 생기기 쉽습니다.
- 죽은 프로세스가 잡고 있던 잠금이
LEASE(2분)가 끝날 때까지 남아 있는 경우 - 준비 이벤트 시점에는 이미 요청을 받고 있어서, 재접속한 사용자의 답변 제출(AI 꼬리질문 판단 포함)이 잠금을 잡고 있는 경우
이러면 그 질문의 QUEUED/GENERATING 상태가 스케줄러 작업 없이 Redis에 남아서, 음성을 미리 만들어 두지 못합니다. 사용자가 그 질문의 음성을 요청해야 InterviewAudioService가 다시 등록하므로 그만큼 기다리게 됩니다. 반대로 복구가 잠금을 잡은 순간에 들어온 사용자 요청은 ALREADY_IN_USE(409)를 받는데, 이 PR 전에는 없던 충돌입니다.
AlreadyUsingException이 나면 잠깐 기다렸다가 다시 시도하거나, 건너뛴 상태를 나중에 다시 처리하는 경로가 필요해 보입니다.
| image: redis:7-alpine | ||
| container_name: repit-redis | ||
| ports: | ||
| - "6379:6379" |
There was a problem hiding this comment.
Redis 포트 접근을 '제한'한 게 아니라 아예 '제거'했습니다
커밋 메시지는 "접근 범위 제한"인데, 실제로는 호스트 포트 매핑이 통째로 빠졌습니다. 부하 테스트 compose 쪽은 127.0.0.1:6380:6379로 바인딩 범위만 줄였습니다. 이렇게 되면 아래 두 경우가 깨집니다.
- 운영
.env의REDIS_HOST가 compose 서비스 이름(redis)이 아니라 호스트 주소를 가리키면, chat 컨테이너가 Redis에 연결하지 못합니다. - 이 compose로 Redis만 띄우고 기본 프로필(
localhost:6379)로bootRun하던 로컬 실행도 연결되지 않습니다.
외부 노출만 막는 게 목적이라면 "127.0.0.1:6379:6379"로 바인딩하면 기존 사용처가 깨지지 않습니다. 운영 .env의 REDIS_HOST 값도 한 번 확인 부탁드립니다.
kimwuju
left a comment
There was a problem hiding this comment.
코드 리뷰했습니다. 머지를 막을 버그는 찾지 못했습니다.
확인한 내용
- 종료 기록(outbox)이 있는 세션을 다시 준비하면 거부하고, 준비 요청은 IN_PROGRESS 상태만 받습니다. API 서버도 준비 요청에 IN_PROGRESS만 보내고 있어서 계약이 맞습니다.
- 답변 재시도: 이미 다음 질문으로 넘어간 뒤 같은 답변이 다시 오면 현재 질문을 돌려주고, 내용이 다른 답변이면 거부합니다. 같은 문항을 재시도하면 꼬리질문 판단부터 다시 진행합니다.
- 실행 중인 TTS 작업을 취소하면
takeUntilOther로 합성·업로드를 끊고,doFinally로 running과 jobs에서 정리합니다. 기다리던 쪽은 빈 값을 받은 뒤 저장된 상태를 다시 읽습니다. - TTS 복구가 잠금을 얻지 못해 건너뛰어도, 사용자가 음성을 요청할 때
InterviewAudioService가 작업이 없는 걸 보고 다시 등록하므로 음성 생성이 멈추지는 않습니다.
./gradlew test 결과 437개 테스트가 모두 통과했습니다. 단, LoadTestProfileTests는 로컬 셸에 ANTHROPIC_BASE_URL이 설정돼 있으면 실패하는데, 환경 변수 문제라서 PR과는 관계없습니다.
Fix/#61 company follow question
61