8f25d396df
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>
79 lines
2.7 KiB
Python
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
|