Files
hyungi_document_server/app/workers/thumbnail_worker.py
T
hyungi c11f113cf1 fix(workers): silent completion 차단 — transient re-raise + enqueue 격리 (R3)
worker_fn 이 transient 실패를 삼켜 정상 반환하면 queue_consumer 가 status=completed
로 확정 → 영구 데이터 손실 + 재시도/추적 0. 정본(extract/marker/fulltext/stt 는
re-raise)과 어긋난 곳을 통일:
- deep_summary: 호출 실패(call_failed)를 삼키지 않고 raise → 재시도→failed dead-letter
  (이전엔 ai_detail_summary 영구 누락 + tier triage 고착).
- thumbnail: _extract_thumbnail 실패를 silent return → raise (썸네일 영구 누락 방지).
- queue_consumer: 완료 커밋 후 enqueue_next_stage(정상·skip-note 2곳)를 자체 try 로
  격리 — enqueue 실패가 outer except 로 전파돼 completed 항목을 재오픈(stage 재실행)
  하던 결함 차단. 실패는 ERROR 로 가시화.
- broad except 에 asyncio.CancelledError 명시 통과(embed worker / ask classifier·verifier).

dead-letter = ProcessingQueue.status='failed'(기존 attempts/max_attempts 머신 재사용,
신규 컬럼 불필요). 검증: py_compile 통과. 큐 재시도 의미 synthetic smoke(staging) 예정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:24:25 +09:00

135 lines
4.7 KiB
Python

"""비디오 썸네일 생성 워커 — ffmpeg subprocess 로 50% 지점 1장 추출.
PKM/Videos/.thumbs/{doc_id}.jpg 에 저장 후 documents.thumbnail_path 업데이트.
quarantine 상태(needs_conversion=true)인 파일은 건너뜀.
queue_consumer 와의 배선(stage 매핑)은 §1 category 분기와 묶여 있어 본 모듈은
유틸 + process() 진입점만 제공. queue_consumer 측 wiring 은 §1 의존 파트에서.
"""
import subprocess
import unicodedata
from datetime import datetime, timezone
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from core.config import settings
from core.utils import setup_logger
logger = setup_logger("thumbnail_worker")
THUMBS_DIR_NAME = "PKM/Videos/.thumbs"
FFMPEG_TIMEOUT = 30
def _resolve_path(file_path: str) -> Path | None:
"""NFC(DB) vs NFD(NFS) 한글 경로 차이 흡수. OCR/STT 서비스와 동일 패턴."""
candidates = [
file_path,
unicodedata.normalize("NFD", file_path),
unicodedata.normalize("NFC", file_path),
]
for c in candidates:
p = Path(c)
if p.exists():
return p
parent = Path(file_path).parent
if parent.exists():
target = unicodedata.normalize("NFC", Path(file_path).name)
for child in parent.iterdir():
if unicodedata.normalize("NFC", child.name) == target:
return child
return None
def _probe_duration_seconds(path: Path) -> float | None:
"""ffprobe 로 재생 길이 조회. 실패 시 None."""
try:
result = subprocess.run(
[
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(path),
],
capture_output=True, text=True, timeout=FFMPEG_TIMEOUT,
)
if result.returncode != 0:
return None
return float(result.stdout.strip())
except (subprocess.SubprocessError, ValueError):
return None
def _extract_thumbnail(source: Path, output: Path, seek_seconds: float) -> bool:
"""ffmpeg 로 seek_seconds 지점 1프레임을 jpg 로 추출. 성공 시 True."""
output.parent.mkdir(parents=True, exist_ok=True)
try:
result = subprocess.run(
[
"ffmpeg", "-y",
"-ss", f"{seek_seconds:.2f}",
"-i", str(source),
"-vframes", "1",
"-vf", "scale='min(640,iw)':-1",
"-q:v", "3",
str(output),
],
capture_output=True, text=True, timeout=FFMPEG_TIMEOUT,
)
if result.returncode != 0:
logger.error(f"[thumbnail] ffmpeg 실패: {source.name}{result.stderr[-400:]}")
return False
return output.exists() and output.stat().st_size > 0
except subprocess.SubprocessError as e:
logger.error(f"[thumbnail] subprocess 오류: {source.name}{e}")
return False
async def process(document_id: int, session: AsyncSession) -> None:
"""영상 문서 썸네일 생성 진입점 (queue_consumer 에서 호출 예정).
needs_conversion=True 는 skip. 파일 위치가 없으면 NFC/NFD resolver 로 보정.
"""
from models.document import Document
doc = await session.get(Document, document_id)
if not doc:
logger.error(f"[thumbnail] document_id={document_id} 없음")
return
if getattr(doc, "needs_conversion", False):
logger.info(f"[thumbnail] id={document_id} needs_conversion=true → skip")
return
if not doc.file_path:
logger.warning(f"[thumbnail] id={document_id} file_path 없음")
return
raw = str(Path(settings.nas_mount_path) / doc.file_path)
source = _resolve_path(raw)
if source is None:
logger.error(f"[thumbnail] 원본 없음: {raw}")
return
duration = _probe_duration_seconds(source)
seek = (duration * 0.5) if duration and duration > 0 else 1.0
thumbs_dir = Path(settings.nas_mount_path) / THUMBS_DIR_NAME
output = thumbs_dir / f"{document_id}.jpg"
ok = _extract_thumbnail(source, output, seek)
if not ok:
# 썸네일 추출 실패(ffmpeg)는 삼키지 않고 raise (R3) — queue_consumer 가 attempts
# 소진까지 재시도 후 status=failed 로 가시화. silent return 이면 큐가 completed 로
# 확정 + 썸네일 영구 누락 + 재시도/추적 0 (silent skip). 손상 영상이면 failed 로 안착.
raise RuntimeError(
f"thumbnail 추출 실패: document_id={document_id} source={source}"
)
doc.thumbnail_path = str(output)
doc.updated_at = datetime.now(timezone.utc)
await session.commit()
logger.info(f"[thumbnail] id={document_id}{output}")