diff --git a/A00_Common/design/design_calc_server.ts b/A00_Common/design/design_calc_server.ts new file mode 100644 index 000000000..3c9ec1639 --- /dev/null +++ b/A00_Common/design/design_calc_server.ts @@ -0,0 +1,189 @@ +/* ============================================================================= + * design_calc_server.ts + * 계산 자리 「서버」 길(PLAN 511-A) — 판(`design_calc_sites.ts`)이 묶음을 server 로 둔 때 조작 중 계산을 서버 + * 창구(`POST /projects/{id}/calc` · `B06_Section_Router_Calc.py`)가 같은 TS 로 셈. 판이 both 면 지금 그대로. + * serverCalc 한 번 묻기(signal 로 끊음 · 왕복 흉내 `delayMs` · 기록) + * afterQuiet 잠잠 뒤 — 같은 칸 새 부름이 앞 것을 끊음(앞 것 = undefined · 늦은 답 버림) + * calcChecked 동기 자리 — 브라우저 값 바로 · server 면 잠잠 뒤 같은 입력 서버 셈 → 대조 · 다르면 apply + * calcFromServer 그리기 자리 — server 면 서버 값만(입력 같은 답 · 없으면 앞 답) · 답 오면 redraw + * 기록 = `window.__aisloCalcServer`(calls · local = 브라우저 ms · delayMs) — 시간 표 · 화면 검증용. + * 조작 중 왕복 = 지침 5장 「네트워크 왕복 금지」의 시험용 예외(판 기본값 both). + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { calcOnServerNow, calcProjectId } from "./design_calc_sites"; +import { plain } from "./logic/design_calc_node_trial"; + +/** 편집이 멈춘 것으로 볼 시간(ms) — 배분 선반입과 같은 값(`B05_Profile_Api_HaulPlan.ts`). */ +export const QUIET_MS = 400; +const LOG_SIZE = 200; +const LOCAL_SIZE = 20; + +export interface CalcServerCall { + key: string; + id: string; + /** 브라우저 셈 ms(같이 셈한 자리만). */ + browserMs: number | null; + /** 보내기 → 답(흉내 지연 포함). */ + roundMs: number; + /** Node 셈 · 파이썬 ↔ Node(창구 답). */ + nodeMs: number | null; + wallMs: number | null; + bytes: number; + /** 브라우저 값 = 서버 값(대조 자리만 · 아니면 null). */ + same: boolean | null; + applied: boolean; +} + +interface CalcServerLog { + calls: CalcServerCall[]; + local: Record; + delayMs: number; +} + +const log = (): CalcServerLog => + ((globalThis as { __aisloCalcServer?: CalcServerLog }).__aisloCalcServer ??= { + calls: [], + local: {}, + delayMs: 0, + }); + +const now = (): number => performance.now(); +const r2 = (value: number): number => Math.round(value * 100) / 100; + +function wait(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(new DOMException("끊김", "AbortError")); + const timer = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { + clearTimeout(timer); + reject(new DOMException("끊김", "AbortError")); + }); + }); +} + +const bodyOf = (id: string, args: unknown[]): string => + JSON.stringify({ run: [id], inputs: { args: { [id]: args } } }); + +function record(call: CalcServerCall): void { + const calls = log().calls; + calls.push(call); + if (calls.length > LOG_SIZE) calls.splice(0, calls.length - LOG_SIZE); +} + +/** 서버 계산 한 번 — `{id: 값}` · 실패 = throw · `body` = 이미 굳힌 본문(입력이 뒤에 바뀌어도 그때 값). */ +export async function serverCalc( + key: string, + run: string[], + inputs: Record | string, + signal?: AbortSignal, + extra: Partial = {}, +): Promise> { + const body = typeof inputs === "string" ? inputs : JSON.stringify({ run, inputs }); + const t0 = now(); + if (log().delayMs > 0) await wait(log().delayMs, signal); + const response = await fetch(`${API_BASE_URL}/projects/${calcProjectId()}/calc`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body, + signal, + }); + const data = (await response.json()) as { + results?: Record; + ms?: number; + wall_ms?: number; + message?: string; + }; + if (!response.ok || !data.results) throw new Error(data.message ?? `HTTP ${response.status}`); + record({ + key, + id: run.join("+"), + browserMs: null, + roundMs: r2(now() - t0), + nodeMs: data.ms ?? null, + wallMs: data.wall_ms ?? null, + bytes: body.length, + same: null, + applied: true, + ...extra, + }); + return data.results; +} + +const quiet = new Map(); + +/** 잠잠 뒤 task — 같은 칸 새 부름 · outer 끊김 = 앞 것 끊음(undefined) · 실패도 undefined(경고 한 줄). */ +export async function afterQuiet( + slot: string, + task: (signal: AbortSignal) => Promise, + outer?: AbortSignal, +): Promise { + quiet.get(slot)?.abort(); + const controller = new AbortController(); + quiet.set(slot, controller); + outer?.addEventListener("abort", () => controller.abort()); + try { + await wait(QUIET_MS, controller.signal); + return await task(controller.signal); + } catch (error) { + if (!controller.signal.aborted) console.warn(`[calc-server] ${slot} 실패`, error); + return undefined; + } finally { + if (quiet.get(slot) === controller) quiet.delete(slot); + } +} + +/** 브라우저 셈 + 시간 기록(묶음 판 상관없이) · server 면 잠잠 뒤 서버 대조 · 다르면 apply(서버 값). */ +export function calcChecked( + key: string, + id: string, + fn: (...args: A) => R, + args: A, + apply?: (value: R) => void, +): R { + const t0 = now(); + const value = fn(...args); + const browserMs = r2(now() - t0); + const local = (log().local[id] ??= []); + local.push(browserMs); + if (local.length > LOCAL_SIZE) local.shift(); + if (!calcOnServerNow(key)) return value; + const body = bodyOf(id, args); + const mine = JSON.stringify(plain(value)); + const extra: Partial = { browserMs, same: null, applied: false }; + void afterQuiet(id, (signal) => serverCalc(key, [id], body, signal, extra)).then((results) => { + if (!results) return; + extra.same = JSON.stringify(results[id]) === mine; + if (!extra.same && apply) { + apply(results[id] as R); + extra.applied = true; + } + }); + return value; +} + +const held = new Map(); +const asked = new Map(); + +/** 그리기 자리 서버 값 — 같은 입력 답이면 fresh · 아니면 앞 답(없으면 undefined) + 잠잠 뒤 묻고 redraw. */ +export function calcFromServer( + key: string, + id: string, + args: unknown[], + redraw: () => void, +): { value: R | undefined; fresh: boolean } { + const body = bodyOf(id, args); + const hold = held.get(id); + if (hold?.body === body) return { value: hold.value as R, fresh: true }; + if (asked.get(id) !== body) { + asked.set(id, body); + void afterQuiet(id, (signal) => serverCalc(key, [id], body, signal)).then((results) => { + if (asked.get(id) === body) asked.delete(id); + if (!results) return; + held.set(id, { body, value: results[id] }); + redraw(); + }); + } + return { value: hold?.value as R | undefined, fresh: false }; +} diff --git a/A00_Common/design/design_calc_sites.ts b/A00_Common/design/design_calc_sites.ts index 7ecd81f8b..b1cd2c4dc 100644 --- a/A00_Common/design/design_calc_sites.ts +++ b/A00_Common/design/design_calc_sites.ts @@ -5,6 +5,7 @@ * 「계산 자리」 창(`B01_Dashboard_UI_CalcSites.ts`). 열린 페이지는 다음 열기부터 · 못 받으면 전부 both. * ground_sample(G1) server = ground 단계가 격자 대신 서버 표본(`B05_Profile_Api_TerrainGrid.ts`) * corridor_mesh(D3) server = 3D 미리보기 워커 안 띄움 · 서버 모형만(`B05_Profile_UI_Corridor_Worker_Client.ts`) + * quantity · carry · fixed_browser server = 조작 중 서버 창구(511-A · `design_calc_server.ts`) * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; @@ -43,11 +44,15 @@ export const saveCalcSites = (sites: Record): Promise> | null = null; +let loaded: Record = {}; +let project = ""; -/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림). */ -export function openCalcSites(): Promise> { +/** 페이지 열 때 — 새로 받음(앞 페이지 판 버림) · 서버 창구가 쓸 프로젝트. */ +export function openCalcSites(projectId = project): Promise> { + project = projectId; + loaded = {}; opened = fetchCalcSites().then( - (board) => board.sites, + (board) => (loaded = board.sites), () => ({}), ); return opened; @@ -57,3 +62,8 @@ export function openCalcSites(): Promise> { export async function calcOnServer(key: string): Promise { return (await (opened ?? openCalcSites()))[key] === "server"; } + +/** 동기 자리용 — 받기 전 = both. */ +export const calcOnServerNow = (key: string): boolean => loaded[key] === "server"; + +export const calcProjectId = (): string => project; diff --git a/A00_Common/design/design_page.ts b/A00_Common/design/design_page.ts index 4f0515c18..77b29b225 100644 --- a/A00_Common/design/design_page.ts +++ b/A00_Common/design/design_page.ts @@ -120,7 +120,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage { root.replaceChildren(page); const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; pinDraftProject(projectId); // 연 프로젝트 고정(388-3) — 다른 탭이 바꿔도 이 탭 초안 칸 그대로 - void openCalcSites(); // 계산 자리 판(504-2) — 열 때 한 번 · 열린 동안 바뀐 판은 다음 열기부터 + void openCalcSites(projectId); // 계산 자리 판(504-2) — 열 때 한 번 · 열린 동안 바뀐 판은 다음 열기부터 usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id) // 관 길이 · 흙막이 개수 = 공용 캐시(페이지 노선) 한 벌 — 마지막 연 보기의 사본이 아님(416 · B05 = B06) · // 노선 초안 동안 = 계산 줄 새 노선 상세(다시 센 횡단 · 428) diff --git a/A00_Common/design/logic/design_calc_node.ts b/A00_Common/design/logic/design_calc_node.ts index 8c353b06a..de85bb8ab 100644 --- a/A00_Common/design/logic/design_calc_node.ts +++ b/A00_Common/design/logic/design_calc_node.ts @@ -5,6 +5,7 @@ * 브라우저가 쓰는 같은 TS 를 그대로 부름 — 계산을 두 벌로 짜지 않음. * * 실행: node <묶음> <입력.json> <출력.json> (package.json `build:design-calc`) + * node <묶음> --serve 늘 떠 있음 — 표준 입력 한 줄 = 요청 JSON · 표준 출력 한 줄 = 출력 JSON(511-A) * 입력 { run: [id…], inputs: { detail?: 종횡단 상세(API 꼴) · route_points?: 노선 폴리라인 · * context?: { earthwork_conversion · natural_spoil_min_ground_slope · * haul_equipment_limits · 채집석 · 구조물 잔토(B08) } · @@ -17,7 +18,8 @@ * 출력 { results: { [id]: 값 }, available: [id…] } * — 차례는 요청 차례가 아니라 기댐 차례(누가토량 앞에 구조물 면적을 상세에 얹음 · * server-calc 묶음과 같은 차례). - * 끝 코드: 0 성공 / 2 인자 오류 / 4 모르는 id + * — 시험 길 id(`design_calc_node_trial.ts`)도 돌지만 `available` 에는 없음. + * 끝 코드: 0 성공 / 2 인자 오류 / 4 모르는 id(--serve 는 그 줄 { error }) * ========================================================================== */ import { readFileSync, writeFileSync } from "node:fs"; @@ -40,10 +42,11 @@ import { } from "../../../B06_Section/B06_Section_Structure_Layouts"; import { carryRunners, type CarryInputs } from "./design_calc_node_carry"; import { followRunners, type FollowInputs } from "./design_calc_node_follow"; +import { TRIAL_IDS, trialRunners, type TrialInputs } from "./design_calc_node_trial"; type HaulOptions = NonNullable[2]>; -interface CalcInputs extends FollowInputs, CarryInputs { +interface CalcInputs extends FollowInputs, CarryInputs, TrialInputs { detail?: SectionDetailResponse; route_points?: RoutePoint[]; context?: Partial< @@ -141,6 +144,7 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) { const RUNNERS: Record unknown> = { ...followRunners(inputs), // 측점 열쇠 · 따라감 · 꼭짓점 세움(472-4 ①) ...carryRunners(inputs), // 노선 바뀐 [저장] 다시 얹기 — 관 · 구조물 초안 · 횡단 고침(472-6 6-B) + ...trialRunners(inputs), // 계산 자리 「서버」 시험 길(511-A) // 구조물 측점 면적 · 관 길이를 상세에 얹음(자리에서) — 뒤 누가토량이 고친 면적 위에서 쌓임. "structure.layouts": () => { const areas = structureAreaRows(sections); @@ -226,21 +230,48 @@ function runDesignCalc(run: readonly string[], inputs: CalcInputs) { const unknown = run.filter((id) => !(id in RUNNERS)); const results: Record = {}; if (!unknown.length) for (const id of run) results[id] = need(id); - return { results, unknown, available: Object.keys(RUNNERS) }; + const available = Object.keys(RUNNERS).filter((id) => !TRIAL_IDS.includes(id)); + return { results, unknown, available }; +} + +type CalcRequest = { run?: string[]; inputs?: CalcInputs }; + +/** 늘 떠 있음(511-A) — 한 줄 받아 한 줄 냄 · 한 줄 실패는 그 줄 { error }(프로세스는 삶). */ +function serve(): void { + let buffer = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk: string) => { + buffer += chunk; + for (let at = buffer.indexOf("\n"); at >= 0; at = buffer.indexOf("\n")) { + const line = buffer.slice(0, at); + buffer = buffer.slice(at + 1); + let answer: unknown; + try { + const request = JSON.parse(line) as CalcRequest; + const t0 = performance.now(); + const { results, unknown } = runDesignCalc(request.run ?? [], request.inputs ?? {}); + const ms = Math.round((performance.now() - t0) * 100) / 100; + answer = unknown.length ? { error: `모르는 id: ${unknown.join(", ")}` } : { results, ms }; + } catch (error) { + answer = { error: error instanceof Error ? error.message : String(error) }; + } + process.stdout.write(`${JSON.stringify(answer)}\n`); + } + }); + process.stdin.on("end", () => process.exit(0)); } const [inputPath, outputPath] = process.argv.slice(2); -if (!inputPath || !outputPath) { - console.error("사용법: node <묶음> <입력.json> <출력.json>"); +if (inputPath === "--serve") serve(); +else if (!inputPath || !outputPath) { + console.error("사용법: node <묶음> <입력.json> <출력.json> | node <묶음> --serve"); process.exit(2); +} else { + const request = JSON.parse(readFileSync(inputPath, "utf8")) as CalcRequest; + const { results, unknown, available } = runDesignCalc(request.run ?? [], request.inputs ?? {}); + if (unknown.length) { + console.error(`모르는 id: ${unknown.join(", ")}`); + process.exit(4); + } + writeFileSync(outputPath, JSON.stringify({ results, available })); } -const request = JSON.parse(readFileSync(inputPath, "utf8")) as { - run?: string[]; - inputs?: CalcInputs; -}; -const { results, unknown, available } = runDesignCalc(request.run ?? [], request.inputs ?? {}); -if (unknown.length) { - console.error(`모르는 id: ${unknown.join(", ")}`); - process.exit(4); -} -writeFileSync(outputPath, JSON.stringify({ results, available })); diff --git a/A00_Common/design/logic/design_calc_node_trial.ts b/A00_Common/design/logic/design_calc_node_trial.ts new file mode 100644 index 000000000..b2f0f30ca --- /dev/null +++ b/A00_Common/design/logic/design_calc_node_trial.ts @@ -0,0 +1,61 @@ +/* ============================================================================= + * A00_Common/design/logic/design_calc_node_trial.ts + * 계산 자리 「서버」 시험 길(PLAN 511-A) — 조작 중 브라우저가 부르는 함수를 서버 Node 가 **같은 TS 로** 부름. + * 등록부 `node:` 실행기 밖(파이썬 `TRIAL_IDS` 와 같은 표) · 입력 = `inputs.args[id]`(그 함수 인자 그대로 · JSON). + * masshaul.series E1 유토곡선 그리기(`computeMassHaulSeries`) + * stations.carry_keyed R9 측점 열쇠 넘김(`carryKeyedStates` · 읽기 = 칸 이름 → 값 표) + * route.handle R2 · route.station_fit R4 · stations.swap R8 · profile.tools P7 · cross.fit C7 · footprint.plan D4 + * Map · Set 은 목록으로(JSON) — 브라우저 대조도 같은 꼴(`design_calc_server.ts` `plain`). + * ========================================================================== */ + +import { computeMassHaulSeries } from "@util/common_util_mass_haul"; +import { footprintStage } from "../../../B05_Profile/logic/B05_Profile_Footprint_Stage"; +import { fitCurveStations } from "../../../B05_Profile/logic/B05_Profile_RouteEdit_StationFit"; +import { straightenBetween } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten"; +import { dragHandleTo } from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve"; +import { + carryKeyedStates, + type KeyedState, + type Raw, +} from "../../../B06_Section/logic/B06_Section_Route_Carry"; +import { swapCrossSections } from "../../../B06_Section/logic/B06_Section_Station_Swap"; +import { toeOffsets } from "../../../B06_Section/logic/B06_Section_UI_Cross_Fit"; +import type { StageContext } from "./design_pipeline_types"; + +export interface TrialInputs { + args?: Record; +} + +type Call = (...args: never[]) => unknown; + +/** Map · Set → 목록 — JSON 으로 오가는 꼴. */ +export function plain(value: unknown): unknown { + return JSON.parse( + JSON.stringify(value, (_key, item: unknown) => + item instanceof Map ? [...item.entries()] : item instanceof Set ? [...item] : item, + ), + ); +} + +const TRIAL: Record = { + "masshaul.series": computeMassHaulSeries as Call, + "stations.carry_keyed": ((states: Record, from, to) => + carryKeyedStates((name: KeyedState) => states[name] ?? {}, from, to)) as Call, + "route.handle": dragHandleTo as Call, + "route.station_fit": fitCurveStations as Call, + "stations.swap": swapCrossSections as Call, + "profile.tools": straightenBetween as Call, + "cross.fit": toeOffsets as Call, + // 발자국 단계 — 앞 결과 없이 전 구간(브라우저 범위 밖 재사용과 같은 값) + "footprint.plan": ((out: StageContext<"footprint">["out"]) => + footprintStage({ out, range: null, prev: null } as StageContext<"footprint">)) as Call, +}; + +export const TRIAL_IDS = Object.keys(TRIAL); + +/** id → 셈(입구 `RUNNERS` 에 얹음 · `available` 에는 안 넣음). */ +export function trialRunners(inputs: TrialInputs): Record unknown> { + return Object.fromEntries( + TRIAL_IDS.map((id) => [id, () => plain(TRIAL[id](...((inputs.args?.[id] ?? []) as never[])))]), + ); +} diff --git a/B06_Section/B06_Section_Router_Calc.py b/B06_Section/B06_Section_Router_Calc.py new file mode 100644 index 000000000..b7aa5fd68 --- /dev/null +++ b/B06_Section/B06_Section_Router_Calc.py @@ -0,0 +1,59 @@ +"""계산 자리 「서버」 창구(PLAN 511-A · 요구 320-4) — 조작 중 계산을 서버가 같은 TS 로. + + POST /api/projects/{id}/calc {run: [id…], inputs: {…}} → {results, ms, wall_ms} + +브라우저가 계산 자리 판(`calc_sites.json`)에서 묶음을 「서버」로 둔 때만 부름 +(`design_calc_server.ts`). +권한 = 프로젝트 접근(`protected_with_company`) · 호출량 = `main.py` 미들웨어 · 본문 상한 `MAX_BODY`. +실행 = 늘 떠 있는 Node 하나(`common_util_calc_runner.run_calc_served`) · +정본 저장 길은 이 창구를 안 씀. +""" + +import asyncio +import json +from uuid import UUID + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from common_util.common_util_calc_runner import run_calc_served + +router = APIRouter(prefix="/api/projects", tags=["B06 Calc Server"]) + +#: 본문 상한 — 종횡단 상세 한 벌(1.3~1.5 MB) 여유 · 넘으면 413. +MAX_BODY = 8 * 1024 * 1024 + + +def _error(status: int, message: str) -> JSONResponse: + return JSONResponse(status_code=status, content={"status": "error", "message": message}) + + +@router.post("/{project_id}/calc", response_model=None) +async def run_calc_endpoint(project_id: UUID, request: Request) -> dict | JSONResponse: + declared = request.headers.get("content-length") + if declared and declared.isdigit() and int(declared) > MAX_BODY: + return _error(413, "계산 입력이 너무 큼") + body = b"" + async for chunk in request.stream(): + body += chunk + if len(body) > MAX_BODY: + return _error(413, "계산 입력이 너무 큼") + try: + payload = json.loads(body) + except ValueError: + return _error(400, "JSON 아님") + run = payload.get("run") if isinstance(payload, dict) else None + inputs = payload.get("inputs", {}) if isinstance(payload, dict) else None + if not isinstance(run, list) or not all(isinstance(i, str) for i in run): + return _error(400, "run = id 목록") + if not isinstance(inputs, dict): + return _error(400, "inputs = 객체") + try: + answer = await asyncio.to_thread(run_calc_served, run, inputs) + except ValueError as exc: + return _error(400, str(exc)) + if answer is None: + return _error(503, "서버 계산을 못 돌림") + if "error" in answer: + return _error(422, str(answer["error"])) + return {"status": "success", "project_id": str(project_id), **answer} diff --git a/common_util/common_util_calc_runner.py b/common_util/common_util_calc_runner.py index 4c11b847a..e4450fc7f 100644 --- a/common_util/common_util_calc_runner.py +++ b/common_util/common_util_calc_runner.py @@ -4,15 +4,28 @@ (`build:design-calc`). 부름은 `common_util_node_bundle.run_bundle_json` 한 길 · 계산 없음. `NODE_IDS` = 등록부에서 실행기가 `node:` 인 줄(같은지 시험 `test_472_3_calc_runner.py`). 입력 꼴 = 입구 머리(`detail` · `route_points` · `context` · `excavation`). +`run_calc_served` = 계산 자리 「서버」 창구(511-A · `B06_Section_Router_Calc`) — +늘 떠 있는 Node 하나(`--serve` · 한 줄 요청 · 한 줄 답) · 죽음 · 묶음 다시 지음 = 다시 띄움 · +시험 길 `TRIAL_IDS` 도 받음. """ from __future__ import annotations +import atexit +import json +import subprocess +import threading +import time from collections.abc import Sequence from pathlib import Path from typing import Any -from common_util.common_util_node_bundle import run_bundle_json +from common_util.common_util_node_bundle import ( + build_bundle, + bundle_stale, + node_env, + run_bundle_json, +) ROOT = Path(__file__).resolve().parents[1] BUNDLE = ROOT / "config" / "design_calc_node" / "design_calc_node.js" @@ -33,6 +46,20 @@ NODE_IDS = frozenset( "stations.carry", } ) +# 시험 길(`design_calc_node_trial.ts` `TRIAL` 표와 같음) — 조작 중 브라우저 함수를 서버가 같은 TS 로 +TRIAL_IDS = frozenset( + { + "masshaul.series", + "stations.carry_keyed", + "route.handle", + "route.station_fit", + "stations.swap", + "profile.tools", + "cross.fit", + "footprint.plan", + } +) +SERVE_TIMEOUT_S = 60 def run_calc(ids: Sequence[str], inputs: dict[str, Any]) -> dict[str, Any] | None: @@ -42,3 +69,71 @@ def run_calc(ids: Sequence[str], inputs: dict[str, Any]) -> dict[str, Any] | Non raise ValueError(f"Node 실행기 아닌 id: {', '.join(unknown)}") output = run_bundle_json(BUNDLE, NPM_SCRIPT, {"run": list(ids), "inputs": inputs}) return output.get("results") if isinstance(output, dict) else None + + +class _Served: + """늘 떠 있는 Node 하나 — 한 번에 한 요청(잠금) · 답 없음 · 죽음 = 끄고 다음 부름에 다시.""" + + def __init__(self) -> None: + self.lock = threading.Lock() + self.proc: subprocess.Popen[str] | None = None + self.stamp = 0 + + def _start(self) -> subprocess.Popen[str]: + self.stop() + self.proc = subprocess.Popen( # noqa: S603 — 고정 실행 파일 · 입력은 표준 입력 한 줄 + ["node", str(BUNDLE), "--serve"], + cwd=str(ROOT), + env=node_env(), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + encoding="utf-8", + ) + self.stamp = BUNDLE.stat().st_mtime_ns + return self.proc + + def stop(self) -> None: + if self.proc and self.proc.poll() is None: + self.proc.kill() + self.proc = None + + def call(self, payload: dict[str, Any]) -> dict[str, Any] | None: + line = json.dumps(payload, default=float) + "\n" + with self.lock: + if bundle_stale(BUNDLE) and not build_bundle(NPM_SCRIPT): + return None + for _ in range(2): # 죽은 프로세스에 쓴 첫 부름 = 다시 띄워 한 번 더 + proc = self.proc + if not proc or proc.poll() is not None or BUNDLE.stat().st_mtime_ns != self.stamp: + proc = self._start() + timer = threading.Timer(SERVE_TIMEOUT_S, proc.kill) + timer.start() + try: + proc.stdin.write(line) # type: ignore[union-attr] + proc.stdin.flush() # type: ignore[union-attr] + answer = proc.stdout.readline() # type: ignore[union-attr] + except OSError: + answer = "" + finally: + timer.cancel() + if answer: + return json.loads(answer) + self.stop() + return None + + +_SERVED = _Served() +atexit.register(_SERVED.stop) + + +def run_calc_served(ids: Sequence[str], inputs: dict[str, Any]) -> dict[str, Any] | None: + """창구 길 — `{results, ms(Node 셈), wall_ms(파이썬 ↔ Node)}` · `{error}` · 못 돌림 None.""" + unknown = [item for item in ids if item not in NODE_IDS | TRIAL_IDS] + if unknown: + raise ValueError(f"서버 계산 id 아님: {', '.join(unknown)}") + t0 = time.perf_counter() + answer = _SERVED.call({"run": list(ids), "inputs": inputs}) + if answer is not None: + answer["wall_ms"] = round((time.perf_counter() - t0) * 1000, 2) + return answer diff --git a/main.py b/main.py index 3740dd697..d9042e54b 100644 --- a/main.py +++ b/main.py @@ -59,6 +59,7 @@ from B06_Section.B06_Section_Router_HaulPlan import ( router as b06_section_haul_plan_router, ) from B06_Section.B06_Section_Router_CrossTable import router as b06_section_cross_table_router +from B06_Section.B06_Section_Router_Calc import router as b06_calc_router from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from B07_DesignDetail.B07_DesignDetail_Router_PlanKinds import router as b07_plan_kinds_router @@ -477,6 +478,7 @@ app.include_router(b06_design_save_router, dependencies=protected_with_company) app.include_router(b06_section_confirm_router, dependencies=protected_with_company) app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company) app.include_router(b06_section_cross_table_router, dependencies=protected_with_company) +app.include_router(b06_calc_router, dependencies=protected_with_company) app.include_router(b07_design_router, dependencies=protected_with_company) app.include_router(b07_frame_router, dependencies=protected_with_company) app.include_router(b07_plan_kinds_router, dependencies=protected_with_company) diff --git a/resources/tester/test_309_2_calc_sites.py b/resources/tester/test_309_2_calc_sites.py index b835aaa8e..f1cfad168 100644 --- a/resources/tester/test_309_2_calc_sites.py +++ b/resources/tester/test_309_2_calc_sites.py @@ -237,7 +237,7 @@ def test_연결_자리_cross_격자_그대로(): assert body.index('if (await calcOnServer("corridor_mesh")) return null;') < body.index( "getWorker()" ) - assert "void openCalcSites();" in _text("A00_Common/design/design_page.ts") + assert "void openCalcSites(projectId);" in _text("A00_Common/design/design_page.ts") # 저장 길은 판을 안 읽음 for rel in ("A00_Common/design/design_save.ts", "A00_Common/design/design_save_api.ts"): assert "calc_sites" not in _text(rel) and "calcOnServer" not in _text(rel) diff --git a/resources/tester/test_511_a_calc_server.py b/resources/tester/test_511_a_calc_server.py new file mode 100644 index 000000000..3054674f7 --- /dev/null +++ b/resources/tester/test_511_a_calc_server.py @@ -0,0 +1,139 @@ +"""PLAN 511-A (요구 320-4) — 계산 자리 「서버」 창구 `POST /api/projects/{id}/calc` · 떠 있는 Node. + +못박는 것 + - 창구 = 프로젝트 권한(다른 회사 403 · 시스템 관리자 통과) · 본문 상한 413 · 모르는 id · 꼴 400. + - 늘 떠 있는 Node 답 = 파일 길(`run_calc`) 답 · 죽여도 다음 부름에 다시 띄움. + - 시험 길 id(`TRIAL_IDS`) = TS `design_calc_node_trial.ts` 표 · `available` 에 안 섞임. + - 공유 DB 안 씀(가짜 풀) · 실제 마스터 안 씀(고정 자료 golden_472). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_511_a_calc_server.py -q +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B06_Section import B06_Section_Router_Calc as calc_router # noqa: E402 +from common_util import common_util_auth as auth # noqa: E402 +from common_util import common_util_calc_runner as runner # noqa: E402 + +FIXTURE = ROOT / "resources" / "tester" / "fixtures" / "golden_472" +TRIAL_TS = ROOT / "A00_Common" / "design" / "logic" / "design_calc_node_trial.ts" +PROJECT = "11111111-2222-3333-4444-555555555555" + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치" +) + + +def _fixture(name: str): + return json.loads((FIXTURE / name).read_text(encoding="utf-8")) + + +def _inputs() -> dict: + ctx = _fixture("context.json") + keys = ("earthwork_conversion", "haul_equipment_limits", "natural_spoil_min_ground_slope") + return {"detail": _fixture("detail.json"), "context": {k: ctx[k] for k in keys}} + + +class _Cursor: + async def execute(self, sql, args=None): + self.args = args + + async def fetchone(self): + return (1,) if self.args == (PROJECT,) else None # 회사 1 프로젝트 하나 + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + +class _Conn(_Cursor): + def cursor(self): + return _Cursor() + + +class _Pool: + def acquire(self): + return _Conn() + + +@pytest.fixture +def client(monkeypatch): + monkeypatch.setattr(auth, "get_db_pool", lambda: _Pool()) + app = FastAPI() + app.include_router( + calc_router.router, + dependencies=[Depends(auth.verify_session), Depends(auth.require_project_access)], + ) + session = {"role": "USER", "company_id": 1, "user_id": 7} + app.dependency_overrides[auth.verify_session] = lambda: dict(session) + return TestClient(app), session + + +def test_main_프로젝트_권한으로_붙음(): + main = (ROOT / "main.py").read_text(encoding="utf-8") + assert "app.include_router(b06_calc_router, dependencies=protected_with_company)" in main + assert "note_api_call(session_id)" in main # 호출량 = 미들웨어(/api 전부) + + +def test_권한_다른_회사_403_관리자_통과(client): + http, session = client + body = {"run": ["cross.fit"], "inputs": {"args": {"cross.fit": [{}]}}} + ok = http.post(f"/api/projects/{PROJECT}/calc", json=body) + assert ok.status_code == 200, ok.text + assert ok.json()["results"] == {"cross.fit": {"left": None, "right": None}} + session["company_id"] = 2 + assert http.post(f"/api/projects/{PROJECT}/calc", json=body).status_code == 403 + missing = "00000000-0000-0000-0000-000000000000" + assert http.post(f"/api/projects/{missing}/calc", json=body).status_code == 404 + session["role"] = "SYSTEM_ADMIN" + assert http.post(f"/api/projects/{PROJECT}/calc", json=body).status_code == 200 + + +def test_본문_상한_꼴_모르는_id(client, monkeypatch): + http, _ = client + url = f"/api/projects/{PROJECT}/calc" + monkeypatch.setattr(calc_router, "MAX_BODY", 100) + big = {"run": ["cross.fit"], "inputs": {"pad": "x" * 200}} + assert http.post(url, json=big).status_code == 413 + monkeypatch.setattr(calc_router, "MAX_BODY", 8 * 1024 * 1024) + assert http.post(url, content=b"{nope").status_code == 400 + assert http.post(url, json={"run": "cross.fit"}).status_code == 400 + assert http.post(url, json={"run": ["cross.fit"], "inputs": []}).status_code == 400 + bad = http.post(url, json={"run": ["stations.list"], "inputs": {}}) + assert bad.status_code == 400 and "stations.list" in bad.json()["message"] + + +def test_떠_있는_Node_답_파일_길과_같음_죽어도_다시(): + ids = ["structure.layouts", "masshaul.cumulative", "haul.plan", "excavation.trench"] + want = runner.run_calc(ids, _inputs()) + got = runner.run_calc_served(ids, _inputs()) + assert got["results"] == want and want["masshaul.cumulative"]["final_cumulative_m3"] + assert got["ms"] >= 0 and got["wall_ms"] >= got["ms"] + runner._SERVED.proc.kill() + runner._SERVED.proc.wait() + again = runner.run_calc_served(["structure.layouts"], _inputs()) + assert again["results"]["structure.layouts"] == want["structure.layouts"] + + +def test_시험_길_id_TS_표와_같음_available_밖(): + src = TRIAL_TS.read_text(encoding="utf-8") + table = src.split("const TRIAL: Record = {")[1].split("\n};")[0] + assert set(re.findall(r'^ "([a-z_.]+)":', table, re.M)) == runner.TRIAL_IDS + out = runner.run_bundle_json(runner.BUNDLE, runner.NPM_SCRIPT, {"run": [], "inputs": {}}) + assert set(out["available"]) == runner.NODE_IDS + assert not runner.NODE_IDS & runner.TRIAL_IDS