From 1157371fd8f8d2893c642d530a56ec2c1b59e126 Mon Sep 17 00:00:00 2001 From: youkyeom Date: Tue, 4 Aug 2026 16:50:57 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EB=B9=84=EC=A0=95=EC=83=81=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=EC=8B=9C=20PROCESSING=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EA=B0=80=20=EC=9E=A5=EC=8B=9C=EA=B0=84=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../0003_studymaterial_analysis_started_at.py | 18 +++ exams/models.py | 6 +- exams/services/analysis_orchestrator.py | 53 +++++++-- exams/tests.py | 109 ++++++++++++++++++ 4 files changed, 177 insertions(+), 9 deletions(-) create mode 100644 exams/migrations/0003_studymaterial_analysis_started_at.py diff --git a/exams/migrations/0003_studymaterial_analysis_started_at.py b/exams/migrations/0003_studymaterial_analysis_started_at.py new file mode 100644 index 0000000..0d5fe80 --- /dev/null +++ b/exams/migrations/0003_studymaterial_analysis_started_at.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.16 on 2026-08-04 07:42 + +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_started_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='AI 분석 시작 시각'), + ), + ] diff --git a/exams/models.py b/exams/models.py index 20b8749..5310e57 100644 --- a/exams/models.py +++ b/exams/models.py @@ -128,7 +128,7 @@ class StudyMaterial(models.Model): ) error_message = models.TextField(null=True, blank=True, verbose_name="추출/파싱 실패 원인") - # AI 분석(E-AI-01/02/03) 상태 - 위 status/error_message(텍스트 추출)와는 별개 필드. + # AI 분석 상태 - 위 status/error_message(텍스트 추출)와는 별개 필드. # 리뷰 확정 사항: 텍스트 추출 성공 여부와 AI 분석 성공 여부는 서로 다른 단계라 분리한다. analysis_status = models.CharField( max_length=20, @@ -138,6 +138,10 @@ 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 분석 사용자 재시도 횟수") + # PROCESSING으로 전이된 시각. 서버가 분석 도중 비정상 종료되면 이 값이 오래된 + # 채로 PROCESSING 상태가 영원히 유지될 수 있어서, 일정 시간 경과 여부를 판단하는 + # 기준으로 쓴다 (analysis_orchestrator.PROCESSING_TIMEOUT_SECONDS 참고). + analysis_started_at = models.DateTimeField(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 6c04118..715e604 100644 --- a/exams/services/analysis_orchestrator.py +++ b/exams/services/analysis_orchestrator.py @@ -57,7 +57,8 @@ import logging 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 @@ -69,6 +70,11 @@ MAX_RETRY_COUNT = 2 +# PROCESSING 상태가 이 시간(초)보다 오래 지속되면 "좀비 상태"로 간주하고, +# 새 분석/재시도 요청이 이 자리를 대신 차지할 수 있게 허용한다. +# (서버가 분석 도중 크래시/강제종료되면 PROCESSING이 영원히 안 바뀔 수 있어서 생긴 문제) +PROCESSING_TIMEOUT_SECONDS = 300 # 5분 + class DuplicateAnalysisRequestError(Exception): """이미 처리 중이거나(PROCESSING), 지금 상태에서는 분석/재시도를 시작할 수 없을 때""" @@ -194,11 +200,18 @@ def _start_processing(study_material: StudyMaterial, *, is_retry: bool) -> bool: """ analysis_status를 PROCESSING으로 원자적으로 전이시킨다. - - is_retry=False (최초 분석): 현재 analysis_status가 PENDING일 때만 전이 + - is_retry=False (최초 분석): 현재 analysis_status가 PENDING일 때, 또는 + PROCESSING이지만 PROCESSING_TIMEOUT_SECONDS 이상 경과한 "좀비 상태"일 때 전이 - is_retry=True (재시도): 현재 analysis_status가 FAILED이고 - analysis_retry_count < MAX_RETRY_COUNT일 때만 전이, + analysis_retry_count < MAX_RETRY_COUNT일 때, 또는 + 마찬가지로 좀비 상태인 PROCESSING일 때 전이, 전이와 동시에 analysis_retry_count를 1 증가시킨다. + 좀비 상태 처리: 서버가 분석 도중 크래시하거나 강제 종료되면 analysis_status가 + PROCESSING으로 영원히 남아, 이후 어떤 요청도 거부되는 상태가 될 수 있다. + analysis_started_at을 기준으로 PROCESSING_TIMEOUT_SECONDS 이상 지난 PROCESSING은 + "좀비"로 간주하고, 정상적인 PENDING/FAILED와 동등하게 새 시도를 허용한다. + DB 조건부 UPDATE 하나로 "확인 + 변경"을 원자적으로 처리하기 때문에, 동시에 같은 요청이 여러 번 들어와도 정확히 하나만 성공한다. @@ -206,28 +219,42 @@ def _start_processing(study_material: StudyMaterial, *, is_retry: bool) -> bool: True: 전이에 성공함 (study_material 인스턴스도 최신값으로 갱신됨) False: 조건이 안 맞아 전이하지 못함 (이미 처리중/조건 불충족 등) """ + now = timezone.now() + stale_cutoff = now - timezone.timedelta(seconds=PROCESSING_TIMEOUT_SECONDS) + is_zombie_processing = Q( + analysis_status=MaterialStatus.PROCESSING, + analysis_started_at__lt=stale_cutoff, + ) + if is_retry: - updated_count = StudyMaterial.objects.filter( - pk=study_material.pk, + eligible = Q( analysis_status=MaterialStatus.FAILED, analysis_retry_count__lt=MAX_RETRY_COUNT, + ) | is_zombie_processing + updated_count = StudyMaterial.objects.filter( + 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, ) else: + eligible = Q(analysis_status=MaterialStatus.PENDING) | is_zombie_processing updated_count = StudyMaterial.objects.filter( - pk=study_material.pk, - analysis_status=MaterialStatus.PENDING, + Q(pk=study_material.pk) & eligible ).update( analysis_status=MaterialStatus.PROCESSING, analysis_error_message=None, + analysis_started_at=now, ) 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", + ] ) return True return False @@ -295,8 +322,17 @@ 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인데 타임아웃을 넘겨 "좀비" 상태인지 } """ + is_stale = False + if ( + study_material.analysis_status == MaterialStatus.PROCESSING + and study_material.analysis_started_at is not None + ): + elapsed = timezone.now() - study_material.analysis_started_at + is_stale = elapsed.total_seconds() >= PROCESSING_TIMEOUT_SECONDS + return { "status": study_material.analysis_status, "error_message": ( @@ -306,4 +342,5 @@ def get_analysis_status(study_material: StudyMaterial) -> dict: ), "retry_count": study_material.analysis_retry_count, "retry_remaining": max(0, MAX_RETRY_COUNT - study_material.analysis_retry_count), + "is_stale": is_stale, } \ No newline at end of file diff --git a/exams/tests.py b/exams/tests.py index 541fd8a..f55d130 100644 --- a/exams/tests.py +++ b/exams/tests.py @@ -5,6 +5,7 @@ from django.test import TestCase 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 @@ -22,6 +23,7 @@ AnalysisPipelineError, DuplicateAnalysisRequestError, MAX_RETRY_COUNT, + PROCESSING_TIMEOUT_SECONDS, RetryLimitExceededError, analyze_and_estimate, get_analysis_status, @@ -559,6 +561,113 @@ def test_get_analysis_status_reports_retry_remaining(self): self.assertEqual(result["retry_remaining"], MAX_RETRY_COUNT - 1) +class ProcessingTimeoutTestCase(TestCase): + """ + 이슈: 서버가 AI 분석 도중 비정상 종료되면 analysis_status가 PROCESSING으로 + 영원히 남아, 이후 어떤 분석/재시도 요청도 거부되는(좀비 상태) 문제 검증. + """ + + 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, + ) + + def test_start_processing_records_started_at(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) + + 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) + + def test_zombie_processing_can_be_rescued_via_initial_analysis(self): + """타임아웃을 넘긴 PROCESSING은 최초 분석 경로로도 다시 시작할 수 있어야 한다.""" + material = self._make_material() + material.analysis_status = MaterialStatus.PROCESSING + material.analysis_started_at = ( + timezone.now() - datetime.timedelta(seconds=PROCESSING_TIMEOUT_SECONDS + 1) + ) + material.save(update_fields=["analysis_status", "analysis_started_at"]) + + tasks = analyze_and_estimate(material) + + material.refresh_from_db() + self.assertTrue(len(tasks) > 0) + self.assertEqual(material.analysis_status, MaterialStatus.COMPLETED) + + def test_zombie_processing_can_be_rescued_via_retry(self): + """타임아웃을 넘긴 PROCESSING은 재시도 경로로도 다시 시작할 수 있고, + 재시도 횟수도 정상적으로 증가해야 한다.""" + material = self._make_material() + material.analysis_status = MaterialStatus.PROCESSING + material.analysis_retry_count = 0 + material.analysis_started_at = ( + timezone.now() - datetime.timedelta(seconds=PROCESSING_TIMEOUT_SECONDS + 1) + ) + material.save(update_fields=["analysis_status", "analysis_retry_count", "analysis_started_at"]) + + 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_reports_is_stale_true_when_zombie(self): + material = self._make_material() + material.analysis_status = MaterialStatus.PROCESSING + material.analysis_started_at = ( + timezone.now() - datetime.timedelta(seconds=PROCESSING_TIMEOUT_SECONDS + 1) + ) + material.save(update_fields=["analysis_status", "analysis_started_at"]) + + result = get_analysis_status(material) + + self.assertTrue(result["is_stale"]) + + def test_get_analysis_status_reports_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_reports_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"]) + + class MaterialAnalysisViewTestCase(TestCase): """ AI 분석 관련 View(material_analyze/material_retry_analyze/material_analysis_status) 검증.