优化原生结果编码传输与浏览器缓存,记录八路性能基线

原生结果series通过字节索引直传,C端使用Ryu精确回读编码和64 KiB批量写出;网页采用Float64缓存和CSV工作线程,减少结果处理与保存等待。

补充八路AME曲线核查、全流程分阶段计时、独立编码基准和复现工具,固定后续优化采用修正八路及rtol=1e-8。C写出1.1808→0.1638 s,点击到可查看8.0100→6.9756 s。

验证:最终10项编码专项、29项相关后端回归通过;8份原生结果逐位一致,16次网页结果/CSV/刷新恢复通过。前端构建及缓存/CSV专项在本轮结果处理工作中通过。环境、原始大结果与临时构建不纳入Git。
This commit is contained in:
lujingze committed 2026-09-11 15:09:15 +00:00
1 parent 808c484f5b
commit 3bc4be3c06
55 files changed
+20522 -141

No files matched your search

+31 -50
View File
@@ -1,4 +1,5 @@
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { exportResultCsv, resultCsvFilename } from "./resultCsvExport";
import type {
ChangeEvent as ReactChangeEvent,
DragEvent,
@@ -442,6 +443,15 @@ export function SimulationResultsView({
const [activePaneResize, setActivePaneResize] =
useState<PaneResizeKind | null>(null);
const [csvDownloadPending, setCsvDownloadPending] = useState(false);
const csvExportRef = useRef<AbortController | null>(null);
useEffect(() => {
setCsvDownloadPending(false);
return () => {
const pending = csvExportRef.current;
csvExportRef.current = null;
pending?.abort();
};
}, [snapshot]);
const [activeMultiPickerWindowId, setActiveMultiPickerWindowId] = useState<
string | null
>(null);
@@ -1256,32 +1266,25 @@ export function SimulationResultsView({
};
const downloadResultsCsv = async () => {
if (csvDownloadPending) {
return;
}
// Ref closes the gap before React renders the disabled button.
if (csvExportRef.current) return;
const controller = new AbortController();
csvExportRef.current = controller;
setCsvDownloadPending(true);
try {
const response = await fetch("/api/simulation-results/csv", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: snapshot.project.name,
variables: snapshot.result.variables,
series: snapshot.result.series,
}),
});
if (!response.ok) {
throw new Error(await responseErrorMessage(response));
const blob = await exportResultCsv(snapshot.result, controller.signal);
if (csvExportRef.current === controller) {
downloadBlob(blob, resultCsvFilename(snapshot.project.name));
}
const filename = responseDownloadFilename(
response,
`${safeFileStem(snapshot.project.name)}-results.csv`,
);
downloadBlob(await response.blob(), filename);
} catch (error) {
window.alert(`CSV 下载失败:${errorMessage(error)}`);
if (!controller.signal.aborted && csvExportRef.current === controller) {
window.alert(`CSV 下载失败:${errorMessage(error)}`);
}
} finally {
setCsvDownloadPending(false);
if (csvExportRef.current === controller) {
csvExportRef.current = null;
setCsvDownloadPending(false);
}
}
};
@@ -5539,31 +5542,6 @@ function compactFileTimestamp(value: string) {
)}`;
}
function responseDownloadFilename(response: Response, fallback: string) {
const disposition = response.headers.get("Content-Disposition") ?? "";
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(disposition)?.[1];
if (encoded) {
try {
return decodeURIComponent(encoded);
} catch {
return fallback;
}
}
return /filename="([^"]+)"/i.exec(disposition)?.[1] ?? fallback;
}
async function responseErrorMessage(response: Response) {
try {
const payload = (await response.json()) as { detail?: unknown };
if (typeof payload.detail === "string") {
return payload.detail;
}
} catch {
// Use the HTTP status when the server did not return a JSON error body.
}
return `${response.status} ${response.statusText}`.trim();
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
@@ -5582,10 +5560,13 @@ function downloadBlob(blob: Blob, filename: string) {
anchor.href = url;
anchor.download = filename;
anchor.style.display = "none";
document.body.append(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
try {
document.body.append(anchor);
anchor.click();
} finally {
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
}
function colorForVariable(key: string) {
+38
View File
@@ -0,0 +1,38 @@
import { buildResultCsvBlob } from "./resultCsvEncoding";
import type { ResultCsvWorkerMessage, ResultCsvWorkerResponse } from "./resultCsvExport";
// Keep DOM and worker entry points on the same app tsconfig without mixing libs.
const worker = self as unknown as {
onmessage: ((event: MessageEvent<ResultCsvWorkerMessage>) => void) | null;
postMessage: (message: ResultCsvWorkerResponse) => void;
};
let keys: string[] | null = null;
let rowCount = 0;
let received = 0;
let values: Float64Array | null = null;
worker.onmessage = ({ data }) => {
try {
if (data.type === "start") {
keys = data.keys;
rowCount = data.rowCount;
values = new Float64Array(keys.length * rowCount);
received = 0;
} else if (data.type === "chunk") {
if (!values || data.offset !== received || received + data.values.length > values.length) {
throw new Error("Simulation result CSV data is incomplete.");
}
values.set(data.values, received);
received += data.values.length;
} else {
if (!keys || !values || received !== values.length) {
throw new Error("Simulation result CSV data is incomplete.");
}
worker.postMessage({ type: "complete", blob: buildResultCsvBlob(keys, rowCount, values) });
keys = values = null;
}
} catch (error) {
keys = values = null;
worker.postMessage({ type: "error", message: error instanceof Error ? error.message : String(error) });
}
};
+37
View File
@@ -0,0 +1,37 @@
/** CSV uses raw result units and metadata order, independently of chart units. */
export function buildResultCsvBlob(
keys: readonly string[],
rowCount: number,
values: Float64Array,
): Blob {
if (values.length !== keys.length * rowCount) {
throw new Error("Simulation result CSV data is incomplete.");
}
const escape = (value: string) =>
/[",\r\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
const parts: BlobPart[] = ["\ufeff", keys.map(escape).join(","), "\r\n"];
const lines: string[] = [];
const row = new Array<string>(keys.length);
let chunkLength = 0;
for (let index = 0; index < rowCount; index++) {
for (let column = 0; column < keys.length; column++) {
const value = values[column * rowCount + index];
if (!Number.isFinite(value)) {
throw new Error(`Simulation result column '${keys[column]}' contains non-finite values.`);
}
// Number.toString() round-trips binary64. Preserve negative zero too;
// unlike Python float repr, integral values need no redundant ".0".
row[column] = Object.is(value, -0) ? "-0" : String(value);
}
const line = row.join(",") + "\r\n";
lines.push(line);
chunkLength += line.length;
if (chunkLength >= 1024 * 1024) {
parts.push(lines.join(""));
lines.length = 0;
chunkLength = 0;
}
}
if (lines.length) parts.push(lines.join(""));
return new Blob(parts, { type: "text/csv;charset=utf-8" });
}
+128
View File
@@ -0,0 +1,128 @@
export type ResultCsvInput = {
variables: readonly { key: string }[];
series: Readonly<Record<string, readonly number[]>>;
};
export type ResultCsvWorkerMessage =
| { type: "start"; keys: string[]; rowCount: number }
| { type: "chunk"; offset: number; values: Float64Array }
| { type: "finish" };
export type ResultCsvWorkerResponse =
| { type: "complete"; blob: Blob }
| { type: "error"; message: string };
function csvColumns(input: ResultCsvInput) {
const times = input.series.time;
if (!Array.isArray(times) || !times.length) {
throw new Error("Simulation results must contain a non-empty time series.");
}
const variableKeys = input.variables.map((variable) => variable.key);
if (!variableKeys.length) {
throw new Error("Simulation results do not contain exportable variables.");
}
if (new Set(variableKeys).size !== variableKeys.length) {
throw new Error("Simulation result metadata contains duplicate variable keys.");
}
const keys = ["time", ...variableKeys];
const expectedKeys = new Set(keys);
const missing = [...expectedKeys].filter((key) => !Object.hasOwn(input.series, key)).sort();
const unknown = Object.keys(input.series).filter((key) => !expectedKeys.has(key)).sort();
if (missing.length || unknown.length) {
const details = [missing.length ? `missing ${missing.join(", ")}` : "", unknown.length ? `unmapped ${unknown.join(", ")}` : ""].filter(Boolean);
throw new Error(`Simulation result columns do not match metadata: ${details.join("; ")}.`);
}
for (const key of keys) {
if (!Array.isArray(input.series[key]) || input.series[key].length !== times.length) {
throw new Error(`Simulation result column '${key}' has an inconsistent length.`);
}
}
return { keys, rowCount: times.length };
}
export function resultCsvFilename(projectName: string): string {
const stem = projectName.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_").replace(/^[ .]+|[ .]+$/g, "");
return `${Array.from(stem).slice(0, 80).join("") || "simulation"}-results.csv`;
}
/** Transfer at most 1 MiB per task; never stringify or clone the full result. */
export function exportResultCsv(input: ResultCsvInput, signal?: AbortSignal): Promise<Blob> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException("CSV export was cancelled.", "AbortError"));
return;
}
let worker: Worker | undefined;
let settled = false;
let yieldTimer: ReturnType<typeof setTimeout> | undefined;
let resumeYield: (() => void) | undefined;
const finish = (error?: unknown, blob?: Blob) => {
if (settled) return;
settled = true;
signal?.removeEventListener("abort", abort);
if (yieldTimer !== undefined) clearTimeout(yieldTimer);
resumeYield?.();
worker?.terminate();
if (error !== undefined) reject(error);
else resolve(blob!);
};
const abort = () => finish(new DOMException("CSV export was cancelled.", "AbortError"));
signal?.addEventListener("abort", abort, { once: true });
const yieldMainThread = () => new Promise<void>((resume) => {
resumeYield = resume;
yieldTimer = setTimeout(() => {
yieldTimer = undefined;
resumeYield = undefined;
resume();
}, 0);
});
try {
const { keys, rowCount } = csvColumns(input);
worker = new Worker(new URL("./resultCsv.worker.ts", import.meta.url), { type: "module" });
worker.onmessage = ({ data }: MessageEvent<ResultCsvWorkerResponse>) => {
if (data.type === "complete" && data.blob instanceof Blob) finish(undefined, data.blob);
else finish(new Error(data.type === "error" ? data.message : "CSV worker returned an invalid response."));
};
worker.onerror = (event) => {
event.preventDefault();
finish(new Error(event.message || "CSV worker failed."));
};
worker.onmessageerror = () => finish(new Error("CSV worker response could not be read."));
const transfer = async () => {
// Let the pending indicator render before copying any numeric payload.
await yieldMainThread();
if (settled) return;
worker!.postMessage({ type: "start", keys, rowCount } satisfies ResultCsvWorkerMessage);
const total = keys.length * rowCount;
let column = 0;
let row = 0;
for (let offset = 0; offset < total;) {
if (settled) return;
const batch = new Float64Array(Math.min(128 * 1024, total - offset));
let filled = 0;
while (filled < batch.length) {
const source = input.series[keys[column]];
const count = Math.min(rowCount - row, batch.length - filled);
for (let index = 0; index < count; index++) {
const value = source[row + index];
if (typeof value !== "number") {
throw new Error(`Simulation result column '${keys[column]}' contains non-numeric values.`);
}
batch[filled + index] = value;
}
row += count;
filled += count;
if (row === rowCount) { column++; row = 0; }
}
const transferredCount = batch.length;
worker!.postMessage({ type: "chunk", offset, values: batch } satisfies ResultCsvWorkerMessage, [batch.buffer]);
offset += transferredCount;
if (offset < total) await yieldMainThread();
}
if (!settled) worker!.postMessage({ type: "finish" } satisfies ResultCsvWorkerMessage);
};
void transfer().catch(finish);
} catch (error) {
finish(error);
}
});
}
+112 -55
View File
@@ -2,13 +2,15 @@ import type { SimulationResultsSnapshot } from "./SimulationResultsView";
export const RESULT_SNAPSHOT_KEY = "system-simulation-flow:latest-result";
const DATABASE = "system-simulation-results";
const CHUNK_SIZE = 32768;
const BATCH_VALUES = 131072;
// Pack short output columns together: an eight-branch result needs seven writes,
// rather than one write per output column and fourteen serial transactions.
const PACKED_VALUES = 262144;
type Header = {
snapshot: SimulationResultsSnapshot;
lengths: Record<string, number>;
layout?: "packed-f64-v1";
};
type Chunk = { key: IDBValidKey; values: Float64Array };
type PackedChunk = { offset: number; values: Float64Array };
let database: Promise<IDBDatabase> | undefined;
let saveSequence = 0;
let pendingSaves = 0;
@@ -39,15 +41,17 @@ function openDatabase() {
return database;
}
function writeBatch(db: IDBDatabase, chunks: Chunk[], header?: [string, Header]) {
function writeSnapshot(db: IDBDatabase, cacheId: string, chunks: PackedChunk[], header: Header) {
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction(["headers", "chunks"], "readwrite");
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error ?? new Error("结果保存事务已中止"));
transaction.onerror = () => {}; // onabort reports the transaction failure once.
try {
for (const chunk of chunks) transaction.objectStore("chunks").put(chunk.values, chunk.key);
if (header) transaction.objectStore("headers").put(header[1], header[0]);
const store = transaction.objectStore("chunks");
for (const chunk of chunks) store.put(chunk, [cacheId, chunk.offset]);
transaction.objectStore("headers").put(header, cacheId);
transaction.commit?.();
} catch (error) {
transaction.abort();
reject(error);
@@ -65,7 +69,7 @@ function deleteCache(db: IDBDatabase, cacheId: string) {
});
}
/** Publish the small session pointer only after every data chunk is durably committed. */
/** Publish the small session pointer only after the atomic snapshot write commits. */
export async function storeResultSnapshot(snapshot: SimulationResultsSnapshot) {
const sequence = ++saveSequence;
const cacheId = typeof crypto.randomUUID === "function"
@@ -74,49 +78,86 @@ export async function storeResultSnapshot(snapshot: SimulationResultsSnapshot) {
pendingSaves++;
let db: IDBDatabase | undefined;
let committed = false;
let writeStarted = false;
try {
// Yield before serialization so the ready result and controls can paint first.
// Yield before copying so the ready result and controls can paint first.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
if (sequence !== saveSequence) return false;
db = await openDatabase();
const columns = Object.entries(snapshot.result.series);
const lengths: Record<string, number> = Object.create(null);
let batch: Chunk[] = [];
let batchSize = 0;
for (const [name, values] of Object.entries(snapshot.result.series)) {
lengths[name] = values.length;
for (let offset = 0; offset < values.length; offset += CHUNK_SIZE) {
if (sequence !== saveSequence) return false;
const length = Math.min(CHUNK_SIZE, values.length - offset);
const block = new Float64Array(length);
for (let i = 0; i < length; i++) block[i] = values[offset + i];
batch.push({ key: [cacheId, name, offset], values: block });
batchSize += length;
if (batchSize >= BATCH_VALUES) {
await writeBatch(db, batch);
batch = [];
batchSize = 0;
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
let total = 0;
for (const [name, values] of columns) { lengths[name] = values.length; total += values.length; }
const chunks: PackedChunk[] = [];
let columnIndex = 0;
let columnOffset = 0;
for (let offset = 0; offset < total; offset += PACKED_VALUES) {
if (sequence !== saveSequence) return false;
const values = new Float64Array(Math.min(PACKED_VALUES, total - offset));
let written = 0;
while (written < values.length) {
const source = columns[columnIndex][1];
const count = Math.min(source.length - columnOffset, values.length - written);
if (columnOffset === 0 && count === source.length) values.set(source, written);
else for (let i = 0; i < count; i++) values[written + i] = source[columnOffset + i];
written += count;
columnOffset += count;
if (columnOffset === source.length) { columnIndex++; columnOffset = 0; }
}
chunks.push({ offset, values });
// Keep cancellation responsive without holding an idle IndexedDB transaction open.
if (offset + values.length < total) await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
if (sequence !== saveSequence) return false;
await writeBatch(db, batch, [cacheId, {
snapshot: { ...snapshot, result: { ...snapshot.result, series: Object.create(null) } }, lengths,
}]);
db = await openDatabase();
if (sequence !== saveSequence) return false;
writeStarted = true;
await writeSnapshot(db, cacheId, chunks, {
snapshot: { ...snapshot, result: { ...snapshot.result, series: Object.create(null) } },
lengths, layout: "packed-f64-v1",
});
if (sequence !== saveSequence) return false;
sessionStorage.setItem(RESULT_SNAPSHOT_KEY, JSON.stringify({ storage: "indexeddb", version: 1, cacheId }));
committed = true;
// Only retire this page's own previous save, never another tab's loaded snapshot.
// Only retire this page's own previous save, never a snapshot loaded from another tab.
const previous = ownedCacheId;
ownedCacheId = cacheId;
if (previous) void deleteCache(db, previous).catch(() => {});
return true;
} finally {
pendingSaves--;
if (db && !committed) await deleteCache(db, cacheId).catch(() => {});
if (db && writeStarted && !committed) await deleteCache(db, cacheId).catch(() => {});
}
}
function cacheError() { return new Error("结果缓存不完整或分块无效,请重新载入结果文件"); }
function restorePacked(header: Header, chunks: PackedChunk[]) {
const columns = Object.entries(header.lengths);
const total = columns.reduce((sum, [, length]) => sum + length, 0);
let loaded = 0;
let columnIndex = 0;
let columnOffset = 0;
for (const chunk of chunks) {
if (!chunk || chunk.offset !== loaded || !(chunk.values instanceof Float64Array) ||
chunk.values.length === 0 || loaded + chunk.values.length > total) throw cacheError();
let read = 0;
while (read < chunk.values.length) {
while (columnIndex < columns.length && columnOffset === columns[columnIndex][1]) {
columnIndex++; columnOffset = 0;
}
if (columnIndex >= columns.length) throw cacheError();
const [name, length] = columns[columnIndex];
const count = Math.min(length - columnOffset, chunk.values.length - read);
const target = header.snapshot.result.series[name];
for (let i = 0; i < count; i++) target[columnOffset + i] = chunk.values[read + i];
read += count;
columnOffset += count;
}
loaded += chunk.values.length;
}
if (loaded !== total) throw cacheError();
}
export async function loadStoredResultSnapshot(): Promise<unknown | null> {
const raw = sessionStorage.getItem(RESULT_SNAPSHOT_KEY);
if (!raw) return null;
@@ -128,38 +169,54 @@ export async function loadStoredResultSnapshot(): Promise<unknown | null> {
return new Promise<SimulationResultsSnapshot | null>((resolve, reject) => {
const transaction = db.transaction(["headers", "chunks"], "readonly");
let result: SimulationResultsSnapshot | null = null;
let failure: Error | null = null;
let failure: unknown = null;
transaction.oncomplete = () => failure ? reject(failure) : resolve(result);
transaction.onabort = () => reject(transaction.error);
const request = transaction.objectStore("headers").get(value.cacheId);
request.onsuccess = () => {
const header = request.result as Header | undefined;
if (!header) { failure = new Error("结果缓存已丢失,请重新载入结果文件"); return; }
result = header.snapshot;
for (const [name, length] of Object.entries(header.lengths)) {
result.result.series[name] = new Array<number>(length);
}
const loaded: Record<string, number> = Object.create(null);
const cursor = transaction.objectStore("chunks").openCursor(IDBKeyRange.bound([value.cacheId], [value.cacheId, []]));
cursor.onsuccess = () => {
const entry = cursor.result;
if (!entry) {
for (const [name, length] of Object.entries(header.lengths)) {
if ((loaded[name] ?? 0) !== length) failure = new Error("结果缓存不完整,请重新载入结果文件");
}
try {
result = header.snapshot;
for (const [name, length] of Object.entries(header.lengths)) {
if (!Number.isSafeInteger(length) || length < 0) throw cacheError();
result.result.series[name] = new Array<number>(length);
}
const range = IDBKeyRange.bound([value.cacheId], [value.cacheId, []]);
if (header.layout === "packed-f64-v1") {
// A handful of packed records can be restored with one IndexedDB delivery.
const blocks = transaction.objectStore("chunks").getAll(range);
blocks.onsuccess = () => {
try { restorePacked(header, blocks.result as PackedChunk[]); }
catch (error) { failure = error; }
};
return;
}
const [, name, offset] = entry.key as [string, string, number];
const block = entry.value as Float64Array;
const values = result!.result.series[name];
if (!values || offset < 0 || offset + block.length > values.length) {
failure = new Error("结果缓存分块无效");
} else {
for (let i = 0; i < block.length; i++) values[offset + i] = block[i];
loaded[name] = (loaded[name] ?? 0) + block.length;
}
entry.continue();
};
if (header.layout !== undefined) throw cacheError();
// Compatibility with the original [cacheId, columnName, offset] records.
const loaded: Record<string, number> = Object.create(null);
const cursor = transaction.objectStore("chunks").openCursor(range);
cursor.onsuccess = () => {
const entry = cursor.result;
if (!entry) {
for (const [name, length] of Object.entries(header.lengths)) {
if ((loaded[name] ?? 0) !== length) failure = cacheError();
}
return;
}
const [, name, offset] = entry.key as [string, string, number];
const block = entry.value as Float64Array;
const values = result!.result.series[name];
if (!values || !(block instanceof Float64Array) || offset !== (loaded[name] ?? 0) ||
offset + block.length > values.length) {
failure = cacheError();
} else {
for (let i = 0; i < block.length; i++) values[offset + i] = block[i];
loaded[name] = offset + block.length;
}
entry.continue();
};
} catch (error) { failure = error; }
};
});
}
@@ -0,0 +1,208 @@
import { expect, test, type Page } from "@playwright/test";
import { buildResultCsvBlob } from "../../src/resultCsvEncoding";
import { prepareApp, resultSnapshot } from "./fixtures";
test("CSV preserves BOM, CRLF, escaped raw keys and every binary64 value", async () => {
const numbers = [-0, 0, Number.MIN_VALUE, -Number.MIN_VALUE, Number.MAX_VALUE, -Number.MAX_VALUE,
1 + Number.EPSILON, 1e21, 1e-7, Math.PI, 403499745569.73364];
const keys = ["time", '力,"端口"\r\nN'];
const blob = buildResultCsvBlob(keys, numbers.length, new Float64Array([...numbers, ...numbers]));
const bytes = new Uint8Array(await blob.arrayBuffer());
expect([...bytes.slice(0, 3)]).toEqual([0xef, 0xbb, 0xbf]);
const text = new TextDecoder().decode(bytes);
const header = 'time,"力,""端口""\r\nN"\r\n';
expect(text.startsWith(header)).toBe(true);
const rows = text.slice(header.length).split("\r\n");
expect(rows.pop()).toBe("");
expect(rows).toHaveLength(numbers.length);
rows.forEach((row, i) => row.split(",").forEach((value) => expect(Object.is(Number(value), numbers[i])).toBe(true)));
expect(blob.type).toBe("text/csv;charset=utf-8");
expect(() => buildResultCsvBlob(["time"], 1, new Float64Array([Infinity]))).toThrow("non-finite");
expect(() => buildResultCsvBlob(["time"], 2, new Float64Array([0]))).toThrow("incomplete");
});
async function probePage(page: Page) {
await page.route("**/csv-worker-probe", (route) => route.fulfill({ contentType: "text/html", body: "<title>CSV worker probe</title>" }));
await page.goto("/csv-worker-probe");
}
test("real Worker exports over one million raw cells exactly in bounded transfers", async ({ page }) => {
test.setTimeout(60_000);
await probePage(page);
let csvRequests = 0;
page.on("request", (request) => { if (request.url().includes("/api/simulation-results/csv")) csvRequests++; });
const result = await page.evaluate(async () => {
// @ts-expect-error Vite serves this browser module.
const { exportResultCsv } = await import("/src/resultCsvExport.ts");
const OriginalWorker = Worker;
let workerCount = 0, terminated = 0, maxTransferBytes = 0, transferCount = 0;
window.Worker = class extends OriginalWorker {
constructor(url: string | URL, options?: WorkerOptions) { super(url, options); workerCount++; }
postMessage(message: any, transfer?: any) {
if (message.type === "chunk") { maxTransferBytes = Math.max(maxTransferBytes, message.values.byteLength); transferCount++; }
super.postMessage(message, transfer);
}
terminate() { terminated++; super.terminate(); }
};
const count = 4097;
const variables = Array.from({ length: 256 }, (_, i) => ({ key: `component_${255-i}.pressure`, unit: "Pa", order: i }));
const series: Record<string, number[]> = { time: Array.from({ length: count }, (_, i) => i / 1000) };
series.time[1] = 0; // Duplicate and event samples are data, not rows to remove.
series.time[2100] = 2.099000001;
for (let column = variables.length - 1; column >= 0; column--) {
series[variables[column].key] = Array.from({ length: count }, (_, i) => Math.sin(i / 17) * (column + 1));
}
const extremes = [-0, Number.MIN_VALUE, Number.MAX_VALUE, 1 + Number.EPSILON, 403499745569.73364];
extremes.forEach((value, i) => { series[variables[0].key][i] = value; });
let ticks = 0;
const timer = setInterval(() => ticks++, 0);
const started = performance.now();
const blob = await exportResultCsv({ variables, series });
const elapsedMs = performance.now() - started;
clearInterval(timer);
window.Worker = OriginalWorker;
const bytes = new Uint8Array(await blob.arrayBuffer());
const text = new TextDecoder().decode(bytes);
const lines = text.split("\r\n");
const expectedKeys = ["time", ...variables.map((v) => v.key)];
const ordered = lines.shift() === expectedKeys.join(",");
const trailingNewline = lines.pop() === "";
let checked = 0, exact = true;
lines.forEach((line, row) => line.split(",").forEach((value, column) => {
exact &&= Object.is(Number(value), series[expectedKeys[column]][row]); checked++;
}));
return { ordered, trailingNewline, exact, checked, rowCount: lines.length,
bom: [...bytes.slice(0,3)], workerCount, terminated, maxTransferBytes, transferCount, ticks, elapsedMs };
});
expect(result).toMatchObject({ ordered: true, trailingNewline: true, exact: true, checked: 257 * 4097,
rowCount: 4097, bom: [239,187,191], workerCount: 1, terminated: 1 });
expect(result.maxTransferBytes).toBeLessThanOrEqual(1024 * 1024);
expect(result.transferCount).toBeGreaterThan(1);
expect(result.ticks).toBeGreaterThan(1);
expect(csvRequests).toBe(0);
console.log(JSON.stringify({ probe: "csv-worker-roundtrip", ...result }));
});
test("CSV rejects bad metadata/data and releases Workers after failure or cancellation", async ({ page }) => {
await probePage(page);
const result = await page.evaluate(async () => {
// @ts-expect-error Vite serves this browser module.
const { exportResultCsv, resultCsvFilename } = await import("/src/resultCsvExport.ts");
const OriginalWorker = Worker;
let created = 0, terminated = 0;
window.Worker = class extends OriginalWorker {
constructor(url: string | URL, options?: WorkerOptions) { super(url, options); created++; }
terminate() { terminated++; super.terminate(); }
};
const good = () => ({ variables: [{ key: "p" }], series: { time: [0,1], p: [1,2] } });
const errors: string[] = [];
for (const input of [
{ variables: [{ key: "p" }], series: { p: [1] } },
{ variables: [], series: { time: [0] } },
{ variables: [{ key: "p" }, { key: "p" }], series: { time: [0], p: [1] } },
{ variables: [{ key: "p" }], series: { time: [0], extra: [1] } },
{ variables: [{ key: "p" }], series: { time: [0], p: [1,2] } },
{ variables: [{ key: "p" }], series: { time: [0], p: [Infinity] } },
{ variables: [{ key: "p" }], series: { time: [0], p: [null] } },
]) {
try { await exportResultCsv(input); errors.push("unexpected success"); }
catch (error) { errors.push((error as Error).message); }
}
const aborted = new AbortController(); aborted.abort();
const names: string[] = [];
try { await exportResultCsv(good(), aborted.signal); } catch (error) { names.push((error as Error).name); }
const running = new AbortController();
const task = exportResultCsv(good(), running.signal);
running.abort();
try { await task; } catch (error) { names.push((error as Error).name); }
await exportResultCsv(good()); // Failure and abort must not poison a retry.
window.Worker = OriginalWorker;
return { errors, names, created, terminated,
filename: resultCsvFilename(' .储气:/系统. '), unicodeLength: resultCsvFilename("😀".repeat(90)).split("-results")[0].length };
});
expect(result.errors[0]).toContain("non-empty time");
expect(result.errors[1]).toContain("exportable variables");
expect(result.errors[2]).toContain("duplicate");
expect(result.errors[3]).toContain("missing p; unmapped extra");
expect(result.errors[4]).toContain("inconsistent length");
expect(result.errors[5]).toContain("non-finite");
expect(result.errors[6]).toContain("non-numeric");
expect(result.names).toEqual(["AbortError", "AbortError"]);
expect(result.created).toBe(4);
expect(result.terminated).toBe(result.created);
expect(result.filename).toBe("储气__系统-results.csv");
expect(result.unicodeLength).toBe(160);
});
async function openResults(page: Page) {
await prepareApp(page);
await page.addInitScript((snapshot) => {
sessionStorage.setItem("system-simulation-flow:latest-result", JSON.stringify(snapshot));
}, resultSnapshot);
await page.goto("/");
await page.getByRole("tab", { name: "结果", exact: true }).click();
await expect(page.getByRole("button", { name: "下载结果 CSV", exact: true })).toBeEnabled();
}
test("CSV toolbar downloads current raw data locally without HTTP", async ({ page }) => {
await openResults(page);
let requests = 0;
page.on("request", (request) => { if (request.url().includes("/api/simulation-results/csv")) requests++; });
const downloading = page.waitForEvent("download");
await page.getByRole("button", { name: "下载结果 CSV", exact: true }).click();
const download = await downloading;
const stream = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream!) chunks.push(Buffer.from(chunk));
expect(Buffer.concat(chunks).toString("utf8")).toBe("\ufefftime,generic_sensor_1.value\r\n0,0\r\n5,0.5\r\n10,1\r\n");
await expect(page.getByRole("button", { name: "下载结果 CSV", exact: true })).toBeEnabled();
expect(requests).toBe(0);
});
async function installHeldWorker(page: Page) {
await page.evaluate(() => {
const original = Worker;
(window as any).__csvWorkers = [];
window.Worker = function(url: string | URL, options?: WorkerOptions) {
if (!String(url).includes("resultCsv.worker")) return new original(url, options);
const worker = { onmessage: null, onerror: null, onmessageerror: null, terminated: 0,
postMessage() {}, terminate() { this.terminated++; } };
(window as any).__csvWorkers.push(worker);
return worker;
} as any;
});
}
test("CSV toolbar blocks duplicate clicks and cancels a download when leaving results", async ({ page }) => {
await openResults(page);
await installHeldWorker(page);
const button = page.getByRole("button", { name: "下载结果 CSV", exact: true });
let downloads = 0;
page.on("download", () => downloads++);
await button.evaluate((element: HTMLButtonElement) => { element.click(); element.click(); });
await expect(button).toBeDisabled();
expect(await page.evaluate(() => (window as any).__csvWorkers.length)).toBe(1);
await page.getByRole("tab", { name: "建模", exact: true }).click();
expect(await page.evaluate(() => (window as any).__csvWorkers[0].terminated)).toBe(1);
await page.evaluate(() => (window as any).__csvWorkers[0].onmessage({ data: { type: "complete", blob: new Blob(["stale"]) } }));
expect(downloads).toBe(0);
await page.getByRole("tab", { name: "结果", exact: true }).click();
await expect(button).toBeEnabled();
});
test("CSV Worker errors release resources and allow the toolbar to retry", async ({ page }) => {
await openResults(page);
await installHeldWorker(page);
const button = page.getByRole("button", { name: "下载结果 CSV", exact: true });
await button.click();
const dialog = page.waitForEvent("dialog");
const fail = page.evaluate(() => (window as any).__csvWorkers[0].onerror({ message: "injected worker error", preventDefault() {} }));
const alert = await dialog;
expect(alert.message()).toBe("CSV 下载失败:injected worker error");
await alert.accept();
await fail;
await expect(button).toBeEnabled();
expect(await page.evaluate(() => (window as any).__csvWorkers[0].terminated)).toBe(1);
await button.click();
expect(await page.evaluate(() => (window as any).__csvWorkers.length)).toBe(2);
});
@@ -0,0 +1,179 @@
import { expect, test, type Page } from "@playwright/test";
import { resultSnapshot, prepareApp } from "./fixtures";
async function probePage(page: Page) {
await prepareApp(page);
await page.route("**/result-persistence-probe", route => route.fulfill({ contentType: "text/html", body: "<title>Persistence probe</title>" }));
await page.goto("/result-persistence-probe");
}
test("eight-branch shaped results save atomically with few writes and restore every number", async ({ page }) => {
await probePage(page);
const saved = await page.evaluate(async template => {
// @ts-expect-error browser-side Vite module
const api = await import("/src/resultPersistence.ts");
const series: Record<string, number[]> = { emptyFirst: [] };
for (let column = 0; column < 1785; column++) {
series[column === 0 ? "time" : `output.${column}`] = Array.from({ length: 1002 }, (_, row) => column * 100000 + row + 0.125);
}
series.emptyLast = [];
series["output.17"][31] = -0;
const snapshot = { ...template, result: { ...template.result, series } };
const originalTransaction = IDBDatabase.prototype.transaction;
const originalPut = IDBObjectStore.prototype.put;
let writes = 0;
let transactions = 0;
let pointerBeforeCommit: string | null | undefined;
let pointerAtCommit: string | null | undefined;
IDBDatabase.prototype.transaction = function (...args: Parameters<typeof originalTransaction>) {
const transaction = originalTransaction.apply(this, args);
if (this.name === "system-simulation-results" && transaction.mode === "readwrite") {
transactions++;
pointerBeforeCommit = sessionStorage.getItem(api.RESULT_SNAPSHOT_KEY);
transaction.addEventListener("complete", () => { pointerAtCommit = sessionStorage.getItem(api.RESULT_SNAPSHOT_KEY); });
}
return transaction;
};
IDBObjectStore.prototype.put = function (...args: Parameters<typeof originalPut>) {
writes++;
return originalPut.apply(this, args);
};
const start = performance.now();
await api.storeResultSnapshot(snapshot);
const saveMs = performance.now() - start;
IDBDatabase.prototype.transaction = originalTransaction;
IDBObjectStore.prototype.put = originalPut;
const readStart = performance.now();
const restored = await api.loadStoredResultSnapshot();
return { writes, transactions, saveMs, readMs: performance.now() - readStart,
pointerBeforeCommit, pointerAtCommit, hasPointer: Boolean(sessionStorage.getItem(api.RESULT_SNAPSHOT_KEY)),
pending: api.resultSavePending(),
exact: Object.entries(series).every(([key, values]) => values.length === restored.result.series[key].length && values.every((value, i) => Object.is(value, restored.result.series[key][i]))) };
}, resultSnapshot);
expect(saved).toMatchObject({ transactions: 1, pointerBeforeCommit: null, pointerAtCommit: null, hasPointer: true, pending: false, exact: true });
// Thousands of short output columns must not cause thousands of writes or
// one transaction per small batch; the workload bound is independent of packing size.
expect(saved.writes).toBeLessThan(16);
console.log(JSON.stringify({ probe: "eight-branch-packed-persistence", ...saved }));
await page.reload();
const restored = await page.evaluate(async () => {
// @ts-expect-error browser-side Vite module
const { loadStoredResultSnapshot } = await import("/src/resultPersistence.ts");
const snapshot = await loadStoredResultSnapshot();
return { columns: Object.keys(snapshot.result.series).length, last: snapshot.result.series["output.1784"][1001], empty: snapshot.result.series.emptyFirst };
});
expect(restored).toEqual({ columns: 1787, last: 178401001.125, empty: [] });
});
test("legacy session snapshots and old column chunks remain readable; gaps and overlaps fail", async ({ page }) => {
await probePage(page);
const result = await page.evaluate(async template => {
// @ts-expect-error browser-side Vite module
const api = await import("/src/resultPersistence.ts");
sessionStorage.setItem(api.RESULT_SNAPSHOT_KEY, JSON.stringify(template));
const sessionLegacy = (await api.loadStoredResultSnapshot()).id === template.id;
// Create legacy records independently of the new writer and its format.
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("system-simulation-results", 1);
request.onupgradeneeded = () => { request.result.createObjectStore("headers"); request.result.createObjectStore("chunks"); };
request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error);
});
async function legacy(chunks: [number, number[]][]) {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(["headers", "chunks"], "readwrite");
tx.oncomplete = () => resolve(); tx.onabort = () => reject(tx.error);
tx.objectStore("headers").put({ snapshot: { ...template, result: { ...template.result, series: {} } }, lengths: { time: 6, empty: 0 } }, "legacy");
tx.objectStore("chunks").clear();
for (const [offset, values] of chunks) tx.objectStore("chunks").put(new Float64Array(values), ["legacy", "time", offset]);
});
sessionStorage.setItem(api.RESULT_SNAPSHOT_KEY, JSON.stringify({ storage: "indexeddb", version: 1, cacheId: "legacy" }));
try { return { values: (await api.loadStoredResultSnapshot()).result.series.time, rejected: false }; }
catch { return { rejected: true }; }
}
const complete = await legacy([[0, [1, 2, 3]], [3, [4, 5, 6]]]);
const gap = await legacy([[0, [1, 2]], [3, [4, 5, 6]]]);
const overlap = await legacy([[0, [1, 2, 3, 4]], [2, [3, 4]]]);
db.close();
return { sessionLegacy, complete, gap, overlap };
}, resultSnapshot);
expect(result).toEqual({ sessionLegacy: true, complete: { values: [1, 2, 3, 4, 5, 6], rejected: false }, gap: { rejected: true }, overlap: { rejected: true } });
});
test("in-flight superseded commits and failed writes cannot replace the latest complete pointer", async ({ page }) => {
await probePage(page);
const result = await page.evaluate(async template => {
// @ts-expect-error browser-side Vite module
const api = await import("/src/resultPersistence.ts");
await api.storeResultSnapshot(template);
const stablePointer = sessionStorage.getItem(api.RESULT_SNAPSHOT_KEY);
const originalPut = IDBObjectStore.prototype.put;
let puts = 0;
IDBObjectStore.prototype.put = function (...args: Parameters<typeof originalPut>) {
if (++puts === 2) throw new DOMException("Injected header failure after data write", "QuotaExceededError");
return originalPut.apply(this, args);
};
let failed = false;
try { await api.storeResultSnapshot({ ...template, id: "failed" }); } catch { failed = true; }
IDBObjectStore.prototype.put = originalPut;
const preserved = sessionStorage.getItem(api.RESULT_SNAPSHOT_KEY) === stablePointer;
const originalTransaction = IDBDatabase.prototype.transaction;
let latest: Promise<boolean> | undefined;
let armed = true;
IDBDatabase.prototype.transaction = function (...args: Parameters<typeof originalTransaction>) {
const transaction = originalTransaction.apply(this, args);
if (armed && this.name === "system-simulation-results" && transaction.mode === "readwrite") {
armed = false;
// Replace an older save after its transaction starts but before commit.
queueMicrotask(() => { latest = api.storeResultSnapshot({ ...template, id: "newest" }); });
}
return transaction;
};
const old = await api.storeResultSnapshot({ ...template, id: "older" });
const newer = await latest;
IDBDatabase.prototype.transaction = originalTransaction;
const restored = await api.loadStoredResultSnapshot();
return { failed, preserved, old, newer, id: restored.id, pending: api.resultSavePending() };
}, resultSnapshot);
expect(result).toEqual({ failed: true, preserved: true, old: false, newer: true, id: "newest", pending: false });
});
test("saving in another page preserves its borrowed cache and corrupt packed records are rejected", async ({ page, context }) => {
await probePage(page);
const pointer = await page.evaluate(async template => {
// @ts-expect-error browser-side Vite module
const api = await import("/src/resultPersistence.ts");
await api.storeResultSnapshot(template);
return sessionStorage.getItem(api.RESULT_SNAPSHOT_KEY)!;
}, resultSnapshot);
const other = await context.newPage();
await probePage(other);
await other.evaluate(async ({ template, pointer }) => {
// @ts-expect-error browser-side Vite module
const api = await import("/src/resultPersistence.ts");
sessionStorage.setItem(api.RESULT_SNAPSHOT_KEY, pointer);
await api.loadStoredResultSnapshot();
await api.storeResultSnapshot({ ...template, id: "other-page" });
}, { template: resultSnapshot, pointer });
const outcome = await page.evaluate(async () => {
// @ts-expect-error browser-side Vite module
const api = await import("/src/resultPersistence.ts");
const before = await api.loadStoredResultSnapshot();
const marker = JSON.parse(sessionStorage.getItem(api.RESULT_SNAPSHOT_KEY)!);
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("system-simulation-results", 1);
request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error);
});
await new Promise<void>((resolve, reject) => {
const tx = db.transaction("chunks", "readwrite");
tx.oncomplete = () => resolve(); tx.onabort = () => reject(tx.error);
const cursor = tx.objectStore("chunks").openCursor(IDBKeyRange.bound([marker.cacheId], [marker.cacheId, []]));
cursor.onsuccess = () => { const entry = cursor.result; if (entry) entry.update({ ...entry.value, offset: 1 }); };
});
db.close();
let rejected = false;
try { await api.loadStoredResultSnapshot(); } catch { rejected = true; }
return { originalId: before.id, rejected };
});
expect(outcome).toEqual({ originalId: resultSnapshot.id, rejected: true });
await other.close();
});