Files
Aislo/A00_Common/design/design_calc_server.ts
T

237 lines
8.7 KiB
TypeScript

/* =============================================================================
* 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
* calcOnServerOnly 비동기 자리 — server 면 서버 값만(잠잠 뒤 · 끊긴 부름 = undefined)
* 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<string, number[]>;
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<void> {
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"));
});
});
}
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, unknown> | string,
signal?: AbortSignal,
extra: Partial<CalcServerCall> = {},
): Promise<Record<string, unknown>> {
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<string, unknown>;
ms?: number;
wall_ms?: number;
message?: string;
};
if (!response.ok || !data.results) throw new Error(data.message ?? `HTTP ${response.status}`);
// extra 그 객체로 기록 — 대조 뒤 같음 · apply 를 부른 쪽이 그 자리에서 채움
record(
Object.assign(extra, {
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<string, AbortController>();
/** 잠잠 뒤 task — 같은 칸 새 부름 · outer 끊김 = 앞 것 끊음(undefined) · 실패도 undefined(경고 한 줄). */
export async function afterQuiet<T>(
slot: string,
task: (signal: AbortSignal) => Promise<T>,
outer?: AbortSignal,
): Promise<T | undefined> {
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);
}
}
/** 서버 묻는 꼴 — 없으면 시험 길(`inputs.args[id]` = 함수 인자 · 답 = `results[id]`). */
export interface CalcRequest {
run: string[];
inputs: Record<string, unknown>;
pick: (results: Record<string, unknown>) => unknown;
}
/** F7 구조물 면적 · C4 소단 구간 — 등록부 `node:` 입력 그대로. */
export const areasRequest = (rows: unknown[]): CalcRequest => ({
run: ["structure.layouts"],
inputs: { detail: { cross_sections: rows } },
pick: (results) => (results["structure.layouts"] as { areas: unknown }).areas,
});
export const bermRequest = (structures: unknown[]): CalcRequest => ({
run: ["cross.berm_spans"],
inputs: { berm: { structures, chainages: [] } },
pick: (results) => (results["cross.berm_spans"] as { spans: unknown }).spans,
});
export const trialRequest = (id: string, args: unknown[]): CalcRequest => ({
run: [id],
inputs: { args: { [id]: args } },
pick: (results) => results[id],
});
/** 브라우저 셈 + 시간 기록(판 상관없이) · server 면 잠잠 뒤 서버 대조 · 다르면 apply(서버 값). */
export function calcChecked<A extends unknown[], R>(
key: string,
id: string,
fn: (...args: A) => R,
args: A,
options: { apply?: (value: R) => void; request?: CalcRequest } = {},
): 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 request = options.request ?? trialRequest(id, args);
const body = JSON.stringify({ run: request.run, inputs: request.inputs });
const mine = JSON.stringify(plain(value));
const extra: Partial<CalcServerCall> = { browserMs, same: null, applied: false };
void afterQuiet(id, (signal) => serverCalc(key, request.run, body, signal, extra)).then(
(results) => {
if (!results) return;
const theirs = request.pick(results);
extra.same = JSON.stringify(theirs) === mine;
if (!extra.same && options.apply) {
options.apply(theirs as R);
extra.applied = true;
}
},
);
return value;
}
/** 서버 값만(비동기 자리) — 잠잠 뒤 묻고 고른 값 · 새 부름 · outer 끊김 · 실패 = undefined. */
export async function calcOnServerOnly<R>(
key: string,
slot: string,
request: CalcRequest,
outer?: AbortSignal,
): Promise<R | undefined> {
const body = JSON.stringify({ run: request.run, inputs: request.inputs });
const results = await afterQuiet(
slot,
(signal) => serverCalc(key, request.run, body, signal),
outer,
);
return results ? (request.pick(results) as R) : undefined;
}
const held = new Map<string, { body: string; value: unknown }>();
const asked = new Map<string, string>();
/** 그리기 자리 서버 값 — 같은 입력 답이면 fresh · 아니면 앞 답(없으면 undefined) + 잠잠 뒤 묻고 redraw. */
export function calcFromServer<R>(
key: string,
id: string,
args: unknown[],
redraw: () => void,
): { value: R | undefined; fresh: boolean } {
const request = trialRequest(id, args);
const body = JSON.stringify({ run: request.run, inputs: request.inputs });
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 };
}