fix(queue): enqueue 경로 중복 방어 — partial unique index + 중앙 enqueue_stage 함수

기존 UNIQUE(document_id, stage, status)는 pending+processing 동시 존재를
허용해서 stale 복구 시 충돌 발생. 2-layer 방어로 근본 차단:

1) DB: partial unique index uq_queue_active — 활성 행(pending/processing)은
   (document_id, stage)당 최대 1개만 허용
2) App: enqueue_stage() 중앙 함수 — INSERT ON CONFLICT DO NOTHING으로
   모든 9개 경로의 check-then-insert TOCTOU race 제거

migration 117은 guard check 포함 — 활성 중복이 남아있으면 RAISE EXCEPTION
으로 중단, 수동 정리 유도.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-15 08:37:32 +09:00
parent 8ec1e53ca4
commit 751cdc5be8
9 changed files with 77 additions and 65 deletions
+23 -3
View File
@@ -2,7 +2,9 @@
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, SmallInteger, Text, UniqueConstraint
from sqlalchemy import BigInteger, DateTime, Enum, ForeignKey, SmallInteger, Text, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column
from core.database import Base
@@ -29,6 +31,24 @@ class ProcessingQueue(Base):
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = (
UniqueConstraint("document_id", "stage", "status"),
# DB 제약은 partial unique index uq_queue_active로 관리 (migration 117)
async def enqueue_stage(
session: AsyncSession, document_id: int, stage: str, *, status: str = "pending",
) -> bool:
"""ProcessingQueue에 행 추가 (DB 레벨 중복 방어).
같은 (document_id, stage)에 활성 행(pending/processing)이 이미 있으면
아무것도 하지 않고 False 반환.
"""
stmt = (
pg_insert(ProcessingQueue)
.values(document_id=document_id, stage=stage, status=status)
.on_conflict_do_nothing(
index_elements=["document_id", "stage"],
index_where=text("status IN ('pending', 'processing')"),
)
)
result = await session.execute(stmt)
return result.rowcount > 0