perf(B05): 3D 진입에서 저장본을 받아놓고 버리는 일 제거

B05 에 들어가는 것만으로 코리도 저장본 18.6MB 를 통째로 받고, 열쇠(해시)에 종횡단
내용이 섞여 있어 계획고를 한 번이라도 만졌으면 받아본 뒤 버리고 브라우저가 다시
만들었음(실측 화면 전환 16.7초). 3D 는 이미 [3D 업데이트] 수동인데 진입 경로만 자동.

- 서버: GET corridor 에 `?hash=` 를 받아 저장본 머리 4KB 에서 열쇠만 떼어 맞춰 봄.
  어긋나면 파일 대신 `{status:stale}` 만 냄. 맞으면 종전대로 파일 — 왕복은 한 번.
- 화면: 진입은 `loadCorridorIfFresh` 로 **저장본이 맞을 때만** 3D 에 올림.
  낡았으면 받지도 만들지도 않고 [3D 업데이트] 대기 표시만 켬.
- `ensureCorridor` 에 `rebuild` 인자 추가 — [3D 업데이트] 버튼 경로는 종전대로 재생성.

시험 tmp/tests/test_b05_corridor_hash_probe.py 3건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 21:07:11 +09:00
co-authored by Claude Opus 5
parent 1bb367058b
commit 59df765e30
3 changed files with 111 additions and 22 deletions
+38 -3
View File
@@ -9,10 +9,11 @@
import asyncio
import json
import logging
import re
from pathlib import Path
from uuid import UUID
from fastapi import APIRouter, Body
from fastapi import APIRouter, Body, Query
from fastapi.responses import JSONResponse, Response
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
@@ -64,9 +65,39 @@ async def _resolve_project_root(project_id: UUID) -> Path | None:
return Path(resolve_stored_project_path(stored_path))
# 저장본 머리에서 열쇠(해시)만 떼어 볼 만큼. 봉투 첫 두 칸이 `version`·`hash` 라
# (`B05_Profile_UI_Corridor_Envelope.serialize`) 이 안에 반드시 들어온다.
_HASH_HEAD_BYTES = 4096
_HASH_PATTERN = re.compile(rb'"hash"\s*:\s*"([0-9a-fA-F]+)"')
async def _stored_hash(path: Path) -> str | None:
"""저장본을 통째로 읽지 않고 머리 4KB 에서 열쇠만 뽑는다. 못 찾으면 None."""
def read_head() -> bytes:
with path.open("rb") as handle:
return handle.read(_HASH_HEAD_BYTES)
found = _HASH_PATTERN.search(await asyncio.to_thread(read_head))
return found.group(1).decode() if found else None
@router.get("/{project_id}/routes/{route_id}/corridor", response_model=None)
async def get_corridor(project_id: UUID, route_id: int) -> Response:
"""저장된 코리도 파일 반환 — 없으면 404(프론트가 빌드로 폴백)."""
async def get_corridor(
project_id: UUID,
route_id: int,
hash: str | None = Query(
None,
description="지금 정본의 열쇠. 저장본이 다르면 파일 대신 stale 만 돌려준다.",
max_length=64,
),
) -> Response:
"""저장된 코리도 파일 반환 — 없으면 404(프론트가 빌드로 폴백).
`hash` 를 주면 **먼저 열쇠부터 맞춰 본다**(2026-09-06 실측). 저장본이 낡았으면
18.6MB 를 다 내려보낸 뒤 브라우저가 버리는 일이 벌어졌다 — 어긋날 때는 수십 바이트만
돌려주고 끝낸다. 맞으면 종전처럼 파일을 그대로 흘려보내므로 왕복은 여전히 한 번이다.
"""
try:
project_root = await _resolve_project_root(project_id)
if project_root is None:
@@ -79,6 +110,10 @@ async def get_corridor(project_id: UUID, route_id: int) -> Response:
status_code=404,
content={"status": "error", "message": "저장된 코리도가 없습니다."},
)
if hash:
stored = await _stored_hash(path)
if stored is not None and stored != hash:
return JSONResponse(content={"status": "stale", "hash": stored})
payload = await asyncio.to_thread(path.read_bytes)
return Response(content=payload, media_type="application/json")
except OSError as exc:
+63 -14
View File
@@ -58,18 +58,35 @@ async function requestCorridor(path: string, init: RequestInit): Promise<Respons
}
}
async function fetchStored(projectId: string, routeId: number): Promise<CorridorEnvelope | null> {
/** 저장본 조회 결과 — 파일을 받았거나, 열쇠가 어긋나 안 받았거나, 아예 없거나. */
type StoredLookup =
| { kind: "envelope"; envelope: CorridorEnvelope }
| { kind: "stale" }
| { kind: "missing" };
/**
* 저장본을 가져온다. **열쇠(hash)를 함께 보내면 서버가 먼저 맞춰 본다** — 어긋나면
* 파일 대신 수십 바이트짜리 `stale` 만 온다(2026-09-06). 그 전에는 18.6MB 를 다 받은 뒤
* 해시가 다르다고 버렸다.
*/
async function fetchStored(
projectId: string,
routeId: number,
hash: string,
): Promise<StoredLookup> {
try {
const response = await requestCorridor(`/projects/${projectId}/routes/${routeId}/corridor`, {
method: "GET",
});
if (!response.ok) return null;
const payload = (await response.json()) as CorridorEnvelope;
const response = await requestCorridor(
`/projects/${projectId}/routes/${routeId}/corridor?hash=${encodeURIComponent(hash)}`,
{ method: "GET" },
);
if (!response.ok) return { kind: "missing" };
const payload = (await response.json()) as CorridorEnvelope & { status?: string };
if (payload?.status === "stale") return { kind: "stale" };
return payload && payload.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)
? payload
: null;
? { kind: "envelope", envelope: payload }
: { kind: "missing" };
} catch {
return null; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다.
return { kind: "missing" }; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다.
}
}
@@ -120,16 +137,19 @@ export async function ensureCorridor(
detail: SectionDetailResponse,
routePoints: RoutePoint[],
designSamples?: ProfileSamples,
/** 저장본이 낡았을 때 **브라우저가 다시 만들지** 여부. 진입 경로는 false 로 부른다 —
* 3D 는 [3D 업데이트]로만 도는 수동 조작인데 진입만 자동으로 남아 있었다(2026-09-06). */
rebuild = true,
): Promise<CorridorBuildResult | null> {
const key = keyOf(projectId, routeId);
const hash = corridorHash(detail, routePoints);
const cached = cache.get(key);
if (cached && cached.hash === hash) return cached.build;
const stored = await fetchStored(projectId, routeId);
if (stored && stored.hash === hash) {
const lookup = await fetchStored(projectId, routeId, hash);
if (lookup.kind === "envelope" && lookup.envelope.hash === hash) {
try {
const build = deserialize(stored);
const build = deserialize(lookup.envelope);
cache.set(key, { hash, build, dirty: false });
markSource("stored", hash);
return build;
@@ -137,6 +157,12 @@ export async function ensureCorridor(
// 손상 저장본 — 빌드로 폴백.
}
}
// 저장본이 낡았고 다시 만들지 않기로 했으면 여기서 끝낸다 — 캐시도 건드리지 않아
// 화면에 이미 서 있는 예상형상이 그대로 남는다([3D 업데이트] 대기 표시는 부르는 쪽 몫).
if (!rebuild) return null;
// 저장본이 아예 없는 경우(missing)와 있는데 낡은 경우(stale)를 가른다 — 아래 저장 규칙이
// 갈린다. `stale` 도 파일은 있으므로 즉시 PUT 하지 않고 [저장]·페이지 이동 때 올린다.
const hasStored = lookup.kind !== "missing";
// 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다.
// 라이브 편집분(alignment.samples)이 있으면 그걸 쓴다 — 정본 design_profiles는
@@ -151,7 +177,7 @@ export async function ensureCorridor(
return null;
}
markSource("built", hash);
if (stored === null) {
if (!hasStored) {
// 최초 생성 — 계획 확정 흐름대로 즉시 영구저장(실패해도 표시는 진행).
cache.set(key, { hash, build, dirty: false });
void putStored(projectId, routeId, serialize(build, hash)).then((ok) => {
@@ -186,9 +212,32 @@ export function refreshCorridor(
detail: SectionDetailResponse,
routePoints: RoutePoint[],
designSamples?: ProfileSamples,
rebuild = true,
): Promise<void> {
if (!routeId) return Promise.resolve();
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples)
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild)
.then((build) => viewer.setCorridor(build))
.catch(() => viewer.setCorridor(null));
}
/**
* 진입 전용 — 저장본이 **그대로 맞을 때만** 3D 에 올린다. 낡았으면 받지도 만들지도 않고
* `false` 를 돌려주므로, 부르는 쪽이 [3D 업데이트] 대기 표시를 켜면 된다(2026-09-06).
* 돌아온 값이 곧 「지금 화면의 3D 가 최신인가」다.
*/
export function loadCorridorIfFresh(
viewer: { setCorridor: (build: CorridorBuildResult | null) => void },
projectId: string,
routeId: number | undefined,
detail: SectionDetailResponse,
routePoints: RoutePoint[],
designSamples?: ProfileSamples,
): Promise<boolean> {
if (!routeId) return Promise.resolve(false);
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, false)
.then((build) => {
if (build) viewer.setCorridor(build);
return build !== null;
})
.catch(() => false);
}
+10 -5
View File
@@ -46,7 +46,11 @@ import {
} from "../B06_Section/B06_Section_Api_Fetch";
import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store";
import { migrateLegacyStations } from "./B05_Profile_Api_Structures";
import { refreshCorridor, saveCorridorIfDirty } from "./B05_Profile_UI_Corridor";
import {
loadCorridorIfFresh,
refreshCorridor,
saveCorridorIfDirty,
} from "./B05_Profile_UI_Corridor";
import {
resetDesignAction,
solveRouteAction,
@@ -509,16 +513,17 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// renderLatest 후 재렌더가 오므로 그때 그린다(반복 빌드 방지).
const routePoints = latest?.route_points ?? [];
if (routePoints.length > 1) {
void refreshCorridor(
// 저장본이 지금 정본과 맞을 때만 올린다 — 낡았으면 18.6MB 를 받지도, 다시 만들지도
// 않고 [3D 업데이트] 대기 표시만 켠다(2026-09-06 실측: 받아놓고 버리는 데다 다시
// 만드느라 화면 전환이 16.7초까지 갔다). 3D 는 원래 수동인데 진입만 자동이었다.
void loadCorridorIfFresh(
viewer,
activeProjectId,
routeId ?? latest?.route?.id,
detail,
routePoints,
profilePanel.alignmentSamples() ?? undefined,
);
// 방금 정본 그대로 그렸으므로 [3D 업데이트] 대기 표시를 지운다(2026-09-01).
panel.setCorridorPending(false);
).then((fresh) => panel.setCorridorPending(!fresh));
}
}