From f9b157f002e67ff832bd02e92521ec2a5037e9a6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 19 Oct 2026 16:56:48 +0900 Subject: [PATCH] =?UTF-8?q?fix(476-1=20=E2=91=A3):=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?[=EC=A0=81=EC=9A=A9]=EB=8F=84=20[=EC=A0=80=EC=9E=A5]=EA=B3=BC?= =?UTF-8?q?=20=EA=B0=99=EC=9D=80=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=20=C2=B7=20=EB=96=A0=20=EB=91=90=EA=B3=A0=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EB=A9=B4=20=EB=90=98=EB=8F=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- B05_Profile/B05_Profile_Router_Replan.py | 61 ++++++++------ docs/wiki/concepts/design_page/rules.md | 2 +- resources/tester/test_346_3_route_save.py | 6 ++ resources/tester/test_476_1_apply_initial.py | 88 +++++++++++++++++++- 4 files changed, 128 insertions(+), 29 deletions(-) diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 2b008b81d..7142b8ca3 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -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 diff --git a/docs/wiki/concepts/design_page/rules.md b/docs/wiki/concepts/design_page/rules.md index 113a2e6cf..c98bdd354 100644 --- a/docs/wiki/concepts/design_page/rules.md +++ b/docs/wiki/concepts/design_page/rules.md @@ -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` | diff --git a/resources/tester/test_346_3_route_save.py b/resources/tester/test_346_3_route_save.py index d5b5fc733..c7a050b63 100644 --- a/resources/tester/test_346_3_route_save.py +++ b/resources/tester/test_346_3_route_save.py @@ -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 diff --git a/resources/tester/test_476_1_apply_initial.py b/resources/tester/test_476_1_apply_initial.py index 16c814f9d..24a09168b 100644 --- a/resources/tester/test_476_1_apply_initial.py +++ b/resources/tester/test_476_1_apply_initial.py @@ -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 가 잠금 · 떠 둠