사용자 결정(2026-08-08): 새 자료를 올리면 재계산해 덮어쓰고, 화면은 불러올 자료가 없으면 대시보드로 보낸다. 그러려면 자료가 갈리는 순간 옛 계산 결과가 남아 있으면 안 된다. - common_util_project_reset 신설: 단계별 산출물 폴더(B04~B09)와 산출물 DB 레코드 (surface_models·routes·route_points·route_statistics·longitudinal_sections· cross_sections·structures·quantity_items·outputs·processed_point_cloud)를 함께 지운다. **B03_FileInput(업로드 원본)은 지우지 않는다** — 같은 파일인지 가리는 중복 검사가 쓴다. - 직접 업로드 완료·보관함 연결 양쪽에서 정리를 호출하고, 진행 단계도 1단계 이후를 NOT_STARTED로 되돌린다(reset_stages_after_input_change). - is_analysis_running(): 전처리가 도는 중이면 업로드 세션 생성과 보관함 연결을 409로 막는다. 화면 버튼 잠금은 새로고침·다른 탭으로 우회되므로 서버에도 문을 단다. - fail_stage 아래 있던 리비전(번호표) 안은 폐기 — 사용자가 "산출물이 없으면 대시보드" 방식으로 정리했다. 곁들여: os.replace 공유 위반 재시도(replace_with_retry) 진행률 파일은 서버가 쓰는 동안 화면이 계속 읽어 WinError 5가 났고, 전처리 structured.npz 교체에서는 같은 이유로 분석이 통째로 죽었다(결함 3의 사망 원인). 짧게 여러 번 다시 시도하도록 바꿨다. 검증(실서버 f45243b3, 계획노선 CSV 재업로드로 자료 교체): 전처리 결과 112개 -> 재분석분만, 노선 2->0, 횡단 20->0, DB 지표면 15->0 / 노선 1->0 / 횡단 19->0, 업로드 원본 6개는 그대로. 진행 단계가 초기로 돌아가고 재분석이 자동 시작됨. 교체 재시도는 읽는 쪽이 파일을 잡고 있는 상황을 만들어 성공 확인. ruff format·check 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
57 lines
2.2 KiB
Python
57 lines
2.2 KiB
Python
"""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)
|