"""JSON 파일을 안전하게 저장하는 공통 유틸리티.""" import json import os import tempfile import time from pathlib import Path from typing import Any # 윈도우에서 `os.replace`는 대상 파일을 **누가 열고만 있어도** 거부당한다(WinError 5). # 진행률 파일처럼 서버가 쓰는 동안 화면이 계속 읽는 파일에서 실제로 부딪혔고, 전처리 # 산출물(structured.npz) 교체에서는 분석이 통째로 죽기도 했다(2026-08-08). # 상대가 파일을 놓는 데 걸리는 시간은 밀리초 수준이라 짧게 여러 번 다시 시도한다. _REPLACE_RETRY_COUNT = 12 _REPLACE_RETRY_DELAY_SECONDS = 0.05 def replace_with_retry(source: str | Path, target: str | Path) -> None: """`os.replace` — 다른 쪽이 잠깐 잡고 있으면 잠시 기다렸다 다시 시도한다.""" last_error: OSError | None = None for attempt in range(_REPLACE_RETRY_COUNT): try: os.replace(source, target) return except PermissionError as error: # 윈도우 공유 위반 last_error = error time.sleep(_REPLACE_RETRY_DELAY_SECONDS * (attempt + 1)) raise last_error if last_error else OSError("파일 교체에 실패했습니다.") def atomic_write_json(path: str | Path, value: Any) -> None: """같은 디렉터리의 임시 파일을 교체하여 JSON을 원자적으로 저장한다.""" target = Path(path) target.parent.mkdir(parents=True, exist_ok=True) temporary_path: Path | None = None try: with tempfile.NamedTemporaryFile( mode="w", encoding="utf-8", dir=target.parent, prefix=f".{target.name}.", suffix=".tmp", delete=False, ) as temporary: json.dump(value, temporary, ensure_ascii=False, indent=2) temporary.write("\n") temporary.flush() os.fsync(temporary.fileno()) temporary_path = Path(temporary.name) replace_with_retry(temporary_path, target) temporary_path = None finally: if temporary_path is not None: temporary_path.unlink(missing_ok=True)