Skip to content
Open
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
18 changes: 18 additions & 0 deletions exams/migrations/0003_studymaterial_analysis_started_at.py
Original file line number Diff line number Diff line change
@@ -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 분석 시작 시각'),
),
]
6 changes: 5 additions & 1 deletion exams/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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="생성일시")

Expand Down
53 changes: 45 additions & 8 deletions exams/services/analysis_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -69,6 +70,11 @@

MAX_RETRY_COUNT = 2

# PROCESSING 상태가 이 시간(초)보다 오래 지속되면 "좀비 상태"로 간주하고,
# 새 분석/재시도 요청이 이 자리를 대신 차지할 수 있게 허용한다.
# (서버가 분석 도중 크래시/강제종료되면 PROCESSING이 영원히 안 바뀔 수 있어서 생긴 문제)
PROCESSING_TIMEOUT_SECONDS = 300 # 5분


class DuplicateAnalysisRequestError(Exception):
"""이미 처리 중이거나(PROCESSING), 지금 상태에서는 분석/재시도를 시작할 수 없을 때"""
Expand Down Expand Up @@ -194,40 +200,61 @@ 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 하나로 "확인 + 변경"을 원자적으로 처리하기 때문에,
동시에 같은 요청이 여러 번 들어와도 정확히 하나만 성공한다.

Returns:
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
Expand Down Expand Up @@ -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": (
Expand All @@ -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,
}
109 changes: 109 additions & 0 deletions exams/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -22,6 +23,7 @@
AnalysisPipelineError,
DuplicateAnalysisRequestError,
MAX_RETRY_COUNT,
PROCESSING_TIMEOUT_SECONDS,
RetryLimitExceededError,
analyze_and_estimate,
get_analysis_status,
Expand Down Expand Up @@ -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) 검증.
Expand Down