fix(476-1 ④): 추천 [적용]도 [저장]과 같은 프로젝트 잠금 · 떠 두고 실패면 되돌림
- replan_route as_initial = guard.project_lock 안 · DB 행(지표면 확정 포함) · storage(STORAGE_SCOPE + 배수) 떠 둠 → 실패(409 · 예외)면 되돌림 · 응답 waited - 본체 `_save_route`(손 노선 [저장] = /design/save 가 잠금 · 떠 두기) - 부름 없던 `_write_working_route` 지움(700줄) - 시험 test_476_1_apply_initial 겹침 기다림 · 실패 되돌림 · 손 저장 잠금 밖 · 346-3 가짜 세계에 떠 두기 가짜 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,6 @@
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import csv
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
@@ -49,7 +48,6 @@ from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이
|
||||
)
|
||||
from common_util.common_util_initial_snapshot import design_route_csv_path
|
||||
from common_util.common_util_route_geometry import (
|
||||
densify_route,
|
||||
expected_route_csv_path,
|
||||
planned_route_initial_path,
|
||||
planned_route_working_path,
|
||||
@@ -59,11 +57,6 @@ from common_util.common_util_route_geometry import (
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import (
|
||||
ROUTE_DIRECT_LINK_CELL_FACTOR,
|
||||
ROUTE_GRID_RES_M,
|
||||
ROUTE_PLANNED_DENSIFY_SAFETY,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
|
||||
@@ -307,25 +300,6 @@ def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None:
|
||||
return summary
|
||||
|
||||
|
||||
def _write_working_route(path: Path, vertices: list[tuple[float, float]]) -> int:
|
||||
"""계획노선(수정본) CSV를 쓴다. 열 이름은 `read_planned_route_csv()`가 아는 것.
|
||||
|
||||
쓰기 전에 **조밀화**한다 — 정점이 성기면 B05 격자 탐색이 원좌표를 그대로 잇지 못하고
|
||||
제 나름의 길을 찾아 사용자가 그린 선과 달라진다(`densify_route` 주석). 평면 형상은
|
||||
바뀌지 않고 같은 선 위에 점만 더 찍힌다.
|
||||
"""
|
||||
dense = densify_route(
|
||||
vertices,
|
||||
ROUTE_DIRECT_LINK_CELL_FACTOR * ROUTE_GRID_RES_M * ROUTE_PLANNED_DENSIFY_SAFETY,
|
||||
)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8", newline="") as file:
|
||||
writer = csv.writer(file)
|
||||
writer.writerow(("sequence", "x", "y"))
|
||||
writer.writerows((index, round(x, 4), round(y, 4)) for index, (x, y) in enumerate(dense))
|
||||
return len(dense)
|
||||
|
||||
|
||||
async def _recompute(
|
||||
project_id: UUID,
|
||||
project_root: Path,
|
||||
@@ -552,7 +526,42 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An
|
||||
"""노선 초안을 정본으로 저장한다(346-3 · 요청 · 응답 모양 = `B05_Profile_Route_Save` 머리).
|
||||
|
||||
배수유역은 다시 안 셈(낡음 표시) · 초기값은 추천 [적용](`as_initial`)일 때만 다시 뜬다.
|
||||
추천 [적용] = 즉시 저장(요구 275 ②) · [저장]과 같은 프로젝트 잠금 · 떠 두고 실패면 되돌림(476 ·
|
||||
472-6 `common_util_save_guard`) · 응답 `waited` · ⚠ `/design/save` 잠금 안에서 as_initial 금지.
|
||||
"""
|
||||
if not getattr(body, "as_initial", False):
|
||||
return await _save_route(project_id, body)
|
||||
from B06_Section.B06_Section_Router_DesignSave import _restore
|
||||
from common_util import common_util_save_guard as guard
|
||||
|
||||
lock = guard.project_lock(str(project_id))
|
||||
waited = lock.locked()
|
||||
async with lock:
|
||||
paths = await _project_paths(project_id)
|
||||
if paths is None:
|
||||
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection: # 설계 지형 바꿈(지표면 확정)도 떠 둠
|
||||
rows = await guard.snapshot_rows(connection, str(project_id), surface=True)
|
||||
files = await asyncio.to_thread(
|
||||
guard.snapshot_storage, paths[0], guard.STORAGE_SCOPE + guard.PIPE_SCOPE
|
||||
)
|
||||
try:
|
||||
result = await _save_route(project_id, body)
|
||||
except Exception:
|
||||
await _restore(project_id, rows, files)
|
||||
raise
|
||||
else:
|
||||
if isinstance(result, JSONResponse) and result.status_code >= 400:
|
||||
await _restore(project_id, rows, files)
|
||||
return result
|
||||
finally:
|
||||
files.discard()
|
||||
return {**result, "waited": waited}
|
||||
|
||||
|
||||
async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any] | JSONResponse:
|
||||
"""노선 저장 본체(잠금 · 떠 두기는 부르는 쪽 — `replan_route` · `/design/save`)."""
|
||||
as_initial = bool(getattr(body, "as_initial", False)) # 옛 요청 꼴(파이썬 호출)도 받음
|
||||
try:
|
||||
request = body.resolved() if isinstance(body, RouteSaveRequest) else body
|
||||
|
||||
@@ -52,7 +52,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함
|
||||
|
||||
| 내용 | 자리(파일:줄) | 상태 |
|
||||
|---|---|---|
|
||||
| 저장 밖 정본 쓰기 셋 — 넓힘 `/regenerate` · [측점 만들기] · 추천 [적용] | `B06_Section_Router.py:378-388` · `B06_Section_Router_Stations.py:236-296` · `B05_Profile_UI_RouteEdit_Apply.ts:220-245` | 사용자 확정 · 확인창 · 472-6 협의 |
|
||||
| 저장 밖 정본 쓰기 셋 — 넓힘 `/regenerate` · [측점 만들기] · 추천 [적용](즉시 저장 · [저장]과 같은 프로젝트 잠금 · 떠 두고 실패면 되돌림 · 476 ④) | `B06_Section_Router.py:378-388` · `B06_Section_Router_Stations.py:236-296` · `B05_Profile_UI_RouteEdit_Apply.ts:220-245` · `B05_Profile_Router_Replan.py` replan_route | 사용자 확정(요구 275 ②) · 확인창 |
|
||||
| `pipe_points.json` 한 [저장]에 두 번 PUT(관 · 관 옵션) | `B06_Section_Api_Culvert_Options.ts:67-95` | 코드 확인 |
|
||||
| 노선 바뀐 [저장]의 관 옵션 차례 = 옛 노선 열쇠(누가거리 · 관이 따라 옮긴 새 자리와 안 맞으면 안 얹힘) | `design_save.ts:145` · `B06_Section_Router_DesignSave.py` `_patched_points` | 추정 · 옛 길과 같음 |
|
||||
| ↶ ↷ 가 세션을 직접 되씀 — 관 · 구조물 · 추가 측점은 되쓴 뒤 가게 듣는 쪽을 부름(`announceDesignStore` · 472-5) · B06 조정창 = 세션 칸이 주인(사본 없음) · 칸이 바뀐 걸음만 상세 얹기 · 카드 · 면적 다시(472-5 5-D) · 암선 · 암 경사 · 상단측 = 세션 칸이 주인(사본 없음 · 472-5) | `B05_Profile_UI_Profile_History.ts` apply · `design_store.ts` announceDesignStore · `B06_Section_UI_Page_Station_Controls.ts` onRestore · `B06_Section_UI_Page_Persist.ts` readCells | 관 · 구조물 = `test_472_5_shop_owner` · 조정창 = `test_472_5d_adjust_owner` · 암선 · 암 경사 = `test_472_5r_rock_owner` |
|
||||
|
||||
@@ -467,6 +467,12 @@ def api_world(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(
|
||||
route_initial, "refresh_initial_snapshot", lambda *a: calls.append(a) or _async(None)()
|
||||
)
|
||||
# 476 ④ — 추천 [적용]은 [저장]과 같은 잠금 · 떠 두기(DB · storage) 안에서 돎
|
||||
import common_util.common_util_save_guard as guard
|
||||
|
||||
monkeypatch.setattr(replan, "get_db_pool", lambda: _Pool([]))
|
||||
monkeypatch.setattr(guard, "snapshot_rows", _async({}))
|
||||
monkeypatch.setattr(guard, "snapshot_storage", lambda *a: SimpleNamespace(discard=lambda: None))
|
||||
return calls
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import sys
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TESTER = Path(__file__).resolve().parent
|
||||
for path in (ROOT, TESTER):
|
||||
@@ -96,11 +98,93 @@ def test_적용은_체인에_initial(api_world, monkeypatch):
|
||||
|
||||
monkeypatch.setattr(replan, "_recompute", recompute)
|
||||
body = save.RouteSaveRequest(vertices=[{"x": 0, "y": 0}, {"x": 5, "y": 5}])
|
||||
asyncio.run(replan.replan_route(uuid4(), body))
|
||||
asyncio.run(replan.replan_route(uuid4(), body.model_copy(update={"as_initial": True})))
|
||||
asyncio.run(replan._save_route(uuid4(), body)) # 잠금 · 떠 두기 밖 본체(④)
|
||||
asyncio.run(replan._save_route(uuid4(), body.model_copy(update={"as_initial": True})))
|
||||
assert seen == [False, True]
|
||||
|
||||
|
||||
def test_횡단_손_입력_넘김_한_자리():
|
||||
# 사용자 답 전 = [적용]도 넘김(475-3) — 답 뒤 이 함수 한 줄
|
||||
assert save.carries_cross_inputs(True) is True and save.carries_cross_inputs(False) is True
|
||||
|
||||
|
||||
# ── ④ [적용] = [저장]과 같은 잠금 · 떠 두고 실패면 되돌림 ─────────────────────────
|
||||
|
||||
|
||||
class _Files:
|
||||
def discard(self):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def guarded(monkeypatch, tmp_path):
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
import B06_Section.B06_Section_Router_DesignSave as design_save
|
||||
import common_util.common_util_save_guard as guard
|
||||
|
||||
log: list = []
|
||||
|
||||
class _Pool:
|
||||
def acquire(self):
|
||||
class _A:
|
||||
async def __aenter__(self):
|
||||
return None
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
return _A()
|
||||
|
||||
async def save(project_id, body):
|
||||
rev = int(body.vertices[0].x)
|
||||
log.append(("start", rev))
|
||||
await asyncio.sleep(0.05)
|
||||
log.append(("end", rev))
|
||||
if rev == 9:
|
||||
return JSONResponse(status_code=409, content={"status": "error", "message": "끊김"})
|
||||
return {"status": "success", "route_id": 1}
|
||||
|
||||
async def restore(project_id, rows, files):
|
||||
log.append(("restore", rows))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(replan, "_project_paths", _async((tmp_path, "p")))
|
||||
monkeypatch.setattr(replan, "get_db_pool", lambda: _Pool())
|
||||
monkeypatch.setattr(replan, "_save_route", save)
|
||||
monkeypatch.setattr(guard, "snapshot_rows", lambda *a, **k: log.append("rows") or _async("R")())
|
||||
monkeypatch.setattr(guard, "snapshot_storage", lambda *a: log.append("files") or _Files())
|
||||
monkeypatch.setattr(design_save, "_restore", restore)
|
||||
return log
|
||||
|
||||
|
||||
def _apply(rev: int, as_initial: bool = True):
|
||||
body = save.RouteSaveRequest(
|
||||
vertices=[{"x": rev, "y": 0}, {"x": 5, "y": 5}], as_initial=as_initial
|
||||
)
|
||||
return replan.replan_route(_PID, body) # 같은 프로젝트 = 같은 잠금
|
||||
|
||||
|
||||
_PID = uuid4()
|
||||
|
||||
|
||||
def test_적용_겹치면_기다림(guarded):
|
||||
async def both():
|
||||
return await asyncio.gather(_apply(1), _apply(2))
|
||||
|
||||
first, second = asyncio.run(both())
|
||||
assert first["waited"] is False and second["waited"] is True
|
||||
starts = [entry for entry in guarded if isinstance(entry, tuple) and entry[0] != "restore"]
|
||||
assert starts == [("start", 1), ("end", 1), ("start", 2), ("end", 2)] # 하나씩
|
||||
assert "rows" in guarded and "files" in guarded
|
||||
|
||||
|
||||
def test_적용_실패면_되돌림(guarded):
|
||||
response = asyncio.run(_apply(9))
|
||||
assert response.status_code == 409
|
||||
assert ("restore", "R") in guarded
|
||||
|
||||
|
||||
def test_손_노선_저장은_잠금_밖(guarded):
|
||||
result = asyncio.run(_apply(1, as_initial=False))
|
||||
assert "waited" not in result and "rows" not in guarded # /design/save 가 잠금 · 떠 둠
|
||||
|
||||
Reference in New Issue
Block a user