diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts index 3c076ed39..1a87b5a5d 100644 --- a/B05_Profile/B05_Profile_Api_RouteInitial.ts +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -81,6 +81,8 @@ export interface PrecomputeProgress { done: number; total: number; mode: RouteInitialLogic | null; + /** 기록은 「도는 중」인데 서버가 다시 켜져 끊김(763-4) — 추천 요청 409 가 이어 셈을 예약. */ + interrupted?: boolean; } /** 미리 셈 진행 — 못 읽으면 null(진행 표시만 빠짐). */ diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index 20aeb4ca9..0b01d3878 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -26,8 +26,13 @@ logger = logging.getLogger(__name__) # 미리 셈 진행(364-4) — 프로젝트마다 하나만 돎 · 도는 동안 또 오면 끝난 뒤 한 번 더(입력이 바뀜) _RUNNING: dict[str, dict[str, Any]] = {} -# 미리 셈 갈래인데 저장본이 없을 때(505-1) — 프로젝트 접근은 셈하지 않음 -NOT_PREPARED = "추천 저장본이 없습니다 — 전처리를 다시 하면 생깁니다 · [재계산]이면 지금 셉니다." +# 미리 셈 갈래인데 저장본이 없을 때(505-1) — 프로젝트 접근은 셈하지 않음 · 화면은 머리말로 가름 +# (`B05_Profile_UI_RouteEdit_RecPreview.ts` NOT_PREPARED_HEAD · 셈 중이면 진행 · 아니면 [지금 셈]) +NOT_PREPARED = "추천 저장본 없음 — 지표면이 바뀌어 다시 셈해야 함" +# 미리 셈 진행 기록(763-4) — 재시동 뒤 「끊김」을 알게 · 저장본 폴더 밖(저장본 glob 에 안 섞임) +JOB_FILE = Path("B05_Profile") / "route_recommend_job.json" +# ponytail: 다른 서버(같은 storage)가 도는 중인지 = 기록 나이 · 갈래 하나 최대 ~2 분 · 넘으면 끊김 +JOB_STALE_S = 600.0 @replan.router.post("/{project_id}/route/initial", response_model=None) @@ -172,6 +177,7 @@ async def compute_initial( )[0], ) if found is None: + resume_if_interrupted(project_id, project_root) # 끊긴 미리 셈 이어 셈(763-4) return JSONResponse( status_code=409, content={"status": "error", "message": NOT_PREPARED} ) @@ -296,6 +302,9 @@ async def precompute_defaults(project_id: UUID) -> None: progress = _RUNNING.get(str(project_id)) if progress is not None: progress.update(total=len(terrains) * len(variants) * len(modes)) + if paths is not None: # 진행 기록 자리 — 끝 기록은 `run_precompute`(763-4) + progress["root"] = paths[0] + _note(paths, progress) for terrain, (criteria, weights), mode in itertools.product(terrains, variants, modes): if progress is not None: progress["mode"] = mode @@ -308,17 +317,17 @@ async def precompute_defaults(project_id: UUID) -> None: terrain=terrain, ) outcome = await compute_initial(project_id, request, precompute=True) - if isinstance(outcome, JSONResponse): + if isinstance(outcome, JSONResponse): # 그 갈래만 건너뜀(763-4) logger.warning( - "추천 미리 셈 멈춤(%s %s %s): project_id=%s", + "추천 미리 셈 갈래 건너뜀(%s %s %s): project_id=%s", terrain, mode, outcome.status_code, project_id, ) - return if progress is not None: progress["done"] += 1 + _note(paths, progress) logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started) @@ -343,8 +352,23 @@ async def precompute_terrains(project_id: UUID, project_root: Path) -> list[Any] return [design, *[source for source in sources if source != design]] +def _note(paths: Any, progress: dict[str, Any], running: bool = True) -> None: + """진행 기록 쓰기(763-4) — 실패해도 셈은 계속.""" + from common_util.common_util_lidar_upload import note_job + + if paths is not None: + note_job( + Path(paths[0]) / JOB_FILE, + { + "running": running, + **{k: progress.get(k) for k in ("done", "total", "mode")}, + "updated": time.time(), + }, + ) + + async def run_precompute(project_id: UUID) -> None: - """미리 셈 한 줄(364-4) — 이미 돌면 「한 번 더」만 적고 돌아감(겹쳐 셈 막음).""" + """미리 셈 한 줄(364-4) — 이미 돌면 「한 번 더」만 적고 돌아감(겹쳐 셈 막음) · 기록(763-4).""" key = str(project_id) if key in _RUNNING: _RUNNING[key]["again"] = True @@ -356,20 +380,62 @@ async def run_precompute(project_id: UUID) -> None: if not _RUNNING[key]["again"]: break finally: - _RUNNING.pop(key, None) + progress = _RUNNING.pop(key, {}) + if progress.get("root"): + _note((progress["root"],), progress, running=False) -def precompute_progress(project_id: UUID) -> dict[str, Any]: - """모달 진행 표시 — 돌고 있나 · 끝낸 수 / 셀 수 · 지금 갈래.""" +def precompute_progress(project_id: UUID, project_root: Path | None = None) -> dict[str, Any]: + """진행 표시 — 돌고 있나 · 끝낸 수 / 셀 수 · 지금 갈래 · 끊겼나(763-4). + + 이 서버 메모리가 먼저 · 없으면 기록: 「도는 중」이 `JOB_STALE_S` 안 = 다른 서버가 셈 · + 넘음 = 끊김.""" progress = _RUNNING.get(str(project_id)) - if progress is None: - return {"running": False, "done": 0, "total": 0, "mode": None} - return {"running": True, **{k: progress[k] for k in ("done", "total", "mode")}} + if progress is not None: + return { + "running": True, + "interrupted": False, + **{k: progress[k] for k in ("done", "total", "mode")}, + } + idle = {"running": False, "interrupted": False, "done": 0, "total": 0, "mode": None} + if project_root is None: + return idle + try: + saved = json.loads((Path(project_root) / JOB_FILE).read_text(encoding="utf-8")) + except (OSError, ValueError): + return idle + if not isinstance(saved, dict) or not saved.get("running"): + return idle + fresh = time.time() - float(saved.get("updated") or 0) < JOB_STALE_S + return { + **idle, + **{k: saved.get(k) for k in ("done", "total", "mode")}, + "running": fresh, + "interrupted": not fresh, + } + + +def resume_if_interrupted(project_id: UUID, project_root: Path) -> bool: + """추천 요청이 409 인데 기록이 「끊김」 — 이 서버가 이어 셈 예약 한 번(돌면 안 겹침 · 763-4). + + 이어 셈 = 미리 셈 다시(있는 갈래는 저장본 꺼내기만 · 0.2 s 안팎).""" + state = precompute_progress(project_id, project_root) + if not state["interrupted"]: + return False + _note((project_root,), state) # 곧바로 「도는 중」 — 뒤 요청이 또 예약하지 않게 + schedule_precompute(project_id) + logger.info("추천 미리 셈 이어 셈 예약(끊김 기록): project_id=%s", project_id) + return True @replan.router.get("/{project_id}/route/initial/progress", response_model=None) async def initial_progress(project_id: UUID) -> dict[str, Any]: - return {"status": "success", **precompute_progress(project_id)} + try: + paths = await replan._project_paths(project_id) + except LookupError: + paths = None + root = paths[0] if paths is not None else None + return {"status": "success", **precompute_progress(project_id, root)} def schedule_precompute(project_id: UUID) -> None: diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts b/B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts index def720ed0..7c7c682d9 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts @@ -4,20 +4,32 @@ * (영선 · 토공 최소 · 기울기 최소 · 비중 계산) · 여럿 고름(589-12 · 줄마다 색 · 하나도 안 고르면 꺼짐) · * 선만(위 캔버스 · 노선 아래 그림 · 집기 없음). * 선 = 노선 추천 저장본(`B05_Profile/route_recommend/` · 지금 설계 지형 · 체인이 미리 셈) 그대로 — 다시 셈 · 쓰기 없음 · - * 저장본 없으면(409) 알림 · 끔. 옛 추천 모달 · 좌측 「노선 추천」 칸 · [적용](`as_initial`) 은 걷음(서버 길 · 시험은 남김). + * 저장본 없으면(409) — 미리 셈 도는 중 = 진행 n/m(줄 마우스 글 · 알림 한 번) · 끝나면 다시 그림 · + * 아니면 확인창 「지표면이 바뀌어 다시 셈해야 함」 [지금 셈](그 갈래만 `recompute` · 763-4) · + * 지형이 바뀌면 받아 둔 선을 비움. 옛 추천 모달 · 좌측 「노선 추천」 칸 · [적용](`as_initial`) 은 걷음(서버 길 · 시험은 남김). * 부르는 곳 = `_Chrome`(도구줄 `recommend` 칸) · `_RouteEdit`(그리기 `overStations` 앞). * 메뉴 · 선 그리기 = 공용 `A00_Common/map/map_route_preview`(636-2 · 예정노선 가이드 선과 같이 씀). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; +import { askConfirm } from "@ui/ui_template_modal"; import { themeColor } from "@ui/ui_template_palette"; import { createRoutePreviewMenu, paintRoutePreview } from "../A00_Common/map/map_route_preview"; import type { AnalysisTerrain } from "./B05_Profile_Api_Replan"; -import { fetchRouteInitial, type RouteInitialLogic } from "./B05_Profile_Api_RouteInitial"; +import { + fetchPrecomputeProgress, + fetchRouteInitial, + type RouteInitialLogic, +} from "./B05_Profile_Api_RouteInitial"; import { LOGIC_LABEL } from "./B05_Profile_UI_RouteEdit_Initial_Model"; type Vertex = [number, number]; +/** 서버 `NOT_PREPARED` 머리말(`B05_Profile_Router_RouteInitial.py`) — 저장본 없음 가름(763-4). */ +const NOT_PREPARED_HEAD = "추천 저장본 없음"; +/** 미리 셈 진행 묻기 간격(ms). */ +const POLL_MS = 3000; + /** 드롭다운 넷(요구 530 · 「예상노선 추적」 = 초기값이라 뺌). */ const MODES = [ "gradeline", @@ -54,6 +66,8 @@ export function createRecPreview(opts: { const lines = new Map(); const shown = new Map(); const turns = new Map(); + const waiting = new Set(); + let seenTerrain = opts.terrain(); const menu = createRoutePreviewMenu({ items: MODES.map((mode, index) => ({ value: mode, @@ -63,32 +77,67 @@ export function createRecPreview(opts: { onPick: (mode) => void pick(mode as RouteInitialLogic), }); - async function pick(mode: RouteInitialLogic): Promise { + /** 미리 셈이 도는 중이면 진행 n/m 을 보이고 끝난 뒤 다시 고름 — 돌면 true(763-4). */ + async function waitForPrecompute(mode: RouteInitialLogic, mine: number): Promise { + const progress = await fetchPrecomputeProgress(opts.projectId); + if (!progress?.running || mine !== turns.get(mode)) return false; + const note = `추천 셈 중 ${progress.done}/${progress.total}`; + menu.setItem(mode, { title: note }); + if (!waiting.has(mode)) showToast(`${note} — 끝나면 그림`, "info"); + waiting.add(mode); + window.setTimeout(() => { + if (mine === turns.get(mode) && menu.values().includes(mode)) void pick(mode); + }, POLL_MS); + return true; + } + + async function pick(mode: RouteInitialLogic, recompute = false): Promise { const mine = (turns.get(mode) ?? 0) + 1; turns.set(mode, mine); shown.delete(mode); opts.redraw(); if (!menu.values().includes(mode)) return; - const key = `${mode}|${opts.terrain()}`; + const terrain = opts.terrain(); + if (terrain !== seenTerrain) lines.clear(); // 지형 바뀜 = 옛 선 버림(763-4) + seenTerrain = terrain; + const key = `${mode}|${terrain}`; try { - if (!lines.has(key)) { + if (!lines.has(key) || recompute) { const body = (await fetchRouteInitial(opts.projectId, { mode, with_profile: true, - terrain: opts.terrain(), + terrain, + ...(recompute ? { recompute: true } : {}), })) as { planned?: number[][] }; const planned = (body.planned ?? []).map(([x, y]) => [x, y] as Vertex); if (planned.length < 2) throw new Error("추천 선이 비었음"); lines.set(key, planned); } if (mine !== turns.get(mode)) return; + waiting.delete(mode); + menu.setItem(mode, { title: "" }); shown.set(mode, lines.get(key)!); opts.redraw(); } catch (error) { if (mine !== turns.get(mode)) return; - menu.setItem(mode, { checked: false }); const text = error instanceof Error ? error.message : String(error); - showToast(`추천 노선 「${LOGIC_LABEL[mode]}」 못 봄 — ${text}`, "info"); + if (!recompute && (await waitForPrecompute(mode, mine))) return; + waiting.delete(mode); + menu.setItem(mode, { checked: false, title: "" }); + const now = + text.startsWith(NOT_PREPARED_HEAD) && + (await askConfirm({ + title: "추천 노선", + message: `「${LOGIC_LABEL[mode]}」 ${text}. 지금 셀까요? 몇 분 걸릴 수 있습니다.`, + okLabel: "지금 셈", + })); + if (now) { + menu.setItem(mode, { checked: true }); + showToast(`추천 노선 「${LOGIC_LABEL[mode]}」 셈 중 — 끝나면 그림`, "info"); + return void pick(mode, true); + } + if (!text.startsWith(NOT_PREPARED_HEAD)) + showToast(`추천 노선 「${LOGIC_LABEL[mode]}」 못 봄 — ${text}`, "info"); } } diff --git a/resources/tester/test_309_3_recommend_preprocess_only.py b/resources/tester/test_309_3_recommend_preprocess_only.py index de05599d1..4fb1fe14e 100644 --- a/resources/tester/test_309_3_recommend_preprocess_only.py +++ b/resources/tester/test_309_3_recommend_preprocess_only.py @@ -75,7 +75,7 @@ def test_화면_기본_갈래_저장본_없으면_409_셈_0(wired) -> None: # n for mode in DEFAULT_MODES: response = _ask(False, mode=mode) assert response.status_code == 409 - assert "전처리를 다시 하면 생깁니다" in json.loads(response.body)["message"] + assert json.loads(response.body)["message"] == router.NOT_PREPARED assert wired.count == 0 and not list(store_dir(wired.root).glob("*.json")) diff --git a/resources/tester/test_364_4_precompute_on_open.py b/resources/tester/test_364_4_precompute_on_open.py index 90fc281a3..028ec6e59 100644 --- a/resources/tester/test_364_4_precompute_on_open.py +++ b/resources/tester/test_364_4_precompute_on_open.py @@ -40,9 +40,16 @@ def test_겹쳐_셈_막음_끝난_뒤_한_번_더(monkeypatch) -> None: asyncio.run(main()) assert calls == [str(pid), str(pid)] - assert seen[0] == {"running": True, "done": 2, "total": 5, "mode": "earthwork"} + assert seen[0] == { + "running": True, + "interrupted": False, # 끊김 기록(763-4) + "done": 2, + "total": 5, + "mode": "earthwork", + } assert router.precompute_progress(pid) == { "running": False, + "interrupted": False, "done": 0, "total": 0, "mode": None, diff --git a/resources/tester/test_509_5_recommend_terrain.py b/resources/tester/test_509_5_recommend_terrain.py index acfd3094f..7e10c87ab 100644 --- a/resources/tester/test_509_5_recommend_terrain.py +++ b/resources/tester/test_509_5_recommend_terrain.py @@ -63,7 +63,7 @@ def test_미리_셈_없는_지형은_안내_재계산은_셈(two) -> None: missing = _ask(False, mode="gentle", terrain="sheet") assert missing.status_code == 409 message = json.loads(missing.body)["message"] - assert "전처리를 다시 하면" in message and "[재계산]" in message + assert message == router.NOT_PREPARED # 화면 = 진행 또는 [지금 셈](763-4) assert two.count == 0 assert _ask(False, mode="gentle", terrain="sheet", recompute=True)["stored"] is False assert two.count == 1 diff --git a/resources/tester/test_626_1_recommend_preview.py b/resources/tester/test_626_1_recommend_preview.py index 5a72591c2..1cfe04a24 100644 --- a/resources/tester/test_626_1_recommend_preview.py +++ b/resources/tester/test_626_1_recommend_preview.py @@ -42,10 +42,11 @@ def test_미리보기_넷_하나만_저장본만() -> None: src = read(B05 / "B05_Profile_UI_RouteEdit_RecPreview.ts") modes = re.search(r"const MODES = \[(.*?)\]", src, re.S).group(1) assert re.findall(r'"(\w+)"', modes) == ["gradeline", "earthwork", "gentle", "weighted"] - assert "recompute" not in src and "createRoutePreviewMenu({" in src + # 저장본만 · 다시 셈은 저장본 없을 때 확인창 [지금 셈]만(763-4) + assert src.count("recompute: true") == 1 and "createRoutePreviewMenu({" in src shared = read(ROOT / "A00_Common" / "map" / "map_route_preview.ts") # 636-2 공용 메뉴 · 그리기 assert "createPickMenu({" in shared and "multi: true" in shared and "offLabel" not in shared - assert "terrain: opts.terrain()" in src # 지금 설계 지형 저장본(D7) + assert "const terrain = opts.terrain();" in src # 지금 설계 지형 저장본(D7) chrome = read(B05 / "B05_Profile_UI_RouteEdit_Chrome.ts") assert "recommend: preview.root" in chrome and "terrain: terrain.root" in chrome menu = read(ROOT / "ui_template" / "ui_template_context_menu.ts") diff --git a/resources/tester/test_763_4_precompute_resume.py b/resources/tester/test_763_4_precompute_resume.py new file mode 100644 index 000000000..6cbe0e4f3 --- /dev/null +++ b/resources/tester/test_763_4_precompute_resume.py @@ -0,0 +1,92 @@ +"""763-4 추천 미리 셈이 끊기면 이어 셈 — 진행 기록 · 한 갈래 409 는 그 갈래만 건너뜀 · 화면 글 짝. + +./venv/Scripts/python.exe -m pytest resources/tester/test_763_4_precompute_resume.py -q +""" + +import asyncio +import json +import time +from pathlib import Path +from uuid import uuid4 + +from fastapi.responses import JSONResponse +from test_224_3_recommend_store import _ask, wired # noqa: F401 — 픽스처 + +from B05_Profile import B05_Profile_Router_RouteInitial as router +from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES + +ROOT = Path(__file__).resolve().parents[2] + + +def _record(root: Path, running: bool, age_s: float) -> None: + path = root / router.JOB_FILE + path.parent.mkdir(parents=True, exist_ok=True) + body = {"running": running, "done": 3, "total": 10, "mode": "gentle"} + path.write_text(json.dumps({**body, "updated": time.time() - age_s}), encoding="utf-8") + + +def test_진행_기록_읽기(tmp_path) -> None: + pid = uuid4() + assert router.precompute_progress(pid, tmp_path)["running"] is False + _record(tmp_path, True, 5) # 다른 서버가 도는 중 + state = router.precompute_progress(pid, tmp_path) + assert state["running"] and not state["interrupted"] and state["done"] == 3 + _record(tmp_path, True, router.JOB_STALE_S + 1) # 재시동으로 끊김 + state = router.precompute_progress(pid, tmp_path) + assert not state["running"] and state["interrupted"] + _record(tmp_path, False, 5) + assert router.precompute_progress(pid, tmp_path)["interrupted"] is False + + +def test_끊김_기록_409_이어_셈_한_번(wired, monkeypatch) -> None: # noqa: F811 + scheduled: list = [] + monkeypatch.setattr(router, "schedule_precompute", scheduled.append) + _record(wired.root, True, router.JOB_STALE_S + 1) + for _ in range(3): # 화면이 여러 번 물어도 예약 한 번(기록이 곧바로 「도는 중」) + answer = _ask(False, mode="gentle") + assert answer.status_code == 409 + assert json.loads(answer.body)["message"] == router.NOT_PREPARED + assert len(scheduled) == 1 + assert router.precompute_progress(uuid4(), wired.root)["running"] is True + + +def test_끊김_아니면_예약_없음(wired, monkeypatch) -> None: # noqa: F811 + scheduled: list = [] + monkeypatch.setattr(router, "schedule_precompute", scheduled.append) + _ask(False, mode="gentle") # 기록 없음 + _record(wired.root, True, 5) # 다른 서버가 셈 중 — 안 겹침 + _ask(False, mode="gentle") + assert scheduled == [] + + +def test_미리_셈_기록_남기고_끝(wired) -> None: # noqa: F811 + asyncio.run(router.run_precompute(uuid4())) + saved = json.loads((wired.root / router.JOB_FILE).read_text(encoding="utf-8")) + assert saved["running"] is False and saved["done"] == saved["total"] == len(DEFAULT_MODES) + + +def test_한_갈래_409_는_그_갈래만_건너뜀(wired, monkeypatch) -> None: # noqa: F811 + real = router.compute_initial + asked: list[str] = [] + + async def flaky(project_id, request, precompute=False): + asked.append(request.mode) + if request.mode == "gentle": + return JSONResponse(status_code=409, content={"message": "x"}) + return await real(project_id, request, precompute) + + monkeypatch.setattr(router, "compute_initial", flaky) + asyncio.run(router.precompute_defaults(uuid4())) + assert sorted(asked) == sorted(DEFAULT_MODES) + assert wired.count == len(DEFAULT_MODES) - 1 + + +def test_화면_머리말_서버_글과_같음() -> None: + preview = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit_RecPreview.ts").read_text( + encoding="utf-8" + ) + head = preview.split('const NOT_PREPARED_HEAD = "', 1)[1].split('"', 1)[0] + assert router.NOT_PREPARED.startswith(head) + assert "fetchPrecomputeProgress(" in preview and "recompute: true" in preview + assert 'okLabel: "지금 셈"' in preview and "lines.clear()" in preview + assert "