feat(520-2): B05 배수유역 저장 노선 [업데이트] 결과 남김 · 끊김 알림 · 도엽 메모리 보관

- B04 Router_Watershed — 분석 응답 · 단계 산출물 저장을 Output.write_analysis 로 뗌(primary-region 같은 길) · 도엽 좌표 저장본 메모리 보관(2 벌)
- B04 Router_Watershed_Live — 저장 노선 기준(초안 없음 · 지문 같음) 답이면 마지막 끊김 확인 뒤 분석을 남김 · design/save 잠금 중이면 안 남김 · 남길 때 같은 잠금 쥠 · POST /drainage/live/cancel(그 차례가 마지막일 때만 지움) · 셈 멈춤 로그
- B05 Drainage_Live — 요청 끊을 때 서버에 끊김 알림 · 저장 노선 [업데이트] 답이 저장분 낡음 걷음 · 되돌리기로 초안이 없어지면 저장분의 마지막 낡음으로 표시 바로
- 시험 test_349_drainage_live_flow 고침 — 되돌리기 직후 낡음 기대가 옛 동작(답 올 때까지 낡음) · 새 동작 = 저장분 마지막 낡음(여기선 꺼짐) + 끊김 알림 확인
- 시험 test_347_2 — 「작업본에 안 씀」 옛 설명 고침 · 남김 · 초안 안 남김 · 잠금 중 안 남김 · 끊김 알림 시험 더함 · test_347_1 메모리 보관 확인

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 08:18:00 +09:00
co-authored by Claude Opus 5.5
parent 80615cce2f
commit 0ba4653212
8 changed files with 352 additions and 194 deletions
@@ -4,14 +4,20 @@
답으로만 돌려준다(파생 저장본 = 흐름 방향 · 도엽 좌표만 · 347-1). 세부 유역 나눔까지 같은 요청에서
메모리 격자로 해 격자를 화면에 넘기지 않는다.
저장 노선 기준(초안 없음 · 지문 같음)이면 분석을 primary-region 과 같은 길(`write_analysis`)로 남김
(520-2) — 노선 [저장] 뒤 [업데이트] 한 번이면 관 · 구조물 조작이 세부 유역만(0.2 s). design/save 가
같은 프로젝트를 잡고 있으면(되돌림 스냅샷) 남기지 않음 · 남길 때는 같은 잠금을 쥠.
프로젝트마다 「마지막 차례」를 적어 두고 단계 사이(1차 영역 뒤 · 확장 회차마다 · 세부 유역 앞)에서
차례가 바뀌었으면 그만둔다(`superseded`). 화면은 늦은 답을 차례 · 노선 지문으로 한 번 더 거른다.
차례가 바뀌었으면 그만둔다(`superseded`). 화면이 요청을 끊으면 `/drainage/live/cancel` 이 그 차례를
지움(520-2). 화면은 늦은 답을 차례 · 노선 지문으로 한 번 더 거른다.
라우터는 `B04_PreProcess_Router_Watershed.router` 에 붙는다(그 파일 끝에서 읽음).
"""
from __future__ import annotations
import asyncio
import logging
import math
from dataclasses import replace
from typing import Any
@@ -24,14 +30,18 @@ from B04_PreProcess.B04_PreProcess_Engine_Watershed_Expand import AnalysisStoppe
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Export import drainage_dir
from B04_PreProcess.B04_PreProcess_Router_Basins import _build, _payload
from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare, router
from B04_PreProcess.B04_PreProcess_Router_Watershed_Output import write_analysis
from common_util import common_util_save_guard as guard
from common_util.common_util_drainage_context import _open_sampler, load_drainage_context
from common_util.common_util_drainage_detail import RoadRouting
from common_util.common_util_drainage_pipes import parse_pipe_points
from common_util.common_util_drainage_pipes import parse_pipe_points, route_signature
from common_util.common_util_route_geometry import RouteVertex, build_route_vertices
from common_util.common_util_route_polyline import build_planned_polyline
from common_util.common_util_route_profile import resolve_route_profile
from config.config_system import DRAINAGE_ARROW_SPACING_M
logger = logging.getLogger(__name__)
# 프로젝트 → 마지막으로 받은 차례 번호. ponytail: 서버 프로세스 하나 전제(여럿이면 DB · 파일로).
_LATEST_GENERATION: dict[str, int] = {}
SUPERSEDED = {"status": "superseded"}
@@ -88,6 +98,10 @@ async def post_drainage_live(
def superseded() -> bool:
return _LATEST_GENERATION.get(key) != generation
def stopped(where: str) -> dict[str, str]:
logger.info("배수유역 지금 노선 셈 멈춤: project=%s 차례=%d (%s)", key, generation, where)
return SUPERSEDED
try:
raw = body.get("points")
points = None if raw is None else parse_pipe_points(raw)
@@ -112,7 +126,7 @@ async def post_drainage_live(
)
vertices, _ = await asyncio.to_thread(resolve_route_profile, drawn, sampler=sampler)
if superseded():
return SUPERSEDED
return stopped("노선")
try:
preview = await asyncio.to_thread(
@@ -124,14 +138,14 @@ async def post_drainage_live(
superseded,
)
except AnalysisStopped:
return SUPERSEDED
return stopped("유역 셈 도중")
if preview is None or preview.routing is None:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "지금 노선으로 배수유역을 정하지 못했습니다."},
)
if superseded():
return SUPERSEDED
return stopped("세부 유역 앞")
live = replace(context, vertices=vertices)
to_lonlat = context.to_lonlat
@@ -147,7 +161,37 @@ async def post_drainage_live(
status_code=400,
content={"status": "error", "message": "세부 유역을 나누지 못했습니다."},
)
if superseded():
return stopped("남기기 앞")
if route_signature(vertices) == route_signature(context.vertices):
await _keep_analysis(project_id, prepared, preview, vertices)
result = _payload(project_id, live, detail, carried, False)
result["generation"] = generation
result["live"] = True
return result
async def _keep_analysis(
project_id: UUID, prepared: dict[str, Any], preview: Any, vertices
) -> None:
"""저장 노선 분석을 남김 — design/save 가 잡고 있으면 안 남김(되돌림 스냅샷과 안 부딪힘)."""
lock = guard.project_lock(str(project_id))
if lock.locked():
logger.info("배수유역 지금 노선 셈: 저장 중이라 결과를 남기지 않음 project=%s", project_id)
return
async with lock:
await asyncio.to_thread(write_analysis, project_id, prepared, preview, vertices)
@router.post("/{project_id}/drainage/live/cancel", response_model=None)
async def post_drainage_live_cancel(
project_id: UUID, payload: dict[str, Any] | None = None
) -> dict[str, Any]:
"""화면이 끊은 차례(`generation`) — 아직 마지막 차례면 지워 서버 셈을 다음 단계 사이에서 멈춤.
그 뒤 새 차례가 이미 왔으면 그대로(새 셈을 끊지 않음)."""
key = str(project_id)
generation = int((payload or {}).get("generation") or 0)
stopped = _LATEST_GENERATION.get(key) == generation
if stopped:
_LATEST_GENERATION.pop(key)
return {"status": "success", "stopped": stopped}