原生结果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。
223 lines
9.8 KiB
TypeScript
223 lines
9.8 KiB
TypeScript
import type { SimulationResultsSnapshot } from "./SimulationResultsView";
|
|
|
|
export const RESULT_SNAPSHOT_KEY = "system-simulation-flow:latest-result";
|
|
const DATABASE = "system-simulation-results";
|
|
// 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 PackedChunk = { offset: number; values: Float64Array };
|
|
let database: Promise<IDBDatabase> | undefined;
|
|
let saveSequence = 0;
|
|
let pendingSaves = 0;
|
|
let ownedCacheId: string | undefined;
|
|
|
|
export function resultSavePending() { return pendingSaves > 0; }
|
|
|
|
function openDatabase() {
|
|
if (!database) database = new Promise<IDBDatabase>((resolve, reject) => {
|
|
let blocked = false;
|
|
const request = indexedDB.open(DATABASE, 1);
|
|
request.onupgradeneeded = () => {
|
|
request.result.createObjectStore("headers");
|
|
request.result.createObjectStore("chunks");
|
|
};
|
|
request.onerror = () => reject(request.error);
|
|
request.onblocked = () => {
|
|
blocked = true;
|
|
reject(new Error("结果数据库被其他页面占用,请关闭旧页面后重试"));
|
|
};
|
|
request.onsuccess = () => {
|
|
const db = request.result;
|
|
if (blocked) { db.close(); return; }
|
|
db.onversionchange = () => { db.close(); database = undefined; };
|
|
resolve(db);
|
|
};
|
|
}).catch((error) => { database = undefined; throw error; });
|
|
return database;
|
|
}
|
|
|
|
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 {
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
function deleteCache(db: IDBDatabase, cacheId: string) {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const transaction = db.transaction(["headers", "chunks"], "readwrite");
|
|
transaction.oncomplete = () => resolve();
|
|
transaction.onabort = () => reject(transaction.error);
|
|
transaction.objectStore("headers").delete(cacheId);
|
|
transaction.objectStore("chunks").delete(IDBKeyRange.bound([cacheId], [cacheId, []]));
|
|
});
|
|
}
|
|
|
|
/** 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"
|
|
? crypto.randomUUID()
|
|
: `${Date.now()}-${sequence}-${Math.random().toString(36).slice(2)}`;
|
|
pendingSaves++;
|
|
let db: IDBDatabase | undefined;
|
|
let committed = false;
|
|
let writeStarted = false;
|
|
try {
|
|
// 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;
|
|
const columns = Object.entries(snapshot.result.series);
|
|
const lengths: Record<string, number> = Object.create(null);
|
|
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;
|
|
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 a snapshot loaded from another tab.
|
|
const previous = ownedCacheId;
|
|
ownedCacheId = cacheId;
|
|
if (previous) void deleteCache(db, previous).catch(() => {});
|
|
return true;
|
|
} finally {
|
|
pendingSaves--;
|
|
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;
|
|
const value = JSON.parse(raw);
|
|
// Read legacy sessionStorage data without rewriting or duplicating it at startup.
|
|
if (value?.storage !== "indexeddb") return value;
|
|
if (value.version !== 1 || typeof value.cacheId !== "string") return null;
|
|
const db = await openDatabase();
|
|
return new Promise<SimulationResultsSnapshot | null>((resolve, reject) => {
|
|
const transaction = db.transaction(["headers", "chunks"], "readonly");
|
|
let result: SimulationResultsSnapshot | 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; }
|
|
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;
|
|
}
|
|
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; }
|
|
};
|
|
});
|
|
}
|