diff --git a/.gitignore b/.gitignore index 549c8e9..742e829 100644 --- a/.gitignore +++ b/.gitignore @@ -83,6 +83,7 @@ temp/ *.tmp *.bak -test_real_api_3subjects.py +xtest_data_structure_pdf.py +자료구조_7_ocr_text.txt .cph/ \ No newline at end of file diff --git a/exams/migrations/0003_studymaterial_analysis_run_id_and_more.py b/exams/migrations/0003_studymaterial_analysis_run_id_and_more.py new file mode 100644 index 0000000..5dcf028 --- /dev/null +++ b/exams/migrations/0003_studymaterial_analysis_run_id_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.16 on 2026-08-07 07:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('exams', '0002_studymaterial_analysis_error_message_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='studymaterial', + name='analysis_run_id', + field=models.UUIDField(blank=True, null=True, verbose_name='AI 분석 실행 식별자'), + ), + migrations.AddField( + model_name='studymaterial', + name='analysis_started_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='AI 분석 시작 시각'), + ), + ] diff --git a/exams/models.py b/exams/models.py index 20b8749..16cc65a 100644 --- a/exams/models.py +++ b/exams/models.py @@ -128,7 +128,6 @@ class StudyMaterial(models.Model): ) error_message = models.TextField(null=True, blank=True, verbose_name="추출/파싱 실패 원인") - # AI 분석(E-AI-01/02/03) 상태 - 위 status/error_message(텍스트 추출)와는 별개 필드. # 리뷰 확정 사항: 텍스트 추출 성공 여부와 AI 분석 성공 여부는 서로 다른 단계라 분리한다. analysis_status = models.CharField( max_length=20, @@ -138,6 +137,8 @@ class StudyMaterial(models.Model): ) analysis_error_message = models.TextField(null=True, blank=True, verbose_name="AI 분석 실패 사유") analysis_retry_count = models.PositiveSmallIntegerField(default=0, verbose_name="AI 분석 사용자 재시도 횟수") + analysis_started_at = models.DateTimeField(null=True, blank=True, verbose_name="AI 분석 시작 시각") + analysis_run_id = models.UUIDField(null=True, blank=True, verbose_name="AI 분석 실행 식별자") created_at = models.DateTimeField(auto_now_add=True, verbose_name="생성일시") diff --git a/exams/services/analysis_orchestrator.py b/exams/services/analysis_orchestrator.py index cd54406..50f2dea 100644 --- a/exams/services/analysis_orchestrator.py +++ b/exams/services/analysis_orchestrator.py @@ -23,73 +23,70 @@ - 최초 분석: PENDING -> PROCESSING -> COMPLETED 또는 FAILED - 재시도: FAILED -> PROCESSING -> COMPLETED 또는 FAILED - PROCESSING 상태에서는 중복 분석 요청을 거부한다. -- COMPLETED 상태에서는 MVP 기준 재분석을 지원하지 않는다 - (결과를 고치고 싶으면 AI 재분석이 아니라 사용자가 작업 검토 화면에서 직접 수정한다). +- COMPLETED 상태에서는 MVP 기준 재분석을 지원하지 않는다. - 사용자 재시도는 최대 2회. 최초 분석은 이 횟수에 포함하지 않는다. - (최초 분석 실패 -> retry_count=0, 1차 재시도 시작 -> 1, 2차 재시도 시작 -> 2, - 2차까지 실패하면 추가 재시도 거부하고 직접 입력 화면으로 안내) -- task_extractor 내부의 JSON 검증 self-correction 재요청은 이 재시도 횟수에 포함하지 않는다 - (그건 AI 응답 하나를 받는 과정의 내부 디테일이지, 사용자가 누른 "재시도"가 아니다). +- task_extractor 내부의 JSON 검증 self-correction 재요청은 이 재시도 횟수에 포함하지 않는다. - AI 분석 결과 StudyTask가 0개 생성되면 성공으로 보지 않고 FAILED로 처리한다. 동시 요청/중복 방지: -- "PENDING인지 확인 -> PROCESSING으로 저장" 을 두 단계로 나누면 동시 요청 사이에 - 경쟁 상태(race condition)가 생길 수 있다. 그래서 확인과 전이를 DB 조건부 - UPDATE(QuerySet.filter().update()) 하나로 원자적으로 처리한다: 이 UPDATE가 - 실제로 영향을 준 행(row)이 0개면 "지금은 시작할 수 없는 상태"라고 판단한다. +- "확인 -> 저장"을 두 단계로 나누면 경쟁 상태가 생길 수 있어서, DB 조건부 + UPDATE(QuerySet.filter().update()) 하나로 원자적으로 처리한다. - 재시도 시작 시 analysis_retry_count 증가도 F() 표현식으로 원자적으로 처리한다. 예외 처리 범위: - task_extractor/time_estimator/bulk_update 등 파이프라인 전체에서 발생하는 모든 예외를 잡아 analysis_status=FAILED로 남긴다. - - AIAnalysisError 계열: 사용자에게 보여줘도 되는 실패 사유를 그대로 저장 - - 그 외 예기치 못한 예외: 상세 내용은 로그에만 남기고, 사용자용 메시지는 - 일반적인 문구로 저장 (내부 구현 노출 방지) - -해결된 이슈: -- (과거) task_extractor.analyze_study_material()가 @transaction.atomic이라 그 안의 - AI 네트워크 호출이 DB 트랜잭션을 물고 있었음 -> task_extractor를 - fetch_extracted_tasks()(네트워크, 트랜잭션 없음)와 save_extracted_tasks()(DB 쓰기, - 짧은 트랜잭션)로 분리했고, 이 파일도 analyze_study_material() 대신 - fetch_extracted_tasks()를 직접 호출해서 AI 호출이 트랜잭션 밖에서 실행되도록 함. - -AI 호출과 DB 저장 사이의 입력 변경 경쟁 상태 (리뷰 반영): + +AI 호출과 DB 저장 사이의 입력 변경 경쟁 상태: - AI 네트워크 호출을 트랜잭션 밖으로 뺀 대가로, "AI 호출 시작 ~ 결과 저장" 사이에 - StudyMaterial이 바뀔 수 있는 창(window)이 생겼다 (예: 다른 요청이 PDF를 다시 - 추출해서 extracted_text가 바뀌는 경우). 이 경우를 대비해 _save_tasks_with_estimates()가 - 저장 직전에 StudyMaterial을 다시 조회해서 다음을 재검증한다. - 1. analysis_status가 여전히 PROCESSING인지 - 2. status(텍스트 추출 상태)가 COMPLETED인지 - PDF 재추출이 진행 중(status가 - PROCESSING/FAILED/PENDING으로 바뀜)이면, 아직 extracted_text 자체는 안 - 바뀌었더라도 곧 바뀔 수 있는 불안정한 상태이므로 저장을 포기한다. (텍스트 - 비교만으로는 "재추출이 시작됐지만 아직 안 끝난" 시점을 못 걸러내기 때문에 - 별도로 확인이 필요했다.) - 3. extracted_text가 AI 호출 당시와 동일한지 (analyzed_text로 전달받아 비교) - 하나라도 어긋나면 StaleAnalysisRequestError를 던지고 아무것도 저장하지 않는다. -- 같은 이유로 예상시간 계산에 쓰는 exam.speed_factor도, AI 호출 전에 로드해둔 오래된 - 객체가 아니라 저장 시점에 다시 조회한 최신 값을 사용한다. -- material_extract()(exams/views.py, BE2 담당) 쪽에도 analysis_status가 PROCESSING/ - COMPLETED인 자료의 재추출을 조건부 UPDATE로 원자적으로 막는 방어를 추가했다 - (Python에서 조회 후 검사하는 방식은 그 자체로 동시 요청 사이의 경쟁 상태가 남는다). - -PDF 추출 시작과 AI 분석 시작이 동시에 성공하는 경쟁 상태 (리뷰 반영): -- 위 저장 시점 재검증만으로는 못 막는 경우가 있었다: View가 material.status== - COMPLETED를 파이썬에서 확인한 직후, PDF 재추출 요청이 먼저 DB에서 status= - PROCESSING을 차지하고, 그 다음 이 파일의 _start_processing()이 analysis_status만 - 확인하고 PROCESSING 전이에 성공해버리는 경우다. 이러면 추출과 분석이 동시에 - 진행되다가, 나중에 _finish_failure()가 조건 없이 analysis_status=FAILED를 저장하면서 - 재추출 성공 후 초기화된 PENDING 상태를 덮어쓸 수 있었다. -- 해결: _start_processing()의 조건부 UPDATE에도 status=MaterialStatus.COMPLETED - 조건을 추가했다. material_extract()의 조건부 UPDATE(analysis_status가 PROCESSING/ - COMPLETED면 차단)와 서로 대칭을 이루게 되어, 두 요청 중 DB에 먼저 도달해 조건부 - UPDATE를 통과한 쪽만 성공하고 나머지는 원자적으로 실패한다. + StudyMaterial이 바뀔 수 있는 창(window)이 생겼다. _save_tasks_with_estimates()가 + 저장 직전에 StudyMaterial을 다시 조회해서 analysis_status/status(텍스트 추출 상태)/ + extracted_text가 AI 호출 당시와 같은지 재검증하고, 어긋나면 StaleAnalysisRequestError를 + 던지고 아무것도 저장하지 않는다. 예상시간 계산에 쓰는 exam.speed_factor도 저장 + 시점의 최신 값을 쓴다. material_extract()(BE2 담당) 쪽에도 analysis_status가 + PROCESSING/COMPLETED인 자료의 재추출을 조건부 UPDATE로 막는 대칭 방어가 있다. + +PDF 추출 시작과 AI 분석 시작이 동시에 성공하는 경쟁 상태: +- _start_processing()의 조건부 UPDATE에 status=MaterialStatus.COMPLETED 조건을 + 추가해서, material_extract() 쪽 방어와 서로 대칭을 이루게 했다. 두 요청 중 DB에 + 먼저 도달해 조건부 UPDATE를 통과한 쪽만 성공하고 나머지는 원자적으로 실패한다. + +PROCESSING 타임아웃 / 좀비 상태 복구 (이슈 #52): +- 서버가 분석 도중 비정상 종료되면 analysis_status가 PROCESSING인 채로 영원히 + 남을 수 있다. analysis_started_at 기준 PROCESSING_TIMEOUT_SECONDS(5분) 이상 + 지났거나, analysis_started_at이 NULL인(이 필드가 생기기 전부터 PROCESSING이었던 + 기존 데이터) PROCESSING은 "좀비"로 간주한다. +- 정책 단순화: 좀비 구제는 retry_analysis()에서만 허용한다. + analyze_and_estimate()는 status=COMPLETED, analysis_status=PENDING일 때만 + 시작한다 (좀비 구제 없음). 화면에서 is_stale=True일 때도 재시도 엔드포인트로 + 안내하면 된다. +- 재시도 횟수 제한: 좀비 상태여도 analysis_retry_count < MAX_RETRY_COUNT 조건은 + 동일하게 적용한다. eligible 조건 전체에 이 조건을 AND로 묶어서, 좀비라는 + 이유로 재시도 횟수 제한을 우회할 수 없게 했다. +- NULL 처리: analysis_started_at이 NULL인 PROCESSING도 좀비로 취급해서 구제 + 대상에 포함한다. 별도 데이터 마이그레이션 없이 이 판정 로직만으로 처리한다. + +실행 소유권(analysis_run_id) - 좀비 복구 도입으로 생긴 새 문제와 해결책: +- 좀비 PROCESSING을 새 실행이 대신 이어받게 해주면, 원래 실행이 실제로는 죽지 + 않고 뒤늦게 계속 진행 중이었을 경우 두 실행이 동시에 같은 StudyMaterial을 + 건드리게 된다. 늦게 끝난 예전 실행이 새 실행의 결과(StudyTask, 상태)를 + 덮어쓸 수 있다. +- 해결: PROCESSING으로 전이될 때마다 새로운 UUID(analysis_run_id)를 발급한다. + 그 이후의 모든 DB 쓰기(StudyTask 저장, 상태 완료/실패 처리)는 그 시점의 + analysis_run_id가 자신이 발급받은 값과 여전히 같은지 확인한 뒤에만 수행한다. + 다르면(이미 다른 실행이 이어받았다면) 조용히 포기한다 (StaleAnalysisRunError). + 이 확인은 StaleAnalysisRequestError(입력이 바뀐 경우) 확인과는 별개다 - 실행 + 소유권을 잃은 경우엔 이미 다른 실행이 상태를 관리하고 있으므로, 이 실행은 + analysis_status를 아예 건드리지 않고 조용히 물러난다. """ from __future__ import annotations import logging +import uuid from django.db import transaction -from django.db.models import F +from django.db.models import F, Q +from django.utils import timezone from core.choices import MaterialStatus from core.exceptions import AIAnalysisError, AIResponseValidationError @@ -101,6 +98,10 @@ MAX_RETRY_COUNT = 2 +# PROCESSING 상태가 이 시간(초)보다 오래 지속되면 "좀비 상태"로 간주하고, +# 재시도 요청이 이 자리를 대신 차지할 수 있게 허용한다. +PROCESSING_TIMEOUT_SECONDS = 300 # 5분 + class DuplicateAnalysisRequestError(Exception): """이미 처리 중이거나(PROCESSING), 지금 상태에서는 분석/재시도를 시작할 수 없을 때""" @@ -116,10 +117,20 @@ class AnalysisNotSupportedError(Exception): class StaleAnalysisRequestError(Exception): """ - AI 호출 시작 이후 저장 시점까지 사이에 StudyMaterial이 바뀌어서 - (analysis_status가 더 이상 PROCESSING이 아니거나, extracted_text가 AI 호출 - 당시와 달라져서) 지금 들고 있는 AI 결과를 더 이상 신뢰할 수 없을 때. - 이 경우 결과를 저장하지 않고 조용히 포기한다. + AI 호출 시작 이후 저장 시점까지 사이에 StudyMaterial의 입력이 바뀌어서 + (analysis_status가 더 이상 PROCESSING이 아니거나, status가 COMPLETED가 + 아니거나, extracted_text가 AI 호출 당시와 달라져서) 지금 들고 있는 AI + 결과를 더 이상 신뢰할 수 없을 때. 이 실행은 여전히 PROCESSING의 소유자이므로 + (analysis_run_id 기준), 안전하게 FAILED로 마무리해 재시도를 안내한다. + """ + + +class StaleAnalysisRunError(Exception): + """ + 이 실행(analysis_run_id)이 결과를 저장하기 전에 이미 다른(더 최신) 실행이 + 같은 StudyMaterial의 소유권을 가져간 경우. 이 경우 결과는 저장하지 않고 + 조용히 포기한다 - 이미 다른 실행이 상태를 관리 중이므로 analysis_status를 + 건드리면 안 된다. """ @@ -130,38 +141,18 @@ class AnalysisPipelineError(Exception): View 등 호출부가 "AIAnalysisError만 알면 되는" 상태를 유지할 수 있도록, 예상 가능한 실패(AIAnalysisError)와 예상 못한 실패를 이 예외 하나로 - 구분 없이 잡을 수 있게 한다. analysis_status=FAILED와 사용자용 일반 - 오류 메시지는 이 예외가 발생하기 전에 이미 저장이 끝난 상태이며, - 이 예외의 메시지 자체도 사용자에게 그대로 노출해도 안전한 일반 문구다 - (내부 예외의 상세 내용/스택트레이스는 로그에만 남긴다). + 구분 없이 잡을 수 있게 한다. """ -def _run_analysis_and_estimate(study_material: StudyMaterial) -> list[StudyTask]: +def _run_analysis_and_estimate(study_material: StudyMaterial, run_id: uuid.UUID) -> list[StudyTask]: """ AI 분석(task_extractor)과 예상시간 계산(time_estimator)을 순서대로 실행한다. analysis_status는 건드리지 않는다 (상태 관리는 호출하는 쪽이 담당). - 처리 순서: - 1. fetch_extracted_tasks()로 AI 호출 + 파싱 + 검증 (네트워크, 트랜잭션 없음) - 2. _save_tasks_with_estimates()로 StudyTask 생성과 예상시간 계산을 - 하나의 짧은 트랜잭션으로 저장 (DB 쓰기만 있어서 커넥션을 오래 안 붙잡음) - - 이렇게 나눈 이유: AI 네트워크 호출은 재시도 포함 최대 수십 초가 걸릴 수 있는데, - 이걸 DB 트랜잭션 안에 두면 그동안 커넥션을 계속 점유하게 된다. 네트워크 호출을 - 트랜잭션 밖으로 완전히 빼서, DB 트랜잭션은 실제 DB 쓰기 구간(순식간에 끝남)만 - 감싸도록 했다. - - AI 호출에 쓴 텍스트(analyzed_text)를 저장 단계까지 그대로 들고 가서, 저장 - 시점에 StudyMaterial.extracted_text와 비교한다 (아래 _save_tasks_with_estimates - 참고) - 그 사이에 텍스트가 바뀌었으면 이 결과는 버려야 하기 때문이다. - - 실패 시: - - 1단계(AI 호출) 실패: 애초에 트랜잭션이 시작도 안 되므로 DB에는 아무 - 변화도 없다. - - 2단계(DB 저장) 실패: _save_tasks_with_estimates()가 @transaction.atomic이므로 - 그 안에서 생성된 StudyTask도 함께 롤백된다. "예상시간 없는 StudyTask"가 - DB에 남지 않는다. + run_id: 이 실행을 시작할 때 _start_processing()이 발급한 실행 식별자. + DB에 쓰기 직전에 이 값이 여전히 유효한 "현재 실행"인지 확인한다 + (_save_tasks_with_estimates 참고). """ if not study_material.extracted_text: raise AIResponseValidationError("StudyMaterial에 분석할 텍스트가 없습니다.") @@ -170,30 +161,45 @@ def _run_analysis_and_estimate(study_material: StudyMaterial) -> list[StudyTask] analyzed_text = study_material.extracted_text extracted_tasks = fetch_extracted_tasks(exam, analyzed_text) - return _save_tasks_with_estimates(study_material, extracted_tasks, analyzed_text) + return _save_tasks_with_estimates(study_material, extracted_tasks, analyzed_text, run_id) @transaction.atomic def _save_tasks_with_estimates( - study_material: StudyMaterial, extracted_tasks, analyzed_text: str + study_material: StudyMaterial, extracted_tasks, analyzed_text: str, run_id: uuid.UUID ) -> list[StudyTask]: """ - AI가 추출한 결과를 StudyTask로 저장하고, 곧바로 예상시간까지 채운다. - DB 쓰기만 하고 네트워크 호출은 전혀 없어서, 트랜잭션으로 묶어도 커넥션을 - 오래 점유하지 않는다. - - 저장 직전에 StudyMaterial을 다시 조회해서(select_for_update로 잠그면서), - AI 호출 이후 상태가 바뀌지 않았는지 재검증한다: - - analysis_status가 여전히 PROCESSING인지 - - status(텍스트 추출 상태)가 COMPLETED인지 (재추출이 진행 중이면 아직 - extracted_text 자체는 안 바뀌었어도 불안정한 상태로 간주) - - extracted_text가 analyzed_text(AI 호출에 실제로 쓴 텍스트)와 같은지 - 하나라도 어긋나면 StaleAnalysisRequestError를 던지고 아무것도 쓰지 않는다. - (select_for_update는 SQLite에서 실제 잠금이 걸리지는 않지만, 재조회 자체는 - 트랜잭션 안에서 최신 값을 가져오므로 최소한의 방어 역할은 한다.) - - 예상시간 계산에 쓰는 exam.speed_factor도 AI 호출 전에 로드해둔 오래된 객체가 - 아니라, 이 재조회로 얻은 최신 값을 사용한다. + AI가 추출한 결과를 StudyTask로 저장하고, 곧바로 예상시간까지 채운 뒤, + analysis_status=COMPLETED 최종 전이까지 전부 같은 트랜잭션 안에서 처리한다. + + 저장 직전에 StudyMaterial을 다시 조회해서(select_for_update로 잠그면서) 4가지를 + 재검증한다: + 1. analysis_run_id가 여전히 이 실행(run_id)의 것인지 (실행 소유권) + 2. analysis_status가 여전히 PROCESSING인지 + 3. status(텍스트 추출 상태)가 COMPLETED인지 + 4. extracted_text가 analyzed_text(AI 호출에 실제로 쓴 텍스트)와 같은지 + 1번이 어긋나면 StaleAnalysisRunError(이미 다른 실행이 선점), 나머지는 + StaleAnalysisRequestError(입력이 바뀜)를 던지고 아무것도 쓰지 않는다. + + 예상시간 계산에 쓰는 exam.speed_factor도 이 재조회로 얻은 최신 값을 사용한다. + + 리뷰 반영(#84): 원래는 이 함수가 StudyTask 저장만 하고 커밋한 뒤, + _finish_success()가 별도 트랜잭션으로 analysis_status=COMPLETED 전이를 했다. + 그 사이(저장 커밋 ~ 완료 전이 사이)에 다른 실행이 소유권을 가져가면, + "StaleAnalysisRunError는 정상적으로 전파되지만, 이미 저장된 StudyTask는 + 롤백되지 않고 그대로 남는" 데이터 정합성 문제가 있었다 - 예를 들어 그 + 다른 실행이 이어서 AI 호출 단계에서 실패해 자기 결과를 저장하는 데까지 + 못 갔다면, 최종 analysis_status는 FAILED인데 이전 실행이 만든 StudyTask가 + 남아서 material_detail에서 그대로 노출될 수 있었다. + + 이제는 select_for_update()로 잠근 행을 트랜잭션이 끝날 때까지 계속 들고 + 있으면서, 저장과 최종 완료 전이를 같은 트랜잭션에 묶는다. 이 트랜잭션이 + 끝나기 전까지 다른 트랜잭션은 이 행을 갱신하는 UPDATE에서 대기하게 되므로, + 최종 전이 시점에도 소유권이 그대로 보존된다. 혹시라도 최종 조건부 UPDATE가 + 실패하면(방어적으로 여전히 확인한다) StaleAnalysisRunError를 던져서 트랜잭션 + 전체를 롤백시킨다 - StudyTask 저장까지 같이 취소되어 고아 데이터가 남지 않는다. + AI 네트워크 호출(fetch_extracted_tasks)은 이미 이 함수 밖에서 끝난 뒤이므로, + 이 트랜잭션 동안 네트워크 호출로 DB 커넥션을 오래 점유하는 문제는 없다. """ current = ( StudyMaterial.objects @@ -202,6 +208,12 @@ def _save_tasks_with_estimates( .get(pk=study_material.pk) ) + if current.analysis_run_id != run_id: + raise StaleAnalysisRunError( + f"분석 실행(run_id={run_id})이 결과를 저장하기 전에 다른 실행으로 " + f"대체되어 결과를 저장하지 않습니다. study_material_id={study_material.pk}" + ) + if current.analysis_status != MaterialStatus.PROCESSING: raise StaleAnalysisRequestError( f"저장 시점에 analysis_status가 PROCESSING이 아닙니다 " @@ -209,10 +221,6 @@ def _save_tasks_with_estimates( ) if current.status != MaterialStatus.COMPLETED: - # material_extract()가 PDF를 재추출 중이면 status가 PROCESSING/FAILED/PENDING으로 - # 바뀐다. 아직 extracted_text 자체는 안 바뀐 시점이라 아래 텍스트 비교만으로는 - # 못 걸러내므로, 추출 상태 자체도 별도로 확인한다 (재추출 완료 시점에 텍스트가 - # 바뀌기 전에 이 실행이 먼저 저장해버리는 걸 막기 위함). raise StaleAnalysisRequestError( f"저장 시점에 텍스트 추출 상태가 COMPLETED가 아닙니다 " f"(현재: {current.status}). study_material_id={study_material.pk}" @@ -227,6 +235,8 @@ def _save_tasks_with_estimates( tasks = save_extracted_tasks(current, extracted_tasks) if not tasks: + # 빈 결과는 이 함수 책임이 아니라 _execute_analysis()가 FAILED로 마무리한다 + # (성공으로 볼 만한 게 없으니 COMPLETED 전이도 하지 않는다). return tasks exam = current.exam # 재조회로 얻은 최신 exam (speed_factor 최신값 보장) @@ -242,58 +252,113 @@ def _save_tasks_with_estimates( StudyTask.objects.bulk_update( tasks, ["estimated_min_minutes", "estimated_max_minutes"] ) - return tasks + # StudyTask 저장과 같은 트랜잭션 안에서 최종 완료 전이까지 처리한다 (위 docstring + # 참고). select_for_update()로 이 행을 계속 잠그고 있었으므로 이 시점에도 + # analysis_run_id가 run_id와 같다는 게 사실상 보장되지만, 방어적으로 조건부 + # UPDATE로 한 번 더 확인한다 - 실패하면 트랜잭션 전체(StudyTask 저장 포함)가 + # 롤백된다. + updated_count = StudyMaterial.objects.filter( + pk=study_material.pk, analysis_run_id=run_id, + ).update(analysis_status=MaterialStatus.COMPLETED, analysis_error_message=None) + + if not updated_count: + raise StaleAnalysisRunError( + f"실행(run_id={run_id})이 StudyTask 저장과 같은 트랜잭션 안에서 최종 완료 " + f"전이를 시도했지만 이미 다른 실행으로 대체되었습니다. " + f"study_material_id={study_material.pk}" + ) -def _finish_success(study_material: StudyMaterial, tasks: list[StudyTask]) -> None: study_material.analysis_status = MaterialStatus.COMPLETED study_material.analysis_error_message = None - study_material.save(update_fields=["analysis_status", "analysis_error_message"]) logger.info( "AI 분석 + 예상시간 계산 완료: exam=%s, 작업 %d개", - study_material.exam.subject_name, len(tasks), + exam.subject_name, len(tasks), ) + return tasks + +def _finish_failure(study_material: StudyMaterial, message: str, run_id: uuid.UUID) -> None: + """ + run_id가 여전히 "현재 실행"일 때만 FAILED로 갱신한다 (_finish_success와 동일한 이유). + + 리뷰 반영(#84): _finish_success()와 대칭으로, 소유권을 잃었으면 조용히 넘어가는 + 대신 StaleAnalysisRunError를 던진다. 이렇게 하면 호출부(_execute_analysis)가 + "이 실행이 실패했다"는 원래 예외 대신 "이미 다른 실행에 넘어갔다"는 사실을 + 사용자에게 전달할 수 있다 - 안 그러면 이미 다른 실행이 성공적으로 처리 + 중이거나 처리를 마쳤을 수도 있는데, 사용자는 "실패했다"는 오래된(stale) 메시지를 + 보게 된다. + """ + updated_count = StudyMaterial.objects.filter( + pk=study_material.pk, analysis_run_id=run_id, + ).update(analysis_status=MaterialStatus.FAILED, analysis_error_message=message) + if not updated_count: + logger.warning( + "실행(run_id=%s)이 실패했지만 이미 다른 실행으로 대체되어 " + "상태 갱신을 건너뜁니다: study_material_id=%s", run_id, study_material.id, + ) + raise StaleAnalysisRunError( + f"실행(run_id={run_id})이 실패 처리 시점에 이미 다른 실행으로 " + f"대체되었습니다. study_material_id={study_material.pk}" + ) -def _finish_failure(study_material: StudyMaterial, message: str) -> None: study_material.analysis_status = MaterialStatus.FAILED study_material.analysis_error_message = message - study_material.save(update_fields=["analysis_status", "analysis_error_message"]) -def _execute_analysis(study_material: StudyMaterial) -> list[StudyTask]: +def _execute_analysis(study_material: StudyMaterial, run_id: uuid.UUID) -> list[StudyTask]: """ 실제 분석+예상시간 계산을 실행하고 결과에 따라 analysis_status를 COMPLETED 또는 FAILED로 마무리한다. - 호출 전제: analysis_status는 이미 PROCESSING으로 전이되어 있어야 한다 - (전이는 analyze_and_estimate/retry_analysis에서 원자적으로 처리한다). + 호출 전제: analysis_status는 이미 PROCESSING으로 전이되어 있고, run_id는 + 그 전이 시점에 _start_processing()이 발급한 값이어야 한다. 예외 처리: + - StaleAnalysisRunError: 이미 다른 실행에게 선점당함. StudyTask 저장과 + analysis_status=COMPLETED 최종 전이가 이제 같은 트랜잭션으로 묶여있으므로 + (리뷰 반영 #84), 이 예외가 발생하면 그 트랜잭션 전체가 롤백되어 StudyTask + 저장도 함께 취소된다 - "저장은 됐는데 상태만 못 바뀐" 어중간한 상태가 + 남지 않는다. analysis_status를 건드리지 않고(이미 그 다른 실행이 관리 + 중이므로) 그대로 다시 던진다 - 이 경우 원래 실패하려던 사유 + (AIAnalysisError, StaleAnalysisRequestError 등)보다 우선한다. + - StaleAnalysisRequestError: 입력이 바뀜. 이 실행은 여전히 소유자이므로 + 안전하게 FAILED로 마무리한다. - AIAnalysisError 계열: 실패 사유를 그대로 analysis_error_message에 저장 - 그 외 예기치 못한 예외: 상세는 로그에만 남기고, 사용자용 메시지는 - 일반적인 문구로 저장 (내부 구현/스택트레이스 노출 방지) + 일반적인 문구로 저장 - StudyTask가 0개 생성된 경우: 성공으로 보지 않고 FAILED 처리 """ try: - tasks = _run_analysis_and_estimate(study_material) + tasks = _run_analysis_and_estimate(study_material, run_id) + except StaleAnalysisRunError: + logger.info( + "실행(run_id=%s)이 결과 저장 전에 이미 다른 실행으로 대체됨: " + "study_material_id=%s", run_id, study_material.id, + ) + raise except StaleAnalysisRequestError as exc: - # 이 요청은 여전히 PROCESSING의 유일한 소유자다 (_start_processing의 조건부 - # UPDATE 덕분에 동시에 두 실행이 PROCESSING을 가질 수 없음 - 이 예외는 - # "저장 시점에 입력이 바뀌었다"는 뜻이지 "다른 실행에게 뺏겼다"는 뜻이 아니다). - # 그래서 안전하게 FAILED로 마무리해 사용자가 다시 시도할 수 있게 한다. message = "분석 도중 자료 내용이 변경되어 결과를 저장하지 않았습니다. 다시 시도해주세요." - _finish_failure(study_material, message) logger.warning( "분석 결과 저장 시점 재검증 실패: study_material_id=%s, 사유=%s", study_material.id, exc, ) + try: + _finish_failure(study_material, message, run_id) + except StaleAnalysisRunError: + # 리뷰 반영(#84): _finish_failure() 자체도 소유권을 잃었다면, 이 + # 실행이 "실패했다"는 오래된 사실보다 "이미 다른 실행에 넘어갔다"는 + # 사실을 우선 전달한다 (_finish_success와 동일한 원칙). + raise raise AnalysisPipelineError(message) from exc except AIAnalysisError as exc: - _finish_failure(study_material, str(exc)) logger.warning( "AI 분석 실패: study_material_id=%s, 사유=%s", study_material.id, exc, ) + try: + _finish_failure(study_material, str(exc), run_id) + except StaleAnalysisRunError: + raise raise except Exception as exc: logger.exception( @@ -301,62 +366,75 @@ def _execute_analysis(study_material: StudyMaterial) -> list[StudyTask]: study_material.id, ) message = "분석 중 알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요." - _finish_failure(study_material, message) + try: + _finish_failure(study_material, message, run_id) + except StaleAnalysisRunError: + raise raise AnalysisPipelineError(message) from exc if not tasks: message = "분석 결과 학습 작업이 생성되지 않았습니다." - _finish_failure(study_material, message) logger.warning( "AI 분석 결과 0개: study_material_id=%s", study_material.id, ) + try: + _finish_failure(study_material, message, run_id) + except StaleAnalysisRunError: + raise raise AIResponseValidationError(message) - _finish_success(study_material, tasks) + # _save_tasks_with_estimates()가 StudyTask 저장과 analysis_status=COMPLETED + # 최종 전이까지 같은 트랜잭션 안에서 이미 끝냈다 (리뷰 반영 #84 - 저장과 완료 + # 전이 사이의 소유권 경쟁으로 StudyTask만 남고 상태는 다른 것으로 바뀌는 + # 데이터 정합성 문제를 막기 위함). tasks가 여기까지 정상 반환됐다는 것 자체가 + # 이미 COMPLETED 전이까지 성공했다는 뜻이므로, 별도로 완료 처리를 할 필요가 없다. return tasks -def _start_processing(study_material: StudyMaterial, *, is_retry: bool) -> bool: +def _start_processing(study_material: StudyMaterial, *, is_retry: bool) -> uuid.UUID | None: """ - analysis_status를 PROCESSING으로 원자적으로 전이시킨다. - - - is_retry=False (최초 분석): 현재 status(텍스트 추출 상태)가 COMPLETED이고 - analysis_status가 PENDING일 때만 전이 - - is_retry=True (재시도): 현재 status가 COMPLETED이고 analysis_status가 - FAILED이며 analysis_retry_count < MAX_RETRY_COUNT일 - 때만 전이, 전이와 동시에 analysis_retry_count를 - 1 증가시킨다. - - status=COMPLETED 조건을 넣은 이유 (리뷰 반영): 이 조건이 없으면 아래 경쟁 - 상태가 가능했다. - 1. View가 material.status==COMPLETED를 파이썬에서 확인 - 2. 그 직후 PDF 재추출 요청이 DB에서 status=PROCESSING을 먼저 차지 - 3. 이 함수가 analysis_status=PENDING만 확인하고 PROCESSING 전이에 성공 - 4. PDF 추출과 AI 분석이 동시에 실행되어, 나중에 _finish_failure()가 - 조건 없이 analysis_status=FAILED를 저장하면서 재추출 성공 후 - 초기화된 PENDING 상태를 덮어씀 - status=COMPLETED를 이 조건부 UPDATE 안에 같이 넣으면, material_extract() - 쪽의 조건부 UPDATE(analysis_status가 PROCESSING/COMPLETED면 재추출 차단)와 - 서로 대칭을 이뤄서, 두 요청 중 DB에 먼저 도달한 쪽만 성공하고 나머지는 - 원자적으로 실패하게 된다. + analysis_status를 PROCESSING으로 원자적으로 전이시키고, 성공하면 이번 + 실행을 식별하는 새 UUID(run_id)를 발급해서 반환한다. + + - is_retry=False (최초 분석): status가 COMPLETED이고 analysis_status가 + PENDING일 때만 전이. 좀비 PROCESSING 구제는 여기서 하지 않는다 + (retry_analysis 전용 정책). + - is_retry=True (재시도): status가 COMPLETED이고, analysis_retry_count가 + MAX_RETRY_COUNT 미만이며, 아래 중 하나일 때 전이한다 (전이와 동시에 + retry_count를 1 증가시킨다). + 1) analysis_status가 FAILED + 2) analysis_status가 PROCESSING이고 좀비 상태 + (analysis_started_at이 PROCESSING_TIMEOUT_SECONDS 이상 지났거나 NULL) + 재시도 횟수 조건은 위 두 경우 모두에 동일하게 적용된다 (좀비라고 재시도 + 횟수 제한을 우회할 수 없다). DB 조건부 UPDATE 하나로 "확인 + 변경"을 원자적으로 처리하기 때문에, 동시에 같은 요청이 여러 번 들어와도 정확히 하나만 성공한다. Returns: - True: 전이에 성공함 (study_material 인스턴스도 최신값으로 갱신됨) - False: 조건이 안 맞아 전이하지 못함 (이미 처리중/조건 불충족/추출 미완료 등) + 성공 시 새로 발급된 run_id(uuid.UUID). 조건이 안 맞아 전이하지 못하면 None. """ + now = timezone.now() + new_run_id = uuid.uuid4() + stale_cutoff = now - timezone.timedelta(seconds=PROCESSING_TIMEOUT_SECONDS) + if is_retry: + is_zombie_processing = Q(analysis_status=MaterialStatus.PROCESSING) & ( + Q(analysis_started_at__lt=stale_cutoff) | Q(analysis_started_at__isnull=True) + ) + eligible = ( + Q(status=MaterialStatus.COMPLETED) + & Q(analysis_retry_count__lt=MAX_RETRY_COUNT) + & (Q(analysis_status=MaterialStatus.FAILED) | is_zombie_processing) + ) updated_count = StudyMaterial.objects.filter( - pk=study_material.pk, - status=MaterialStatus.COMPLETED, - analysis_status=MaterialStatus.FAILED, - analysis_retry_count__lt=MAX_RETRY_COUNT, + Q(pk=study_material.pk) & eligible ).update( analysis_status=MaterialStatus.PROCESSING, analysis_error_message=None, analysis_retry_count=F("analysis_retry_count") + 1, + analysis_started_at=now, + analysis_run_id=new_run_id, ) else: updated_count = StudyMaterial.objects.filter( @@ -366,28 +444,32 @@ def _start_processing(study_material: StudyMaterial, *, is_retry: bool) -> bool: ).update( analysis_status=MaterialStatus.PROCESSING, analysis_error_message=None, + analysis_started_at=now, + analysis_run_id=new_run_id, ) if updated_count: study_material.refresh_from_db( - fields=["analysis_status", "analysis_error_message", "analysis_retry_count"] + fields=[ + "analysis_status", "analysis_error_message", + "analysis_retry_count", "analysis_started_at", "analysis_run_id", + ] ) - return True - return False + return new_run_id + return None def analyze_and_estimate(study_material: StudyMaterial) -> list[StudyTask]: """ E-AI-01 진입점. 텍스트 추출(status)이 COMPLETED이고 analysis_status가 - PENDING일 때만 분석을 시작한다. + PENDING일 때만 분석을 시작한다. (좀비 PROCESSING 구제는 retry_analysis() 전용) Raises: - DuplicateAnalysisRequestError: 시작 조건이 안 맞아 시작 못 함 (추출이 - 아직 진행 중이거나, analysis_status가 PENDING이 아님) + DuplicateAnalysisRequestError: 시작 조건이 안 맞아 시작 못 함 AIAnalysisError 계열, AIResponseValidationError: 분석 자체가 실패함 """ - started = _start_processing(study_material, is_retry=False) - if not started: + run_id = _start_processing(study_material, is_retry=False) + if run_id is None: study_material.refresh_from_db(fields=["status", "analysis_status"]) if study_material.status != MaterialStatus.COMPLETED: raise DuplicateAnalysisRequestError( @@ -397,55 +479,83 @@ def analyze_and_estimate(study_material: StudyMaterial) -> list[StudyTask]: f"분석을 시작할 수 없는 상태입니다 (현재 analysis_status: " f"{study_material.analysis_status})." ) - return _execute_analysis(study_material) + return _execute_analysis(study_material, run_id) def retry_analysis(study_material: StudyMaterial) -> list[StudyTask]: """ - E-AI-03 진입점. 텍스트 추출(status)이 COMPLETED이고, analysis_status가 - FAILED이며 재시도 횟수가 남아있을 때만 재시도한다. + E-AI-03 진입점. 텍스트 추출(status)이 COMPLETED이고, 재시도 횟수가 남아있으며 + 아래 중 하나일 때 재시도한다. + - analysis_status가 FAILED + - analysis_status가 PROCESSING이고 타임아웃을 넘긴 "좀비" 상태 Raises: - DuplicateAnalysisRequestError: 추출이 진행 중이거나, analysis_status가 - PROCESSING이라 중복 요청인 경우 + DuplicateAnalysisRequestError: 추출이 진행 중이거나, 타임아웃 전인 진짜 + 진행 중(PROCESSING)이라 중복 요청인 경우 AnalysisNotSupportedError: COMPLETED 상태라 MVP 기준 재분석 미지원인 경우 - RetryLimitExceededError: FAILED 상태이지만 재시도 횟수(2회)를 이미 다 쓴 경우 + RetryLimitExceededError: 재시도 횟수(2회)를 이미 다 쓴 경우 (FAILED든 좀비든 동일) AIAnalysisError 계열, AIResponseValidationError: 재시도한 분석 자체가 실패함 """ - started = _start_processing(study_material, is_retry=True) - if not started: - study_material.refresh_from_db(fields=["status", "analysis_status", "analysis_retry_count"]) + run_id = _start_processing(study_material, is_retry=True) + if run_id is not None: + return _execute_analysis(study_material, run_id) - if study_material.status != MaterialStatus.COMPLETED: - raise DuplicateAnalysisRequestError( - "텍스트 추출이 진행 중이라 지금은 재시도를 시작할 수 없습니다." - ) + study_material.refresh_from_db( + fields=["status", "analysis_status", "analysis_retry_count", "analysis_started_at"] + ) + + if study_material.status != MaterialStatus.COMPLETED: + raise DuplicateAnalysisRequestError( + "텍스트 추출이 진행 중이라 지금은 재시도를 시작할 수 없습니다." + ) - status = study_material.analysis_status + status = study_material.analysis_status - if status == MaterialStatus.PROCESSING: - raise DuplicateAnalysisRequestError("이미 분석 중인 자료는 다시 분석할 수 없습니다.") - if status == MaterialStatus.COMPLETED: - raise AnalysisNotSupportedError( - "이미 분석이 완료된 자료입니다. MVP에서는 재분석을 지원하지 않습니다. " - "결과를 수정하려면 작업 검토 화면에서 직접 수정해주세요." - ) - if status == MaterialStatus.FAILED: - # FAILED이고 추출도 COMPLETED인데 전이 실패했다는 건 - # 재시도 횟수를 이미 다 썼다는 뜻 + if status == MaterialStatus.COMPLETED: + raise AnalysisNotSupportedError( + "이미 분석이 완료된 자료입니다. MVP에서는 재분석을 지원하지 않습니다. " + "결과를 수정하려면 작업 검토 화면에서 직접 수정해주세요." + ) + + # 리뷰 반영(#84): PROCESSING 상태를 "진짜 진행 중"인지 "좀비인데 재시도 + # 횟수까지 소진되어 더는 손쓸 수 없는 상태"인지 구분해서 진단한다. + # + # 마지막 재시도(예: retry_count=1, MAX=2) 자리를 두 요청이 동시에 노리는 + # 경쟁 상태를 생각해보자 - 이긴 요청이 retry_count를 2로 올리고 방금 막 + # PROCESSING을 차지했다(신선함, is_stale=False). 진 요청이 재조회하면 + # retry_count=2(이미 최대치), analysis_status=PROCESSING을 보게 되는데, + # retry_count부터 확인하면 "재시도 횟수를 다 썼다"고 잘못 안내하게 된다 - + # 실제로는 "지금 막 다른 요청이 처리를 시작했다"는 게 진짜 이유인데도. + # + # 반대로, PROCESSING이 5분 넘게 멈춘 좀비이고 retry_count도 이미 MAX라면 + # (예: test_retry_rejected_when_retry_count_maxed_even_if_zombie), 이건 + # "누군가 지금 활발히 처리 중"이 아니라 "예전에 멈춘 채로 방치됐고 더 이상 + # 아무도 구제할 수 없는" 상태이므로, DuplicateAnalysisRequestError보다 + # RetryLimitExceededError(재시도 횟수 소진, 직접 추가하라)가 정확한 안내다. + # + # 그래서 is_stale까지 같이 확인해서: "좀비 + 재시도 소진"만 RetryLimitExceededError로 + # 먼저 걸러내고, 그 외 PROCESSING(신선하거나, 좀비여도 재시도 여지가 남아있는 + # 경우)은 DuplicateAnalysisRequestError로 처리한다. + if status == MaterialStatus.PROCESSING: + analysis_data = get_analysis_status(study_material) + if analysis_data["is_stale"] and study_material.analysis_retry_count >= MAX_RETRY_COUNT: raise RetryLimitExceededError( "재시도 횟수(최대 2회)를 모두 사용했습니다. 학습 작업을 직접 추가해주세요." ) - raise DuplicateAnalysisRequestError( - f"재시도할 수 없는 상태입니다 (현재 analysis_status: {status})." + raise DuplicateAnalysisRequestError("이미 분석 중인 자료는 다시 분석할 수 없습니다.") + + if study_material.analysis_retry_count >= MAX_RETRY_COUNT: + raise RetryLimitExceededError( + "재시도 횟수(최대 2회)를 모두 사용했습니다. 학습 작업을 직접 추가해주세요." ) - return _execute_analysis(study_material) + raise DuplicateAnalysisRequestError( + f"재시도할 수 없는 상태입니다 (현재 analysis_status: {status})." + ) def get_analysis_status(study_material: StudyMaterial) -> dict: """ - E-AI-02: StudyMaterial의 AI 분석 진행 상태를 조회한다. Returns: { @@ -453,8 +563,54 @@ def get_analysis_status(study_material: StudyMaterial) -> dict: "error_message": str | None, # FAILED가 아니면 항상 None "retry_count": int, # 지금까지 사용자가 재시도한 횟수 "retry_remaining": int, # 남은 재시도 가능 횟수 (0~2) + "is_stale": bool, # PROCESSING인데 타임아웃을 넘겨 "좀비" 상태인지 + # (analysis_started_at이 NULL인 경우도 좀비로 간주) + "can_retry": bool, # 지금 이 순간 재시도 버튼을 활성화해도 되는지 + "retry_after_seconds": int | None, # PROCESSING이라 아직 재시도 못 하는 경우, + # 좀비 판정까지 남은 초 (그 외엔 None) } + + can_retry / retry_after_seconds를 추가한 이유: 프론트가 "5분 지났는지"를 + 직접 타이머로 계산하게 하면, 서버 시각과 클라이언트 시각이 어긋나거나 화면을 + 켜둔 채 방치했을 때 오차가 생길 수 있다. 그 대신 폴링할 때마다 서버가 판단한 + 결과(can_retry)를 그대로 내려줘서, 프론트는 이 값만 보고 버튼을 켜고 끄면 된다. + 판정 기준은 retry_analysis()가 실제로 허용하는 조건과 동일하게 맞췄다: + - 텍스트 추출(status)이 COMPLETED이고 + - 재시도 횟수가 남아있고(retry_remaining > 0) + - analysis_status가 FAILED이거나, PROCESSING이면서 좀비 상태(is_stale)일 때 """ + is_stale = False + elapsed_seconds: float | None = None + if study_material.analysis_status == MaterialStatus.PROCESSING: + if study_material.analysis_started_at is None: + is_stale = True + else: + elapsed_seconds = ( + timezone.now() - study_material.analysis_started_at + ).total_seconds() + is_stale = elapsed_seconds >= PROCESSING_TIMEOUT_SECONDS + + retry_remaining = max(0, MAX_RETRY_COUNT - study_material.analysis_retry_count) + + can_retry = False + retry_after_seconds: int | None = None + + if study_material.status == MaterialStatus.COMPLETED and retry_remaining > 0: + if study_material.analysis_status == MaterialStatus.FAILED: + can_retry = True + elif study_material.analysis_status == MaterialStatus.PROCESSING: + if is_stale: + can_retry = True + elif elapsed_seconds is not None: + retry_after_seconds = max( + 0, int(PROCESSING_TIMEOUT_SECONDS - elapsed_seconds) + ) + else: + # 이 분기는 사실상 도달하지 않는다 (started_at이 None이면 위에서 + # 이미 is_stale=True로 처리됨). 방어적으로 타임아웃 전체를 남겨둔다. + retry_after_seconds = PROCESSING_TIMEOUT_SECONDS + # PENDING/COMPLETED(analysis_status)는 can_retry=False, retry_after_seconds=None 유지 + return { "status": study_material.analysis_status, "error_message": ( @@ -463,5 +619,8 @@ def get_analysis_status(study_material: StudyMaterial) -> dict: else None ), "retry_count": study_material.analysis_retry_count, - "retry_remaining": max(0, MAX_RETRY_COUNT - study_material.analysis_retry_count), + "retry_remaining": retry_remaining, + "is_stale": is_stale, + "can_retry": can_retry, + "retry_after_seconds": retry_after_seconds, } \ No newline at end of file diff --git a/exams/services/importance_recommender.py b/exams/services/importance_recommender.py deleted file mode 100644 index e69de29..0000000 diff --git a/exams/tests.py b/exams/tests.py index 4ce995b..b5cda04 100644 --- a/exams/tests.py +++ b/exams/tests.py @@ -1,10 +1,12 @@ import datetime import io import pypdf +import uuid from unittest.mock import patch from django.db import connection from django.test import TestCase, TransactionTestCase, override_settings, Client from django.urls import reverse +from django.utils import timezone from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile @@ -24,7 +26,13 @@ AnalysisPipelineError, DuplicateAnalysisRequestError, MAX_RETRY_COUNT, + PROCESSING_TIMEOUT_SECONDS, RetryLimitExceededError, + StaleAnalysisRunError, + _execute_analysis, + _finish_failure, + _save_tasks_with_estimates, + _start_processing, analyze_and_estimate, get_analysis_status, retry_analysis, @@ -469,6 +477,109 @@ def test_stage_response_includes_extraction_fields(self): self.assertEqual(data["extraction_status"], MaterialStatus.FAILED) self.assertEqual(data["extraction_error_message"], "PDF 추출 실패 사유") + def test_stage_response_includes_can_retry_true_when_failed_with_retries_left(self): + """이슈 #52: FAILED고 재시도 횟수가 남아있으면 실제 응답에서도 can_retry=True여야 한다.""" + self.material.analysis_status = MaterialStatus.FAILED + self.material.analysis_retry_count = 0 + self.material.save(update_fields=["analysis_status", "analysis_retry_count"]) + + data = self._get_stage() + + self.assertTrue(data["can_retry"]) + self.assertIsNone(data["retry_after_seconds"]) + + def test_stage_response_includes_can_retry_false_within_5min_processing(self): + """분석 시작 5분 이내에는 실제 응답에서도 can_retry=False + 남은 초가 내려가야 한다.""" + self.material.analysis_status = MaterialStatus.PROCESSING + self.material.analysis_started_at = timezone.now() - datetime.timedelta(minutes=1) + self.material.save(update_fields=["analysis_status", "analysis_started_at"]) + + data = self._get_stage() + + self.assertFalse(data["can_retry"]) + self.assertIsNotNone(data["retry_after_seconds"]) + self.assertTrue(200 <= data["retry_after_seconds"] <= 240) + + def test_stage_response_includes_can_retry_true_when_processing_over_5min(self): + """PROCESSING이 5분을 넘긴 좀비 상태면 실제 응답에서도 can_retry=True여야 한다.""" + self.material.analysis_status = MaterialStatus.PROCESSING + self.material.analysis_started_at = ( + timezone.now() - datetime.timedelta(seconds=PROCESSING_TIMEOUT_SECONDS + 1) + ) + self.material.save(update_fields=["analysis_status", "analysis_started_at"]) + + data = self._get_stage() + + self.assertTrue(data["can_retry"]) + self.assertIsNone(data["retry_after_seconds"]) + + def test_stage_response_includes_is_stale_true_when_zombie(self): + """리뷰 반영(#84): 5분 넘긴 좀비 PROCESSING이면 실제 응답에도 is_stale=True가 담겨야 한다.""" + self.material.analysis_status = MaterialStatus.PROCESSING + self.material.analysis_started_at = ( + timezone.now() - datetime.timedelta(seconds=PROCESSING_TIMEOUT_SECONDS + 1) + ) + self.material.save(update_fields=["analysis_status", "analysis_started_at"]) + + data = self._get_stage() + + self.assertTrue(data["is_stale"]) + + def test_stage_response_includes_is_stale_false_when_fresh_processing(self): + """5분 이내 PROCESSING(진짜 진행 중)이면 실제 응답에서도 is_stale=False여야 한다.""" + self.material.analysis_status = MaterialStatus.PROCESSING + self.material.analysis_started_at = timezone.now() - datetime.timedelta(minutes=1) + self.material.save(update_fields=["analysis_status", "analysis_started_at"]) + + data = self._get_stage() + + self.assertFalse(data["is_stale"]) + + def test_stage_response_distinguishes_zombie_with_retries_exhausted(self): + """ + 리뷰 반영(#84): 마지막(2번째) 재시도가 좀비가 되고 재시도 횟수까지 소진된 + 경우, "정상적으로 마지막 재시도가 진행 중인 상태"와 "이미 좀비이고 재시도도 + 더 못 하는 상태"를 stage(계속 ANALYZING)만으로는 구분할 수 없었다. + is_stale=True + can_retry=False 조합으로 실제 응답에서 구분 가능한지 확인한다. + """ + self.material.analysis_status = MaterialStatus.PROCESSING + self.material.analysis_retry_count = MAX_RETRY_COUNT # 재시도 횟수 이미 소진 + self.material.analysis_started_at = ( + timezone.now() - datetime.timedelta(seconds=PROCESSING_TIMEOUT_SECONDS + 1) + ) + self.material.save(update_fields=[ + "analysis_status", "analysis_retry_count", "analysis_started_at", + ]) + + data = self._get_stage() + + # stage 자체는 여전히 ANALYZING이라 이것만으로는 구분이 안 된다는 것도 같이 확인 + self.assertEqual(data["stage"], "ANALYZING") + # is_stale + can_retry 조합으로 "재시도 불가, 직접 작업 추가 안내"를 구분할 수 있어야 한다 + self.assertTrue(data["is_stale"]) + self.assertFalse(data["can_retry"]) + self.assertIsNone(data["retry_after_seconds"]) + + def test_stage_response_includes_can_retry_false_when_retries_exhausted(self): + """재시도 2회를 다 쓰면 실제 응답에서도 can_retry=False여야 한다 (버튼 숨김/비활성).""" + self.material.analysis_status = MaterialStatus.FAILED + self.material.analysis_retry_count = MAX_RETRY_COUNT + self.material.save(update_fields=["analysis_status", "analysis_retry_count"]) + + data = self._get_stage() + + self.assertFalse(data["can_retry"]) + + def test_stage_response_includes_can_retry_false_when_completed(self): + """분석이 끝났으면 실제 응답에서도 can_retry=False여야 한다 (버튼 숨김).""" + self.material.status = MaterialStatus.COMPLETED + self.material.analysis_status = MaterialStatus.COMPLETED + self.material.save(update_fields=["status", "analysis_status"]) + + data = self._get_stage() + + self.assertFalse(data["can_retry"]) + @patch("exams.services.analysis_orchestrator.estimate_task_minutes") def test_analyze_unexpected_exception_redirects_instead_of_500(self, mock_estimate): mock_estimate.side_effect = ValueError("예상시간 계산 중 알 수 없는 오류") @@ -1198,4 +1309,402 @@ def test_existing_unconfirmed_task_preserved_when_save_rolls_back(self): self.assertTrue( StudyTask.objects.filter(pk=old_task.pk, title="기존 작업").exists() - ) \ No newline at end of file + ) + +class ProcessingTimeoutTestCase(TestCase): + """ + 이슈 #52: 서버가 AI 분석 도중 비정상 종료되면 analysis_status가 PROCESSING으로 + 영원히 남아, 이후 어떤 분석/재시도 요청도 거부되는(좀비 상태) 문제 검증. + + - 좀비 구제는 retry_analysis()에서만 허용 (analyze_and_estimate()는 PENDING 전용) + - 재시도 횟수 제한을 FAILED/좀비 PROCESSING 양쪽에 동일하게 적용 + - analysis_started_at이 NULL인 PROCESSING도 좀비로 취급 + - 실행 소유권(analysis_run_id)으로 늦게 끝난 예전 실행이 최신 실행 결과를 + 덮어쓰지 못하게 방지 + """ + + def setUp(self): + self.user = User.objects.create_user( + username="timeout_tester@example.com", email="timeout_tester@example.com", password="pass1234!" + ) + self.period = ExamPeriod.objects.create( + user=self.user, title="타임아웃 테스트", + start_date=datetime.date(2026, 8, 1), end_date=datetime.date(2026, 8, 20), + ) + self.exam = Exam.objects.create( + exam_period=self.period, subject_name="테스트과목", exam_date=datetime.date(2026, 8, 18), + ) + + def _make_material(self, text="1장 개념 정리"): + return StudyMaterial.objects.create( + exam=self.exam, title="테스트 자료", extracted_text=text, + status=MaterialStatus.COMPLETED, + ) + + def _make_stale_processing(self, retry_count=0, started_at="stale"): + """PROCESSING + 좀비 조건을 만족하는 StudyMaterial을 만든다.""" + material = self._make_material() + material.analysis_status = MaterialStatus.PROCESSING + material.analysis_retry_count = retry_count + material.analysis_run_id = uuid.uuid4() # 원래(이제 좀비가 된) 실행의 run_id + if started_at == "stale": + material.analysis_started_at = ( + timezone.now() - datetime.timedelta(seconds=PROCESSING_TIMEOUT_SECONDS + 1) + ) + elif started_at is None: + material.analysis_started_at = None + else: + material.analysis_started_at = started_at + material.save(update_fields=[ + "analysis_status", "analysis_retry_count", "analysis_started_at", "analysis_run_id", + ]) + return material + + # ---------- 기본 동작 ---------- + + def test_start_processing_records_started_at_and_run_id(self): + material = self._make_material() + before = timezone.now() + + analyze_and_estimate(material) + + material.refresh_from_db() + self.assertIsNotNone(material.analysis_started_at) + self.assertGreaterEqual(material.analysis_started_at, before) + self.assertIsNotNone(material.analysis_run_id) + + def test_fresh_processing_still_blocks_duplicate_request(self): + """방금 시작된 PROCESSING(좀비 아님)은 그대로 중복 요청을 거부해야 한다.""" + material = self._make_material() + material.analysis_status = MaterialStatus.PROCESSING + material.analysis_started_at = timezone.now() + material.save(update_fields=["analysis_status", "analysis_started_at"]) + + with self.assertRaises(DuplicateAnalysisRequestError): + analyze_and_estimate(material) + + with self.assertRaises(DuplicateAnalysisRequestError): + retry_analysis(material) + + # ---------- 최초 분석은 좀비를 구제하지 않는다 ---------- + + def test_initial_analysis_does_not_rescue_zombie_processing(self): + material = self._make_stale_processing(retry_count=0) + + with self.assertRaises(DuplicateAnalysisRequestError): + analyze_and_estimate(material) + + material.refresh_from_db() + self.assertEqual(material.analysis_status, MaterialStatus.PROCESSING) + self.assertEqual(material.analysis_retry_count, 0) + + def test_retry_can_rescue_zombie_processing(self): + material = self._make_stale_processing(retry_count=0) + + tasks = retry_analysis(material) + + material.refresh_from_db() + self.assertTrue(len(tasks) > 0) + self.assertEqual(material.analysis_status, MaterialStatus.COMPLETED) + self.assertEqual(material.analysis_retry_count, 1) + + # ---------- 좀비여도 재시도 횟수 제한은 그대로 적용 ---------- + + def test_retry_rejected_when_retry_count_maxed_even_if_zombie(self): + material = self._make_stale_processing(retry_count=MAX_RETRY_COUNT) + + with self.assertRaises(RetryLimitExceededError): + retry_analysis(material) + + material.refresh_from_db() + self.assertEqual(material.analysis_retry_count, MAX_RETRY_COUNT) + self.assertEqual(material.analysis_status, MaterialStatus.PROCESSING) + + def test_retry_race_loser_gets_duplicate_request_not_retry_limit_exceeded(self): + """ + 리뷰 반영(#84): 마지막 재시도 자리를 두 요청이 동시에 놓고 경쟁하는 상황을 + 재현한다. retry_count=1(한 번 남음), status=FAILED인 material에서 한 + 요청이 먼저 _start_processing()에 성공해 retry_count=MAX/PROCESSING을 + 선점했다고 가정한 뒤, "진" 요청이 그 직후 재조회하면 어떤 예외를 받는지 + 확인한다. + + 이긴 요청이 이미 retry_count를 최대치로 올려놓은 상태이므로, PROCESSING + 여부를 retry_count 소진 여부보다 먼저 확인하지 않으면 "재시도 횟수를 + 다 썼다"는 잘못된 진단(RetryLimitExceededError)이 나간다 - 실제 이유는 + "지금 막 다른 요청이 처리를 시작했다"는 것인데도. PROCESSING을 먼저 + 확인하면 DuplicateAnalysisRequestError로 정확히 진단된다. + """ + material = self._make_material() + material.analysis_status = MaterialStatus.FAILED + material.analysis_retry_count = MAX_RETRY_COUNT - 1 # 마지막 재시도 한 번 남음 + material.save(update_fields=["analysis_status", "analysis_retry_count"]) + + # "이긴" 요청이 _start_processing()에 성공해 마지막 재시도 슬롯을 + # 선점했다고 가정한다 (retry_count=MAX, status=PROCESSING, 방금 시작함). + StudyMaterial.objects.filter(pk=material.pk).update( + analysis_status=MaterialStatus.PROCESSING, + analysis_retry_count=MAX_RETRY_COUNT, + analysis_started_at=timezone.now(), # 방금 시작 -> is_stale=False + analysis_run_id=uuid.uuid4(), + ) + material.refresh_from_db() + + with self.assertRaises(DuplicateAnalysisRequestError): + retry_analysis(material) + + # ---------- analysis_started_at이 NULL인 좀비도 구제 ---------- + + def test_retry_can_rescue_zombie_with_null_started_at(self): + material = self._make_stale_processing(retry_count=0, started_at=None) + + tasks = retry_analysis(material) + + material.refresh_from_db() + self.assertTrue(len(tasks) > 0) + self.assertEqual(material.analysis_status, MaterialStatus.COMPLETED) + self.assertEqual(material.analysis_retry_count, 1) + + def test_get_analysis_status_is_stale_true_when_started_at_null(self): + material = self._make_stale_processing(retry_count=0, started_at=None) + result = get_analysis_status(material) + self.assertTrue(result["is_stale"]) + + # ---------- is_stale 조회 ---------- + + def test_get_analysis_status_is_stale_true_when_zombie(self): + material = self._make_stale_processing(retry_count=0) + result = get_analysis_status(material) + self.assertTrue(result["is_stale"]) + + def test_get_analysis_status_is_stale_false_when_fresh(self): + material = self._make_material() + material.analysis_status = MaterialStatus.PROCESSING + material.analysis_started_at = timezone.now() + material.save(update_fields=["analysis_status", "analysis_started_at"]) + + result = get_analysis_status(material) + self.assertFalse(result["is_stale"]) + + def test_get_analysis_status_is_stale_false_when_not_processing(self): + material = self._make_material() + material.analysis_status = MaterialStatus.PENDING + result = get_analysis_status(material) + self.assertFalse(result["is_stale"]) + + # ---------- can_retry / retry_after_seconds (프론트가 버튼 상태를 서버 응답만으로 판단) ---------- + + def test_can_retry_false_within_5min_processing(self): + """분석 시작 5분 이내(진짜 진행 중)에는 재시도 버튼을 켜면 안 된다.""" + material = self._make_material() + material.analysis_status = MaterialStatus.PROCESSING + material.analysis_started_at = timezone.now() - datetime.timedelta(minutes=2) + material.save(update_fields=["analysis_status", "analysis_started_at"]) + + result = get_analysis_status(material) + + self.assertFalse(result["can_retry"]) + self.assertIsNotNone(result["retry_after_seconds"]) + # 2분 지났으니 남은 시간은 3분(180초) 근처여야 한다 + self.assertTrue(170 <= result["retry_after_seconds"] <= 180) + + def test_can_retry_true_when_processing_over_5min(self): + """PROCESSING이 5분을 넘긴 좀비 상태면 재시도 버튼을 켜야 한다.""" + material = self._make_stale_processing(retry_count=0) + result = get_analysis_status(material) + + self.assertTrue(result["can_retry"]) + self.assertIsNone(result["retry_after_seconds"]) + + def test_can_retry_true_when_failed_with_retries_left(self): + material = self._make_material() + material.analysis_status = MaterialStatus.FAILED + material.analysis_retry_count = 1 + material.save(update_fields=["analysis_status", "analysis_retry_count"]) + + result = get_analysis_status(material) + + self.assertTrue(result["can_retry"]) + self.assertIsNone(result["retry_after_seconds"]) + + def test_can_retry_false_when_retries_exhausted_even_if_failed(self): + """재시도 2회를 다 쓰면 FAILED여도 재시도 버튼을 숨겨야 한다.""" + material = self._make_material() + material.analysis_status = MaterialStatus.FAILED + material.analysis_retry_count = MAX_RETRY_COUNT + material.save(update_fields=["analysis_status", "analysis_retry_count"]) + + result = get_analysis_status(material) + + self.assertFalse(result["can_retry"]) + self.assertIsNone(result["retry_after_seconds"]) + + def test_can_retry_false_when_completed(self): + """분석이 끝난 자료는 재시도 버튼을 숨겨야 한다.""" + material = self._make_material() + material.analysis_status = MaterialStatus.COMPLETED + + result = get_analysis_status(material) + + self.assertFalse(result["can_retry"]) + self.assertIsNone(result["retry_after_seconds"]) + + def test_can_retry_false_when_pending(self): + """아직 최초 분석도 시작 안 한 자료는 "재시도" 대상이 아니다.""" + material = self._make_material() + material.analysis_status = MaterialStatus.PENDING + + result = get_analysis_status(material) + + self.assertFalse(result["can_retry"]) + self.assertIsNone(result["retry_after_seconds"]) + + def test_can_retry_false_when_extraction_not_completed(self): + """텍스트 추출이 아직 안 끝났으면, analysis_status가 뭐든 재시도는 불가능하다.""" + material = self._make_material() + material.status = MaterialStatus.PROCESSING + material.analysis_status = MaterialStatus.FAILED + material.save(update_fields=["status", "analysis_status"]) + + result = get_analysis_status(material) + + self.assertFalse(result["can_retry"]) + + # ---------- 실행 소유권 (analysis_run_id) ---------- + + def test_save_discards_result_when_run_superseded(self): + """ + DB 쓰기 직전에 소유권을 다시 확인하므로, 이미 다른 실행이 선점했다면 + StudyTask를 저장하지 않고 StaleAnalysisRunError를 던져야 한다. + """ + material = self._make_material() + run_id = _start_processing(material, is_retry=False) + self.assertIsNotNone(run_id) + + # 다른(더 최신) 실행이 이 자리를 이어받았다고 가정 + StudyMaterial.objects.filter(pk=material.pk).update(analysis_run_id=uuid.uuid4()) + + fake_tasks = [ + task_extractor.ExtractedTask( + unit_name="1장", title="가짜 작업", task_type="concept", + importance="high", depth="core", difficulty="normal", + ai_reason="테스트용", + ) + ] + + with self.assertRaises(StaleAnalysisRunError): + _save_tasks_with_estimates(material, fake_tasks, material.extracted_text, run_id) + + self.assertEqual(StudyTask.objects.filter(study_material=material).count(), 0) + + def test_finish_failure_raises_when_run_superseded(self): + """ + 뒤늦게 도착한 실패 처리는 최신 실행의 상태를 덮어쓰면 안 된다. + 리뷰 반영(#84): 조용히 무시하는 대신 StaleAnalysisRunError를 던진다 + (호출부가 "실패"가 아니라 "다른 실행에 넘어감"으로 정확히 처리하게 하기 위함). + """ + material = self._make_material() + old_run_id = _start_processing(material, is_retry=False) + + new_run_id = uuid.uuid4() + StudyMaterial.objects.filter(pk=material.pk).update( + analysis_status=MaterialStatus.PROCESSING, analysis_run_id=new_run_id, + ) + + with self.assertRaises(StaleAnalysisRunError): + _finish_failure(material, "예전 실행의 실패 메시지", old_run_id) + + material.refresh_from_db() + self.assertEqual(material.analysis_status, MaterialStatus.PROCESSING) + self.assertIsNone(material.analysis_error_message) + self.assertEqual(material.analysis_run_id, new_run_id) + + def test_new_run_after_zombie_gets_fresh_run_id(self): + """좀비를 이어받은 새 실행은 예전 실행과 다른 run_id를 받아야 한다.""" + material = self._make_stale_processing(retry_count=0) + old_run_id = material.analysis_run_id + + new_run_id = _start_processing(material, is_retry=True) + + self.assertIsNotNone(new_run_id) + self.assertNotEqual(new_run_id, old_run_id) + + def test_end_to_end_zombie_takeover_new_run_wins(self): + """ + 엔드 투 엔드: 좀비를 새 실행이 이어받아 끝까지 성공시키면, 예전 실행이 + 뒤늦게 같은 자리에 성공/실패를 기록하려 해도 반영되지 않아야 한다. + """ + material = self._make_stale_processing(retry_count=0) + old_run_id = material.analysis_run_id + + tasks = retry_analysis(material) # 새 실행이 좀비를 이어받아 정상 완료 + + material.refresh_from_db() + self.assertTrue(len(tasks) > 0) + self.assertEqual(material.analysis_status, MaterialStatus.COMPLETED) + new_run_id = material.analysis_run_id + self.assertNotEqual(new_run_id, old_run_id) + + # 예전(이제는 죽은) 실행이 뒤늦게 실패를 기록하려는 상황을 재현 + with self.assertRaises(StaleAnalysisRunError): + _finish_failure(material, "예전 실행의 뒤늦은 실패", old_run_id) + + material.refresh_from_db() + self.assertEqual(material.analysis_status, MaterialStatus.COMPLETED) + self.assertIsNone(material.analysis_error_message) + + def test_studytask_save_and_completion_are_rolled_back_together(self): + """ + 리뷰 반영(#84): StudyTask 저장과 analysis_status=COMPLETED 최종 전이가 + 이제 같은 트랜잭션으로 묶여있다. StudyTask가 저장(bulk_update)된 "직후", + 같은 트랜잭션이 끝나기 "전"에 다른 실행이 소유권을 가져가면, 최종 완료 + 전이가 실패하면서 StudyTask 저장까지 통째로 롤백되어야 한다. + + (이전 구조에서는 저장이 별도 트랜잭션으로 먼저 커밋되고, 완료 전이만 + 별도로 실패할 수 있어서 "StudyTask는 남아있는데 상태는 다른 것으로 + 바뀐" 어중간한 상태가 생길 수 있었다 - 특히 뒤이어 그 다른 실행마저 + 실패하면, 최종 상태는 FAILED인데 이전 실행의 StudyTask가 고아로 남아 + material_detail 등에서 그대로 노출될 위험이 있었다.) + """ + material = self._make_material() + original_bulk_update = StudyTask.objects.bulk_update + + def hijacking_bulk_update(objs, fields, **kwargs): + result = original_bulk_update(objs, fields, **kwargs) + # StudyTask 저장(bulk_update)은 이미 끝났지만, 아직 같은 트랜잭션 + # 안이다. 그 사이 다른 실행이 소유권을 가져갔다고 가정한다. + StudyMaterial.objects.filter(pk=material.pk).update( + analysis_run_id=uuid.uuid4() + ) + return result + + with patch.object(StudyTask.objects, "bulk_update", side_effect=hijacking_bulk_update): + with self.assertRaises(StaleAnalysisRunError): + analyze_and_estimate(material) + + # 트랜잭션 전체가 롤백됐어야 한다 - StudyTask도 저장되지 않은 채로 남아야 한다. + self.assertEqual(StudyTask.objects.filter(study_material=material).count(), 0) + material.refresh_from_db() + self.assertNotEqual(material.analysis_status, MaterialStatus.COMPLETED) + + @patch("exams.services.analysis_orchestrator._run_analysis_and_estimate") + def test_execute_analysis_reports_stale_run_instead_of_original_failure(self, mock_run): + """ + 리뷰 반영(#84): AI 분석 자체가 실패(AICallFailedError)한 시점에 이미 + 소유권을 잃었다면, 그 오래된 실패 사유가 아니라 StaleAnalysisRunError가 + 전파되어야 한다 - 안 그러면 이미 다른 실행이 정상 처리 중이거나 성공했을 + 수도 있는데, 사용자는 "실패했다"는 낡은 메시지를 보게 된다. + """ + material = self._make_material() + run_id = _start_processing(material, is_retry=False) + + # AI 호출 자체가 실패했다고 가정 + mock_run.side_effect = AICallFailedError("네트워크 오류") + # 동시에, 그 사이 다른 실행이 소유권을 이미 가져갔다고 가정 + StudyMaterial.objects.filter(pk=material.pk).update(analysis_run_id=uuid.uuid4()) + + with self.assertRaises(StaleAnalysisRunError): + _execute_analysis(material, run_id) + + material.refresh_from_db() + # 원래 실패 메시지("네트워크 오류")로 덮어써지면 안 된다 + self.assertIsNone(material.analysis_error_message) \ No newline at end of file diff --git a/exams/views.py b/exams/views.py index 926d4c9..98bde49 100644 --- a/exams/views.py +++ b/exams/views.py @@ -30,6 +30,7 @@ AnalysisNotSupportedError, RetryLimitExceededError, AnalysisPipelineError, + StaleAnalysisRunError, ) logger = logging.getLogger(__name__) @@ -377,6 +378,13 @@ def material_analyze(request, material_id): except DuplicateAnalysisRequestError: messages.info(request, "이미 분석 중이거나 처리된 자료입니다.") return redirect('exams:material_detail', material_id=material.id) + except StaleAnalysisRunError: + # 리뷰 반영(#84): 이 실행이 시작은 했지만, 완료 처리 직전에 다른(더 최신) + # 실행에게 선점당한 경우다. 진짜 시스템 오류가 아니라 정상적인 동시성 + # 상황이므로, DuplicateAnalysisRequestError와 같은 계열의 안내로 처리한다. + logger.info(f"AI 분석 실행이 완료 직전 다른 실행에 선점됨 (material_id={material_id})") + messages.info(request, "다른 요청이 먼저 이 자료를 처리했습니다. 최신 상태를 다시 확인해주세요.") + return redirect('exams:material_detail', material_id=material.id) except (AIAnalysisError, AnalysisPipelineError): messages.error(request, "AI 분석에 실패했습니다. 다시 시도하거나 직접 작업을 추가해주세요.") return redirect('exams:material_detail', material_id=material.id) @@ -403,6 +411,12 @@ def material_retry_analyze(request, material_id): except DuplicateAnalysisRequestError: messages.info(request, "이미 분석 중인 자료입니다.") return redirect('exams:material_detail', material_id=material.id) + except StaleAnalysisRunError: + # material_analyze()와 동일한 이유 - 완료 처리 직전에 다른 실행에게 + # 선점당한 정상적인 동시성 상황이다. + logger.info(f"AI 재시도 실행이 완료 직전 다른 실행에 선점됨 (material_id={material_id})") + messages.info(request, "다른 요청이 먼저 이 자료를 처리했습니다. 최신 상태를 다시 확인해주세요.") + return redirect('exams:material_detail', material_id=material.id) except AnalysisNotSupportedError as e: messages.error(request, str(e)) return redirect('exams:material_detail', material_id=material.id) @@ -440,11 +454,18 @@ def material_analysis_status(request, material_id): extraction_status = material.status extraction_error = material.error_message - # 피드백 4번 반영: 확정된 키 직접 사용 (fallback 제거) analysis_status = analysis_data["status"] analysis_error = analysis_data["error_message"] retry_count = analysis_data["retry_count"] retry_remaining = analysis_data["retry_remaining"] + # 재시도 버튼을 켜고 끄면 되도록 서버가 판단한 결과를 그대로 내려준다. + can_retry = analysis_data["can_retry"] + retry_after_seconds = analysis_data["retry_after_seconds"] + # 리뷰 반영(#84): stage="ANALYZING"만으로는 "정상적으로 진행 중"인지 + # "5분 넘게 멈춘 좀비인데 재시도 횟수까지 소진돼 더 이상 손쓸 수 없는 상태"인지 + # FE가 구분할 수 없었다. is_stale을 같이 내려줘서, is_stale=True인데 + # can_retry=False면 "재시도 불가, 직접 작업 추가 안내"로 구분할 수 있게 한다. + is_stale = analysis_data["is_stale"] # 1. 전체 stage 판정 로직 (작성하신 추출 우선 stage 판정 유지) failed_stage = None @@ -488,6 +509,9 @@ def material_analysis_status(request, material_id): "failed_stage": failed_stage, "retry_count": retry_count, "retry_remaining": retry_remaining, + "can_retry": can_retry, + "retry_after_seconds": retry_after_seconds, + "is_stale": is_stale, "study_material_id": material.id, "exam_id": material.exam_id, })