Files
workinf_Blender_Wasm/web/protocol/simulation-cache.ts
mes123456 0fe8d2bb56
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Advance M8-M11 parity workflows
2026-08-17 04:37:07 -04:00

320 lines
13 KiB
TypeScript

import type { ErrorCode } from "./error";
export const SIMULATION_CACHE_SCHEMA = 2 as const;
export const SIMULATION_CACHE_BLENDER_VERSION_PREFIX = "5.2." as const;
export const SIMULATION_CACHE_BUDGET = {
maxCacheBytes: 16 * 1024 * 1024 * 1024,
maxProjectCacheBytes: 16 * 1024 * 1024 * 1024,
maxFrameBytes: 512 * 1024 * 1024,
maxFrames: 100_000,
} as const;
export interface SimulationCacheLRUCandidateIR {
cacheKey: string;
byteLength: number;
createdAt: string;
lastAccessAt: string;
}
export interface SimulationCacheLRUPlanIR {
maxBytes: number;
beforeBytes: number;
remainingBytes: number;
removedBytes: number;
cacheKeys: string[];
protectedCacheKeys: string[];
budgetSatisfied: boolean;
}
export interface SimulationCacheFrameIR {
frame: number;
byteOffset: number;
byteLength: number;
sha256: string;
}
export interface SimulationCacheManifestIR {
schemaVersion: typeof SIMULATION_CACHE_SCHEMA;
graphId: string;
graphHash: string;
sourceBlendSha256: string;
sourceRevision: number;
inputHash: string;
revisionHash: string;
cacheSha256: string;
blenderVersion: string;
frameStart: number;
frameEnd: number;
byteLength: number;
frames: SimulationCacheFrameIR[];
}
export interface SimulationCacheRevisionBindingIR {
graphId: string;
graphHash: string;
sourceBlendSha256: string;
sourceRevision: number;
inputHash: string;
blenderVersion: string;
frameStart: number;
frameEnd: number;
}
export class SimulationCacheValidationError extends Error {
readonly code: ErrorCode;
constructor(code: ErrorCode, message: string) {
super(message);
this.name = "SimulationCacheValidationError";
this.code = code;
}
}
const SHA256 = /^[a-f0-9]{64}$/;
const CACHE_KEY = /^sim2-[a-f0-9]{64}$/;
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function integer(value: unknown, name: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} must be an integer from ${minimum} to ${maximum}`);
}
return value;
}
function digest(value: unknown, name: string): string {
if (typeof value !== "string" || !SHA256.test(value)) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} must be a lowercase SHA-256 digest`);
}
return value;
}
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], name: string): void {
const allowedSet = new Set(allowed);
if (Object.keys(value).some((key) => !allowedSet.has(key))) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} contains undeclared fields`);
}
}
function boundedText(value: unknown, name: string, maximumBytes: number): string {
if (typeof value !== "string" || value.length === 0 || new TextEncoder().encode(value).byteLength > maximumBytes) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `${name} is outside its text budget`);
}
return value;
}
function revisionBindingText(binding: SimulationCacheRevisionBindingIR): string {
return JSON.stringify([
"blender-web-simulation-cache-revision-v2",
binding.graphId,
binding.graphHash,
binding.sourceBlendSha256,
String(binding.sourceRevision),
binding.inputHash,
binding.blenderVersion,
String(binding.frameStart),
String(binding.frameEnd),
]);
}
export async function computeSimulationCacheRevisionHash(
binding: SimulationCacheRevisionBindingIR,
): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(revisionBindingText(binding)));
return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("");
}
export function parseSimulationCacheManifest(value: unknown): SimulationCacheManifestIR {
if (!record(value) || value.schemaVersion !== SIMULATION_CACHE_SCHEMA) {
throw new SimulationCacheValidationError("PROTOCOL_MISMATCH", "Unsupported SimulationCache manifest schema");
}
exactKeys(value, [
"schemaVersion", "graphId", "graphHash", "sourceBlendSha256", "sourceRevision",
"inputHash", "revisionHash", "cacheSha256", "blenderVersion", "frameStart",
"frameEnd", "byteLength", "frames",
], "manifest");
const graphId = boundedText(value.graphId, "graphId", 256);
const blenderVersion = boundedText(value.blenderVersion, "blenderVersion", 64);
if (!blenderVersion.startsWith(SIMULATION_CACHE_BLENDER_VERSION_PREFIX)) {
throw new SimulationCacheValidationError("PROTOCOL_MISMATCH", `Simulation cache requires Blender ${SIMULATION_CACHE_BLENDER_VERSION_PREFIX}x`);
}
const sourceRevision = integer(value.sourceRevision, "sourceRevision");
const frameStart = integer(value.frameStart, "frameStart", -1_000_000, 1_000_000);
const frameEnd = integer(value.frameEnd, "frameEnd", -1_000_000, 1_000_000);
const byteLength = integer(value.byteLength, "byteLength", 1);
if (byteLength > SIMULATION_CACHE_BUDGET.maxCacheBytes) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_BUDGET_EXCEEDED", "Simulation cache exceeds the byte budget");
}
if (frameEnd < frameStart || !Array.isArray(value.frames)) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation frame range is invalid");
}
if (frameEnd - frameStart + 1 > SIMULATION_CACHE_BUDGET.maxFrames) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_BUDGET_EXCEEDED", "Simulation frame range exceeds the budget");
}
if (value.frames.length !== frameEnd - frameStart + 1) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", "Simulation cache must contain every declared frame");
}
let nextOffset = 0;
const frames = value.frames.map((item, index): SimulationCacheFrameIR => {
if (!record(item)) throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `frames[${index}] is invalid`);
exactKeys(item, ["frame", "byteOffset", "byteLength", "sha256"], `frames[${index}]`);
const frame = integer(item.frame, `frames[${index}].frame`, -1_000_000, 1_000_000);
const byteOffset = integer(item.byteOffset, `frames[${index}].byteOffset`);
const frameByteLength = integer(item.byteLength, `frames[${index}].byteLength`, 1);
if (frameByteLength > SIMULATION_CACHE_BUDGET.maxFrameBytes) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_BUDGET_EXCEEDED", `frames[${index}] exceeds the byte budget`);
}
if (frame !== frameStart + index || byteOffset !== nextOffset || byteOffset > byteLength - frameByteLength) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `frames[${index}] is not contiguous or ordered`);
}
nextOffset += frameByteLength;
return { frame, byteOffset, byteLength: frameByteLength, sha256: digest(item.sha256, `frames[${index}].sha256`) };
});
if (nextOffset !== byteLength) throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Simulation frame ranges do not cover the cache payload");
return {
schemaVersion: SIMULATION_CACHE_SCHEMA,
graphId,
graphHash: digest(value.graphHash, "graphHash"),
sourceBlendSha256: digest(value.sourceBlendSha256, "sourceBlendSha256"),
sourceRevision,
inputHash: digest(value.inputHash, "inputHash"),
revisionHash: digest(value.revisionHash, "revisionHash"),
cacheSha256: digest(value.cacheSha256, "cacheSha256"),
blenderVersion,
frameStart,
frameEnd,
byteLength,
frames,
};
}
export async function verifySimulationCacheRevisionBinding(
manifestValue: unknown,
): Promise<SimulationCacheManifestIR> {
const manifest = parseSimulationCacheManifest(manifestValue);
const computed = await computeSimulationCacheRevisionHash(manifest);
if (computed !== manifest.revisionHash) {
throw new SimulationCacheValidationError(
"SIMULATION_CACHE_REVISION_MISMATCH",
"Simulation cache revision hash does not match its graph, source, revision, inputs, and frame range",
);
}
return manifest;
}
async function sha256(data: ArrayBuffer): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash), (value) => value.toString(16).padStart(2, "0")).join("");
}
export async function verifySimulationCache(manifestValue: unknown, data: ArrayBuffer): Promise<SimulationCacheManifestIR> {
return verifySimulationCacheCancellable(manifestValue, data);
}
export async function verifySimulationCacheCancellable(
manifestValue: unknown,
data: ArrayBuffer,
checkCancelled: () => void = () => undefined,
): Promise<SimulationCacheManifestIR> {
checkCancelled();
const manifest = await verifySimulationCacheRevisionBinding(manifestValue);
checkCancelled();
if (data.byteLength !== manifest.byteLength || await sha256(data) !== manifest.cacheSha256) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", "Simulation cache payload does not match its manifest");
}
checkCancelled();
for (const frame of manifest.frames) {
const bytes = data.slice(frame.byteOffset, frame.byteOffset + frame.byteLength);
if (await sha256(bytes) !== frame.sha256) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", `Simulation frame ${frame.frame} failed SHA-256 verification`);
}
checkCancelled();
}
return manifest;
}
export function selectSimulationCacheFrame(manifestValue: unknown, frame: number): SimulationCacheFrameIR {
const manifest = parseSimulationCacheManifest(manifestValue);
if (!Number.isSafeInteger(frame) || frame < manifest.frameStart || frame > manifest.frameEnd) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", `Simulation cache has no frame ${frame}`);
}
const selected = manifest.frames[frame - manifest.frameStart];
if (!selected || selected.frame !== frame) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_MISSING", `Simulation cache has no frame ${frame}`);
}
return selected;
}
export async function verifySimulationCacheFrame(
manifestValue: unknown,
frame: number,
data: ArrayBuffer,
): Promise<SimulationCacheFrameIR> {
const manifest = await verifySimulationCacheRevisionBinding(manifestValue);
const selected = selectSimulationCacheFrame(manifest, frame);
if (data.byteLength !== selected.byteLength || await sha256(data) !== selected.sha256) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_HASH_MISMATCH", `Simulation frame ${frame} failed SHA-256 verification`);
}
return selected;
}
export function simulationCacheKey(manifest: SimulationCacheManifestIR): string {
return `sim2-${manifest.revisionHash}`;
}
export function planSimulationCacheLRU(
candidatesValue: readonly SimulationCacheLRUCandidateIR[],
maxBytesValue: number,
protectedCacheKeysValue: readonly string[] = [],
): SimulationCacheLRUPlanIR {
const maxBytes = integer(maxBytesValue, "maxBytes", 0, SIMULATION_CACHE_BUDGET.maxProjectCacheBytes);
const seen = new Set<string>();
const candidates = candidatesValue.map((candidate, index) => {
if (!record(candidate) || !CACHE_KEY.test(candidate.cacheKey) || seen.has(candidate.cacheKey)) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `LRU candidate ${index} has an invalid or duplicate cache key`);
}
seen.add(candidate.cacheKey);
const byteLength = integer(candidate.byteLength, `LRU candidate ${index} byteLength`, 1, SIMULATION_CACHE_BUDGET.maxCacheBytes);
for (const field of ["createdAt", "lastAccessAt"] as const) {
if (typeof candidate[field] !== "string" || !Number.isFinite(Date.parse(candidate[field]))) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", `LRU candidate ${index} ${field} is invalid`);
}
}
return { ...candidate, byteLength };
});
const protectedCacheKeys = [...new Set(protectedCacheKeysValue)].sort();
if (protectedCacheKeys.some((cacheKey) => !CACHE_KEY.test(cacheKey))) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_INVALID", "Protected Simulation cache key is invalid");
}
const protectedSet = new Set(protectedCacheKeys);
const beforeBytes = candidates.reduce((total, candidate) => {
const next = total + candidate.byteLength;
if (!Number.isSafeInteger(next)) {
throw new SimulationCacheValidationError("SIMULATION_CACHE_BUDGET_EXCEEDED", "Simulation cache LRU byte total exceeds the safe integer range");
}
return next;
}, 0);
let remainingBytes = beforeBytes;
const cacheKeys: string[] = [];
const removable = candidates.filter((candidate) => !protectedSet.has(candidate.cacheKey)).sort((left, right) =>
left.lastAccessAt.localeCompare(right.lastAccessAt) ||
left.createdAt.localeCompare(right.createdAt) ||
left.cacheKey.localeCompare(right.cacheKey));
for (const candidate of removable) {
if (remainingBytes <= maxBytes) break;
cacheKeys.push(candidate.cacheKey);
remainingBytes -= candidate.byteLength;
}
return {
maxBytes,
beforeBytes,
remainingBytes,
removedBytes: beforeBytes - remainingBytes,
cacheKeys,
protectedCacheKeys,
budgetSatisfied: remainingBytes <= maxBytes,
};
}