Skip to content

feat: PROCESSING 좀비 상태 복구 - 실행 소유권(analysis_run_id) 및 can_retry API 필… - #84

Merged
6ye0m merged 6 commits into
devfrom
feature/52-run-ownership
Aug 9, 2026
Merged

feat: PROCESSING 좀비 상태 복구 - 실행 소유권(analysis_run_id) 및 can_retry API 필…#84
6ye0m merged 6 commits into
devfrom
feature/52-run-ownership

Conversation

@6ye0m

@6ye0m 6ye0m commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

close #52

작업 내용

exams/models.py
StudyMaterial에 analysis_started_at(PROCESSING 진입 시각), analysis_run_id(실행 식별자 UUID) 필드 추가
exams/services/analysis_orchestrator.py
PROCESSING_TIMEOUT_SECONDS = 300(5분) 기준으로 좀비 PROCESSING 판정, retry_analysis()에서만 구제 허용 (정책 단순화)
재시도 횟수 제한을 FAILED/좀비 양쪽에 동일하게 적용 (좀비라고 우회 불가)
analysis_started_at이 NULL인 경우(필드 추가 이전부터 PROCESSING이었던 데이터)도 좀비로 취급
analysis_run_id로 실행 소유권 관리 — 저장 직전 소유권 재확인(StaleAnalysisRunError), _finish_success/_finish_failure도 조건부 UPDATE로 변경해 뒤늦은 실행이 최신 상태를 덮어쓰지 못하게 방지
get_analysis_status()에 is_stale/can_retry/retry_after_seconds 추가
exams/views.py
material_analysis_status 응답에 can_retry/retry_after_seconds 포함
마이그레이션: exams/migrations/0003_studymaterial_analysis_run_id_and_more.py
exams/tests.py
ProcessingTimeoutTestCase(22개), MaterialAnalysisViewTestCase에 5개 추가 (총 27개)

검증 결과

python manage.py test exams.tests.ProcessingTimeoutTestCase → Ran 22 tests, OK
python manage.py test exams.tests.MaterialAnalysisViewTestCase → Ran 25 tests, OK
python manage.py test → Ran 240 tests, OK
manage.py check / makemigrations --check → 이상 없음

@wngjs8114

Copy link
Copy Markdown
Collaborator

전체적으로 #52 의도대로 잘 구현된 것 확인했습니다.
analysis_started_at 기반 좀비 판정, retry 횟수 제한, analysis_run_id 소유권 체크,
can_retry/retry_after_seconds 응답 및 테스트까지 방향은 좋습니다.

다만 머지 전에 한 가지 동시성 케이스만 확인/수정 부탁드립니다.

현재 _save_tasks_with_estimates()에서 StudyTask 저장 트랜잭션이 먼저 commit된 뒤
_finish_success()가 별도로 실행됩니다.

분석이 이미 5분을 넘긴 상태에서는 아래 순서가 가능해 보입니다.

  1. 기존 run A가 _save_tasks_with_estimates()에서 run_id=A 확인 후 StudyTask 저장/commit
  2. 그 직후 사용자가 좀비 재시도 → run B가 run_id를 B로 takeover
  3. run A의 _finish_success(run_id=A)는 조건부 UPDATE 0건으로 무시됨
  4. 그런데 _execute_analysis()는 그대로 tasks를 반환해서 View에서는
    "AI 분석이 완료되었습니다" 후 task_review로 이동할 수 있음

즉 상태 소유권 덮어쓰기는 막았지만,
StudyTask 저장 → COMPLETED 처리 사이에 소유권이 바뀌는 경우
이전 실행이 성공 응답을 반환할 수 있습니다.

_finish_success()가 소유권 상실 시 bool 또는 StaleAnalysisRunError를 반환/발생시키게 해서
기존 실행이 성공으로 끝나지 않게 하거나,
StudyTask 저장과 최종 상태 변경까지 같은 소유권 검증/transaction 범위에서
원자적으로 처리하는 방향으로 보완하면 좋을 것 같습니다.

이 케이스 테스트도 하나 추가 부탁드립니다.

@6ye0m

6ye0m commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

지적하신 동시성 케이스 반영했습니다.

수정 내용

_finish_success()가 소유권을 잃었을 때(조건부 UPDATE 0건) 조용히 return하던 걸, StaleAnalysisRunError를 던지도록 변경했습니다.

python
if not updated_count:
logger.warning(...)
raise StaleAnalysisRunError(...) # 이전: return

_execute_analysis()도 이 예외를 잡아서, StudyTask 저장은 끝났더라도 최종 완료 처리 시점에 소유권을 잃었다면 tasks를 정상 반환하지 않고 예외를 그대로 전파하도록 수정했습니다.

python
try:
_finish_success(study_material, tasks, run_id)
except StaleAnalysisRunError:
logger.info(...)
raise
return tasks

테스트 추가

test_ownership_lost_between_save_and_finish_does_not_report_success — _finish_success()를 patch해서, 그 함수가 호출되는 바로 그 시점(StudyTask 저장 커밋 직후, 완료 처리 직전)에 소유권이 다른 실행으로 넘어가는 상황을 재현했습니다. analyze_and_estimate()가 StaleAnalysisRunError를 던지고, analysis_status는 COMPLETED로 갱신되지 않으며, 이미 저장된 StudyTask는 그대로 남아있는 것까지 확인했습니다.

기존 test_finish_success_ignored_when_run_superseded도 새 동작(예외 발생)에 맞게 같이 수정했습니다.

검증 결과

python manage.py test → Ran 252 tests, OK
python manage.py check / makemigrations --check → 이상 없음

참고: _finish_failure()는 이번엔 대칭적으로 고치지 않았습니다. 실패 경로에서 소유권을 잃으면 원래 예외(AIAnalysisError 등)가 그대로 "실패"로 전달되는데, 이건 이번에 고친 "거짓 성공" 케이스보다 위험도가 낮다고 판단했습니다. 필요하시면 후속으로 대칭 처리하겠습니다.

@wngjs8114

Copy link
Copy Markdown
Collaborator

추가 수정 확인했습니다.

기존에 말씀드린 StudyTask 저장 → _finish_success() 사이 소유권 변경 케이스는 잘 보완된 것 확인했습니다. _finish_success()가 소유권 상실 시 StaleAnalysisRunError를 발생시키고, _execute_analysis()도 이 경우 tasks를 정상 반환하지 않아서 이전 실행이 성공 응답으로 끝나는 문제는 해결된 것으로 보입니다. 관련 테스트도 해당 타이밍을 잘 재현하고 있습니다.

다만 머지 전에 한 가지 케이스만 추가로 확인 부탁드립니다.

재시도 횟수를 모두 사용한 상태에서 마지막 실행이 좀비가 되면 FE에서 상태를 구분할 수 없습니다.

현재 정책상 좀비 PROCESSING이어도 analysis_retry_count < MAX_RETRY_COUNT 조건을 동일하게 적용하는 것은 맞습니다.

다만 예를 들어 2번째 재시도가 시작되어

analysis_retry_count=2 / analysis_status=PROCESSING

인 상태에서 서버가 비정상 종료되고 5분이 지나면 get_analysis_status() 내부적으로는

  • is_stale=True
  • retry_remaining=0
  • can_retry=False
  • retry_after_seconds=None

이 됩니다.

그런데 현재 material_analysis_status 응답에는 is_stale이 포함되지 않고, analysis_status=PROCESSING이므로 stage도 계속 ANALYZING으로 내려갑니다.

그러면 FE에서는

stage=ANALYZING + can_retry=false + retry_after_seconds=null

만 받아서 정상적으로 마지막 재시도가 진행 중인 상태와, 이미 5분을 넘긴 좀비인데 재시도 횟수까지 소진된 상태를 구분할 수 없습니다.

이 경우 #52에서 해결하려던 PROCESSING 고착 문제가 최종 재시도에서 다시 발생할 수 있을 것 같습니다.

get_analysis_status()에서 이미 계산하고 있는 is_stalematerial_analysis_status JSON에도 함께 내려주도록 추가 부탁드립니다.

그러면 FE에서는

  • is_stale=True, can_retry=True → 재시도 가능
  • is_stale=True, can_retry=False → 재시도 횟수 소진 / 직접 작업 추가 안내

처럼 처리할 수 있을 것 같습니다.

이 케이스에 대한 View 응답 테스트도 하나 추가해주시면 좋겠습니다.

그 외 기존 동시성 수정 사항은 확인됐고, 이 부분 반영되면 머지해도 될 것 같습니다.

참고로 병합 차단 사항은 아니지만 StaleAnalysisRunError가 현재 View의 generic Exception으로 처리되어 “알 수 없는 시스템 오류”가 표시되는 부분과, _finish_failure()의 소유권 상실 시 기존 실패 응답이 그대로 전달되는 부분은 후속으로 대칭 처리하면 더 깔끔할 것 같습니다.

@6ye0m

6ye0m commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

지적하신 두 가지 다 반영했습니다.

  1. is_stale을 material_analysis_status 응답에 추가

get_analysis_status()가 이미 계산하던 값을 응답에 노출만 하면 됐습니다.

json
{
"stage": "ANALYZING",
"can_retry": false,
"retry_after_seconds": null,
"is_stale": true
}

말씀하신 조합대로 FE에서 구분 가능합니다.

is_stale=True, can_retry=True → 재시도 가능
is_stale=True, can_retry=False → 재시도 횟수 소진 / 직접 작업 추가 안내

정확히 지적하신 시나리오(2번째 재시도가 좀비가 되고 5분 경과)를 재현하는 테스트를 추가했습니다.

python
def test_stage_response_distinguishes_zombie_with_retries_exhausted(self):
# retry_count=MAX, analysis_status=PROCESSING, 5분 초과
...
self.assertEqual(data["stage"], "ANALYZING") # stage만으론 구분 안 됨을 재확인
self.assertTrue(data["is_stale"])
self.assertFalse(data["can_retry"])

  1. 후속으로 남겨주신 두 가지도 같이 반영했습니다

StaleAnalysisRunError가 View의 generic except Exception:에 걸려 "알 수 없는 시스템 오류"로 나오던 부분 — material_analyze(), material_retry_analyze() 둘 다 except StaleAnalysisRunError:를 DuplicateAnalysisRequestError와 같은 급으로 분리해서, "다른 요청이 먼저 이 자료를 처리했습니다" 안내로 바꿨습니다.

_finish_failure()의 소유권 상실 시 대칭 처리 — _finish_success()처럼 조용히 넘어가는 대신 StaleAnalysisRunError를 던지도록 변경했고, _execute_analysis()의 실패 경로 4곳(StaleAnalysisRequestError, AIAnalysisError, Exception, tasks 0개) 전부에서 이 예외가 원래 실패 사유보다 우선 전파되도록 했습니다. 관련 테스트도 추가했습니다 (AI 호출 실패와 소유권 상실이 동시에 벌어지는 상황을 재현해서, "네트워크 오류" 같은 낡은 메시지 대신 StaleAnalysisRunError가 전파되는지 확인).

검증 결과

python manage.py test → Ran 256 tests, OK
python manage.py check / makemigrations --check → 이상 없음

@wngjs8114

Copy link
Copy Markdown
Collaborator

추가 반영 확인했습니다!
is_stale 응답 추가, StaleAnalysisRunError View 처리, _finish_failure()의 소유권 상실 대칭 처리까지 모두 방향 맞는 것 확인했습니다.

다만 최종으로 한 가지 데이터 정합성 케이스만 보완 부탁드립니다.

현재 _save_tasks_with_estimates()의 transaction에서 StudyTask가 먼저 저장/commit되고, 이후 별도로 _finish_success()가 호출됩니다. 이번 테스트에서도 이 사이에 소유권을 잃으면 StaleAnalysisRunError는 정상 전파되지만 이미 저장된 StudyTask는 그대로 남는 것으로 검증하고 있습니다.

예를 들어 run A가 StudyTask 저장 직후 run B에게 takeover되고, run B가 AI 호출 단계에서 실패해서 저장 단계까지 도달하지 못하면, material의 최종 상태는 FAILED인데 run A가 만든 StudyTask가 그대로 남을 수 있습니다. material_detail에서도 현재 StudyTask를 상태와 무관하게 조회하고 있어서 stale 결과가 노출될 가능성이 있습니다.

따라서 StudyTask 저장과 analysis_status=COMPLETED 최종 전이를 같은 짧은 transaction 안에서 처리해서, 최종 소유권 확인이 실패하면 StudyTask 저장도 같이 rollback되도록 하는 게 가장 안전할 것 같습니다. AI 호출은 이미 transaction 밖이므로 네트워크 호출 동안 DB transaction을 잡는 문제는 없습니다.

추가로 두 가지는 작은 사항입니다.

retry_analysis()에서 마지막 재시도 동시 요청 시, 한 요청이 retry_count=2 / PROCESSING을 선점하면 다른 요청이 DuplicateAnalysisRequestError 대신 RetryLimitExceededError를 받을 수 있어 보여서 PROCESSING 상태 판정 순서만 한번 확인 부탁드립니다.
이번 최신 커밋에 들어간 .gitignore의 test_real_api_3subjects.py 제거 및 OCR 테스트 파일 ignore 추가는 #52와 무관해 보여서 가능하면 원복 부탁드립니다.

이 부분까지 정리하고 최신 dev 반영 후 전체 테스트 한 번 재확인되면 머지해도 될 것 같습니다.

@6ye0m

6ye0m commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

지적하신 세 가지 다 반영했습니다.

  1. StudyTask 저장과 완료 전이를 같은 트랜잭션으로 통합 (핵심)

_finish_success()를 별도로 두지 않고, _save_tasks_with_estimates() 안에서 StudyTask 저장(bulk_update) 직후 analysis_status=COMPLETED 전이까지 같은 @transaction.atomic 블록에서 처리하도록 합쳤습니다.

python
@transaction.atomic
def _save_tasks_with_estimates(study_material, extracted_tasks, analyzed_text, run_id):
current = StudyMaterial.objects.select_for_update()...get(pk=study_material.pk)
# ... 기존 4가지 재검증 ...
tasks = save_extracted_tasks(current, extracted_tasks)
# ... 예상시간 계산, bulk_update ...

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(...)  # 트랜잭션 전체 롤백 - StudyTask 저장도 취소
...

select_for_update()로 잠근 행을 트랜잭션이 끝날 때까지 계속 들고 있기 때문에, 최종 완료 전이 시점에도 소유권이 그대로 보존됩니다. 혹시 실패하더라도 트랜잭션 전체가 롤백되어 StudyTask 저장까지 함께 취소되므로, 말씀하신 "저장은 됐는데 상태만 다른 것으로 바뀌는" 고아 데이터 문제가 해소됩니다. AI 네트워크 호출은 이미 이 함수 밖에서 끝난 뒤라 트랜잭션 동안 DB 커넥션을 오래 점유하는 문제는 없습니다.

_finish_success() 함수는 더 이상 필요 없어져서 삭제했고, _execute_analysis()도 그에 맞게 정리했습니다.

테스트 추가: test_studytask_save_and_completion_are_rolled_back_together — bulk_update 직후(같은 트랜잭션 안)에 소유권을 뺏는 상황을 재현해서, StudyTask가 저장되지 않은 채로 롤백되는지 확인했습니다.

  1. retry_analysis() PROCESSING 판정 순서

단순히 "PROCESSING을 먼저 체크"로 순서만 바꾸면 기존에 잘 동작하던 "좀비이면서 재시도까지 소진된" 케이스가 깨져서(RetryLimitExceededError가 나와야 하는데 DuplicateAnalysisRequestError로 잘못 나옴), is_stale 여부까지 같이 확인하도록 조정했습니다.

python
if status == MaterialStatus.PROCESSING:
if get_analysis_status(study_material)["is_stale"] and retry_count >= MAX_RETRY_COUNT:
raise RetryLimitExceededError(...) # 멈춰있고 더 이상 구제 불가
raise DuplicateAnalysisRequestError(...) # 진짜 진행 중이거나 아직 재시도 여지 있음

테스트 추가: test_retry_race_loser_gets_duplicate_request_not_retry_limit_exceeded — 마지막 재시도 슬롯을 두 요청이 동시에 노리는 경쟁 상황을 재현했습니다. 기존 test_retry_rejected_when_retry_count_maxed_even_if_zombie(진짜 좀비+소진 케이스)도 여전히 통과합니다.

@wngjs8114

Copy link
Copy Markdown
Collaborator

최신 커밋 다시 확인했습니다.

StudyTask 저장과 완료 전이 원자성, 마지막 재시도 동시 요청 처리까지 기존에 말씀드린 핵심 부분은 잘 반영된 것 같습니다.

.gitignore 변경은 그대로 두는 방향으로 확인했습니다.

현재 PR 브랜치가 최신 dev보다 뒤처져 있어서, 최신 dev만 반영한 뒤 아래 전체 검증 한 번 부탁드립니다.

python manage.py check
python manage.py makemigrations --check
python manage.py test

여기까지 통과하면 제 기준에서는 #84 머지해도 될 것 같습니다.

@6ye0m
6ye0m merged commit a7940b5 into dev Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor: 비정상 종료 시 PROCESSING 상태가 장시간 유지되는 문제 개선

2 participants