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:
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user