Files
hyungi_document_server/app/workers/upload_cleanup.py
Hyungi Ahn 8f25d396df feat(upload): §4-독립 — error_code 체계 + .uploading orphan cleanup + 진행률/abort UX
plan: ~/.claude/plans/luminous-sprouting-hamster.md §4 (1GB/stt/dashboard 외 독립 항목)

backend:
- _upload_error(status, code, msg) 헬퍼 정의 (§3 가 호출만 추가했던 누락 수정).
  detail = {error_code, message} — 프론트가 error_code 로 분기.
- upload_document 의 모든 HTTPException 을 _upload_error 로 전환:
  body_too_large / invalid_input / empty_file / unsupported_codec / internal
- ClientDisconnect → 499 network_abort + 임시파일 정리.
  asyncio.TimeoutError → 408 upload_timeout.
- 쓰기 중 .uploading 임시명 → 완료 후 staging.replace(target) atomic rename.
  → 프로세스 크래시 잔존물은 cleanup_orphan_uploads 가 수거.
- file_watcher SKIP_EXTENSIONS 에 .uploading 추가 (오해 픽업 방지).

cleanup scheduler:
- workers/upload_cleanup.py 신규. 10분 주기로 Inbox 하위 *.uploading 중
  mtime > orphan_max_age_sec(3600) 인 파일 삭제.
- 최근 3회 (≈30분) 누적 삭제 수가 cleanup_warn_threshold(10) 이상이면
  WARNING 로그. in-memory deque (재시작 시 리셋) — 집요한 이슈만 잡는 목적.
- core/config.py UploadConfig 에 두 임계치 필드 (defaults — config.yaml override 무관).

frontend:
- api.ts: ApiError 에 optional errorCode/errorMessage 필드 (detail string 유지로
  기존 5+ 소비자 호환). parseDetail() 가 {error_code, message} 객체 응답을 풀어
  정규화. uploadFile(path, formData, {signal, onProgress}) XHR 헬퍼 신규
  (fetch() 가 upload progress 미지원이라 XHR). 401 refresh 1회 정책 동일.
- UploadDropzone.svelte 재작성: 진행률 바, 파일별/전체 abort 버튼, 페이지 이탈
  beforeunload 경고, errorCode 별 토스트 메시지 분기 (7 코드 — body_too_large /
  upload_timeout / network_abort / empty_file / invalid_input / unsupported_codec /
  internal). 컴포넌트 unmount 시 진행 중 업로드 abort.

보류:
- max_bytes 1GB 상향 + Caddyfile 1100MB (별도 결정으로 100MB 유지)
- /dashboard 카테고리 카드 (별도 plan)
- docs/categories.md (§1-3 정의 안착 후)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 06:57:02 +09:00

79 lines
2.7 KiB
Python

"""업로드 임시파일 cleanup 워커.
업로드 엔드포인트는 `<name>.uploading` 임시명으로 NAS Inbox 에 쓴 뒤
완료 시 atomic rename 한다. 정상 abort 는 endpoint 의 except 절이 정리하지만
프로세스 크래시 / 강제 종료 / 비정상 종료 시 `*.uploading` 잔존물이 남는다.
이 워커는 10분 주기로 Inbox 하위를 스캔해서
- mtime 이 `orphan_max_age_sec` (기본 1시간) 보다 오래된 `*.uploading` 삭제
- 최근 3회 (≈30분) 누적 삭제 수가 `cleanup_warn_threshold` (기본 10) 이상이면 WARNING
카운터는 in-memory deque (프로세스 재시작 시 리셋). 집요한 이슈만 잡는 것이 목적.
"""
from __future__ import annotations
import time
from collections import deque
from pathlib import Path
from core.config import settings
from core.utils import setup_logger
logger = setup_logger("upload_cleanup")
# 최근 3회 run 의 삭제 카운트. 30분 윈도우 (10분 주기 × 3).
_recent_deletes: deque[int] = deque(maxlen=3)
async def cleanup_orphan_uploads() -> int:
"""`*.uploading` orphan 파일을 수거. 삭제 수 반환.
호출은 APScheduler 가 10분 주기로 트리거.
"""
inbox_path = Path(settings.nas_mount_path) / "PKM" / "Inbox"
if not inbox_path.exists():
return 0
max_age = settings.upload.orphan_max_age_sec
threshold = settings.upload.cleanup_warn_threshold
now = time.time()
deleted = 0
total_bytes = 0
for f in inbox_path.rglob("*.uploading"):
try:
if not f.is_file():
continue
age = now - f.stat().st_mtime
if age < max_age:
continue
size = f.stat().st_size
f.unlink()
deleted += 1
total_bytes += size
logger.info("orphan upload deleted: %s (age=%ds, size=%d)", f.name, int(age), size)
except OSError as e:
# 다른 프로세스가 정리 중이거나 권한 문제 — 다음 주기에 재시도
logger.warning("orphan upload cleanup skipped %s: %s", f, e)
_recent_deletes.append(deleted)
window_total = sum(_recent_deletes)
if window_total >= threshold:
logger.warning(
"upload orphan cleanup high — window=%d (last %d runs), threshold=%d. "
"abort 가 구조적으로 많거나 대용량 업로드 실패 반복 의심.",
window_total,
len(_recent_deletes),
threshold,
)
elif deleted > 0:
logger.info(
"upload orphan cleanup: deleted=%d bytes=%d window_sum=%d",
deleted,
total_bytes,
window_total,
)
return deleted