Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion docs/behavior-analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,35 @@ API 타임아웃은 기존 정책에 따라 대기 기록을 보존하고 종료
기존 `InterviewResultRedisTest`는 실제 로컬 Redis를 사용하여 장애 복구와 잠금 동작을 검증한다.

검증 명령은 Java 21에서 `./gradlew test`다.
이번 변경에서는 `./gradlew --offline --no-daemon test`로 전체 263개 테스트가 통과했고 실패·건너뜀은 없었다.
기존 식별자 보강 당시에는 `./gradlew --offline --no-daemon test`로 전체 263개 테스트가 통과했고 실패·건너뜀은 없었다.
수정 전에는 식별자 충돌 거절 4개 사례가 실패하는 것도 확인했다.
S3 업로드 순서와 API 분석 요청·콜백·Web 중복 업로드 방지는 해당 서버의 통합 검증 대상이다.
질문별 영상 시간축과 실시간 행동 분석은 이번 Chat 계약에 포함하지 않는다.

## 2026-10-21 종료 계약 재검증

기준은 fetch 후 `dcaa01f312921dc59f2fb18b19fc4016a33f2aa3`이며,
작업 브랜치는 `feature/#59-action-analyze`다. 기존 main의 조기 COMPLETE 거절과 소켓 실제 status 전달은 유지했다.

확인한 결함은 구 outbox의 `interviewStatus`가 null/IN_PROGRESS여도 재요청 응답을 COMPLETED로 만드는 fallback이다.
신규 outbox는 COMPLETED/ABANDONED만 받는다. 구 기록의 상태가 유효하지 않으면 원문·식별자를 보존하고
기존 실패 기록/알림 경로로 BLOCKED 처리한다. API로 전송하거나 성공 END를 반환하지 않는다.
답변 수·요청 동사로 상태를 보충하지 않는다. 이미 BLOCKED인 항목은 실패 횟수를 다시 늘리지 않는다.

원인 조사 시 최종 Redis 세션·최초 payload·API의 첫 저장 상태를 대조한다.
422와 누락 상태는 무한 재시도하지 않으며 기존 BLOCKED 복구 절차를 따른다.
최초 상태를 입증할 수 없는 기록을 임의 수정하지 않는다. 운영 데이터 조사·보정은 이번 변경에서 수행하지 않았다.

Java 21에서 `./gradlew --offline --no-daemon test`로 전체 375건 통과, 실패/skip 0건을 확인했다.
실제 로컬 redis-server를 사용하는 11건도 모두 실행했다. 추가 검사는 다음을 확인한다.

- 종료 상태 누락/IN_PROGRESS의 신규 등록 거절과 구 기록의 BLOCKED 보존.
- Redis 강제 종료·AOF 복원 후에도 같은 원문과 상태를 보존하고 잘못된 기록을 전송하지 않음.
- AI 꼬리질문 판단 대기 중 마지막 ANSWER/QUIT/COMPLETE 경합에서 공유 잠금 유지.
- AI 종료 후 첫 COMPLETED 유지와 늦은 ANSWER/QUIT에서 새 AI/TTS 작업이 생기지 않음.
- ABANDONED의 API 2xx를 분석 실행 여부와 관계없이 SENT로 처리.

준비 식별자·SOLO/MULTI 종료·REST/소켓·타임아웃 동일 payload 재전송·실제 HTTP 인증 헤더 검사는 기존 회귀에 포함된다.
API 응답은 대역/로컬 HTTP 서버로 확인했으며 실제 배포 API·S3·토큰 일치·업로드 순서는 미검증이다.
행동 워커 호출·행동 완료 대기는 Chat에 없으며 기존 면접/TEXT 경로에 추가하지 않았다.
운영 배포·병합·실제 outbox 정리는 수행하지 않았다.
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public class InterviewResultDeliveryService {
private final MeterRegistry meters;

public Mono<ChatProgressResponse> resume(String sessionId) {
return outbox.find(sessionId).flatMap(item ->
return outbox.find(sessionId).flatMap(this::requireTerminalStatus).flatMap(item ->
item.getState() == InterviewResultDelivery.State.BLOCKED
? Mono.error(new ApiServerResponseException("면접 기록 전송 설정 확인이 필요합니다."))
: Mono.just(response(item)));
Expand All @@ -35,6 +35,24 @@ public Mono<ChatProgressResponse> submit(ChatInterviewAllRequest request) {

// Call under InterviewOperationLock, shared by requests and recovery workers.
public Mono<InterviewResultDelivery> deliver(InterviewResultDelivery delivery) {
return Mono.defer(() -> requireTerminalStatus(delivery).flatMap(this::deliverValidated));
}

private Mono<InterviewResultDelivery> requireTerminalStatus(InterviewResultDelivery delivery) {
if (delivery.getInterviewStatus() == InterviewStatus.COMPLETED
|| delivery.getInterviewStatus() == InterviewStatus.ABANDONED) {
return Mono.just(delivery);
}
var error = new ApiServerResponseException("면접 전송 기록의 명시적 종료 상태 확인이 필요합니다.");
if (delivery.getState() == InterviewResultDelivery.State.BLOCKED
|| delivery.getState() == InterviewResultDelivery.State.SENT) {
return Mono.error(error);
}
// Preserve the original payload; never infer completion from legacy records or answers.
return recordFailure(delivery, error).then(Mono.error(error));
Comment on lines +41 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 이미 전송이 끝난(SENT) 구 기록도 BLOCKED로 바뀝니다.

requireTerminalStatus가 SENT/BLOCKED/PENDING 상태 확인보다 먼저 실행됩니다. 그래서 이 PR이 대상으로 삼는 interviewStatus null/IN_PROGRESS 구 기록 가운데, 이미 API 전송에 성공한 SENT 기록도 recordFailure를 탑니다. outbox 기록에는 TTL이 없어서 SENT 기록이 계속 남습니다. 그 세션으로 ANSWER/COMPLETE/QUIT 재요청이 오면 resume()에서 attempts가 늘고, state가 SENT에서 BLOCKED로 덮어써지고, 운영 확인 알림(repit.interview.result.attention)까지 나갑니다. 이 기록은 payload가 이미 null이어서 BLOCKED 복구 절차로 다시 보낼 원문도 없습니다.

이미 BLOCKED인 경우처럼, SENT 기록은 상태를 바꾸지 말고 오류만 반환하도록 분기해야 할 것 같습니다. 새로 추가한 테스트는 PENDING 기록만 다룹니다.

}

private Mono<InterviewResultDelivery> deliverValidated(InterviewResultDelivery delivery) {
return Mono.defer(() -> {
if (delivery.getState() == InterviewResultDelivery.State.BLOCKED) {
return Mono.error(new ApiServerResponseException("면접 기록 전송 설정 확인이 필요합니다."));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.person.repit.interview.domain.InterviewResultDelivery;
import com.person.repit.interview.dto.request.ChatInterviewAllRequest;
import com.person.repit.interview.type.InterviewStatus;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import org.springframework.data.redis.core.ScanOptions;
Expand All @@ -26,6 +27,10 @@ public Mono<InterviewResultDelivery> find(String sessionId) {

public Mono<InterviewResultDelivery> enqueue(ChatInterviewAllRequest request) {
return Mono.defer(() -> {
if (request.getStatus() != InterviewStatus.COMPLETED
&& request.getStatus() != InterviewStatus.ABANDONED) {
return Mono.error(new IllegalArgumentException("명시적 종료 상태가 필요합니다."));
}
InterviewResultDelivery delivery = new InterviewResultDelivery();
delivery.setSessionId(request.getSessionId());
delivery.setInterviewId(request.getInterviewId());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.NullSource;
import org.springframework.http.HttpHeaders;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
Expand Down Expand Up @@ -127,6 +129,74 @@ void setUp() {
assertThat(item.getState()).isEqualTo(InterviewResultDelivery.State.PENDING);
}

@ParameterizedTest
@NullSource
@EnumSource(value = InterviewStatus.class, names = "IN_PROGRESS")
void 구_전송기록의_상태누락은_완료로_승격하지_않고_차단한다(InterviewStatus status) {
item.setInterviewStatus(status);
String originalPayload = item.getPayload();
when(outbox.find("session-id")).thenReturn(Mono.just(item));
StepVerifier.create(service.resume("session-id")).expectError(ApiServerResponseException.class).verify();
assertThat(item.getState()).isEqualTo(InterviewResultDelivery.State.BLOCKED);
assertThat(item.getInterviewStatus()).isEqualTo(status);
assertThat(item.getPayload()).isEqualTo(originalPayload);
assertThat(item.getAttempts()).isEqualTo(1);
verify(outbox).save(item);
StepVerifier.create(service.deliver(item)).expectError(ApiServerResponseException.class).verify();
assertThat(item.getAttempts()).isEqualTo(1);
verifyNoInteractions(client);
}

@ParameterizedTest
@NullSource
@EnumSource(value = InterviewStatus.class, names = "IN_PROGRESS")
void 재전송도_종료상태가_없는_기록을_API로_전송하지_않는다(InterviewStatus status) {
item.setInterviewStatus(status);
StepVerifier.create(service.deliver(item)).expectError(ApiServerResponseException.class).verify();
assertThat(item.getState()).isEqualTo(InterviewResultDelivery.State.BLOCKED);
assertThat(item.getPayload()).isNotNull();
verify(outbox).save(item);
verifyNoInteractions(client);
}

@ParameterizedTest
@NullSource
@EnumSource(value = InterviewStatus.class, names = "IN_PROGRESS")
void 종료상태가_잘못된_SENT_구기록은_재요청에도_변경하지_않는다(InterviewStatus status) {
item.setInterviewStatus(status);
item.setState(InterviewResultDelivery.State.SENT);
item.setPayload(null);
item.setAttempts(2);
item.setFirstFailureAt(Instant.EPOCH);
item.setLastError("PREVIOUS_FAILURE");
var original = new InterviewResultDelivery();
org.springframework.beans.BeanUtils.copyProperties(item, original);
when(outbox.find("session-id")).thenReturn(Mono.just(item));

for (int attempt = 0; attempt < 2; attempt++) {
StepVerifier.create(service.resume("session-id"))
.expectError(ApiServerResponseException.class).verify();
StepVerifier.create(service.deliver(item))
.expectError(ApiServerResponseException.class).verify();
}

assertThat(item).usingRecursiveComparison().isEqualTo(original);
assertThat(meters.find("repit.interview.result.attention").meters()).isEmpty();
verify(outbox, never()).save(any());
verifyNoInteractions(client);
}

@Test
void 중단_기록의_2xx는_분석여부와_관계없이_전송완료다() {
item.setInterviewStatus(InterviewStatus.ABANDONED);
when(outbox.enqueue(any())).thenReturn(Mono.just(item));
when(client.saveInterviewPayload(item.getPayload())).thenReturn(Mono.empty());
var response = service.submit(ChatInterviewAllRequest.builder().status(InterviewStatus.ABANDONED).build()).block();
assertThat(response.getStatus()).isEqualTo(InterviewStatus.ABANDONED);
assertThat(item.getState()).isEqualTo(InterviewResultDelivery.State.SENT);
assertThat(item.getPayload()).isNull();
}

private WebClientResponseException httpError(int code) {
return WebClientResponseException.create(code, "failure", new HttpHeaders(), new byte[0], null);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.person.repit.interview.service;

import com.person.repit.common.config.JacksonConfig;
import com.person.repit.interview.dto.request.ChatInterviewAllRequest;
import com.person.repit.interview.type.InterviewStatus;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.NullSource;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import reactor.test.StepVerifier;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;

class InterviewResultOutboxTest {
@ParameterizedTest
@NullSource
@EnumSource(value = InterviewStatus.class, names = "IN_PROGRESS")
void 명시적_종료상태가_없으면_신규_outbox를_등록하지_않는다(InterviewStatus status) {
var redis = mock(ReactiveStringRedisTemplate.class);
var outbox = new InterviewResultOutbox(redis, new JacksonConfig().objectMapper());
var request = ChatInterviewAllRequest.builder().sessionId("s").status(status).build();
StepVerifier.create(outbox.enqueue(request)).expectError(IllegalArgumentException.class).verify();
verifyNoInteractions(redis);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.provider.NullSource;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
import reactor.core.publisher.Mono;
Expand Down Expand Up @@ -203,6 +204,88 @@ void stopRedis() throws Exception {
}
}

@ParameterizedTest
@NullSource
@EnumSource(value = InterviewStatus.class, names = "IN_PROGRESS")
void 재시작한_Redis의_종료상태_누락기록도_보존후_차단한다(InterviewStatus status) throws Exception {
var item = outbox.enqueue(ChatInterviewAllRequest.from(session())).block();
item.setInterviewStatus(status);
var mapper = new JacksonConfig().objectMapper();
var payload = (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(item.getPayload());
if (status == null) payload.remove("status");
else payload.put("status", status.name());
item.setPayload(mapper.writeValueAsString(payload));
String original = item.getPayload();
outbox.save(item).block();
process.destroyForcibly().waitFor();
startProcess();
connect();
var restoredOutbox = new InterviewResultOutbox(redis, mapper);
var client = mock(ApiServerClient.class);
var service = new InterviewResultDeliveryService(restoredOutbox, client, new InterviewResultRetryPolicy(),
new io.micrometer.core.instrument.simple.SimpleMeterRegistry());
var restored = restoredOutbox.find("s").block();
StepVerifier.create(lock.execute("s", () -> service.deliver(restored)))
.expectError(com.person.repit.interview.exception.ApiServerResponseException.class).verify();
var blocked = restoredOutbox.find("s").block();
assertThat(blocked.getState()).isEqualTo(InterviewResultDelivery.State.BLOCKED);
assertThat(blocked.getInterviewStatus()).isEqualTo(status);
assertThat(blocked.getPayload()).isEqualTo(original);
verifyNoInteractions(client);
}

@Test
void AI_판단중_마지막답변과_종료요청이_경합해도_종료를_되돌리지_않는다() {
var objects = new RedisConfig().reactiveRedisTemplate(factory);
var active = session();
active.setStatus(InterviewStatus.IN_PROGRESS);
active.setQuestions(new java.util.ArrayList<>(List.of(
ChatQuestion.builder().questionId(17L).content("첫 질문")
.type(com.person.repit.interview.type.QuestionType.ORIGINAL).build(),
ChatQuestion.builder().questionId(18L).content("마지막 질문")
.type(com.person.repit.interview.type.QuestionType.ORIGINAL).build())));
active.setAnswers(new java.util.ArrayList<>(active.getAnswers()));
active.setCurrentQuestionIndex(1);
objects.opsForValue().set("chat:interview:s", active).block();
var ai = mock(AiQuestionClient.class);
var called = reactor.core.publisher.Sinks.<Boolean>one();
var decision = reactor.core.publisher.Sinks.<com.person.repit.interview.dto.response.FollowQuestionAiResponse>one();
when(ai.decideFollowQuestion(any())).thenReturn(Mono.defer(() -> {
called.tryEmitValue(true);
return decision.asMono();
}));
var client = mock(ApiServerClient.class);
when(client.saveInterviewPayload(anyString())).thenReturn(Mono.empty());
var meters = new io.micrometer.core.instrument.simple.SimpleMeterRegistry();
var delivery = new InterviewResultDeliveryService(outbox, client, new InterviewResultRetryPolicy(), meters);
var tts = mock(InterviewTtsScheduler.class);
var service = new com.person.repit.interview.service.impl.ChatInterviewServiceImpl(ai, objects, delivery, lock,
new com.person.repit.common.metrics.RepitMetrics(meters),
mock(com.person.repit.tts.service.PersonaTtsProfileResolver.class), tts);
var answer = com.person.repit.interview.dto.request.ChatAnswerRequest.builder()
.questionId(18L).content("최종 접수 답변").responseTime(1).build();
var pending = service.submitAnswer("s", answer).toFuture();
try {
called.asMono().block(Duration.ofSeconds(5));
StepVerifier.create(service.quitInterview("s")).expectError(AlreadyUsingException.class).verify();
StepVerifier.create(service.completeInterview("s")).expectError(AlreadyUsingException.class).verify();
verifyNoInteractions(client, tts);
decision.tryEmitValue(com.person.repit.interview.dto.response.FollowQuestionAiResponse.notRequired());
assertThat(pending.orTimeout(5, java.util.concurrent.TimeUnit.SECONDS).join().getStatus())
.isEqualTo(InterviewStatus.COMPLETED);
assertThat(service.quitInterview("s").block().getStatus()).isEqualTo(InterviewStatus.COMPLETED);
assertThat(service.submitAnswer("s", answer).block().getStatus()).isEqualTo(InterviewStatus.COMPLETED);
verify(ai).decideFollowQuestion(any());
verify(tts).cancelSession("s");
verifyNoMoreInteractions(tts);
assertThat(outbox.find("s").block().getState()).isEqualTo(InterviewResultDelivery.State.SENT);
assertThat(objects.hasKey("chat:interview:s").block()).isFalse();
} finally {
decision.tryEmitEmpty();
pending.cancel(true);
}
}

private ChatInterviewSession session() {
return ChatInterviewSession.builder().sessionId("s").interviewId(1L).userId(2L)
.status(InterviewStatus.COMPLETED).createdAt(LocalDateTime.of(2026, 10, 15, 10, 0))
Expand Down