perf(canvas): reuse immutable recovery encoding subtrees
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import type { Json } from '../canvas-core/types.ts';
|
||||
import { fault, freeze, validJson } from '../canvas-core/values.ts';
|
||||
import type { RecoveryImage } from './storage.ts';
|
||||
|
||||
type Encoded = {value:Json|undefined;height:number;reusable:boolean};
|
||||
const immutable=new WeakMap<object,Encoded>(),sealed=new WeakSet<object>();
|
||||
|
||||
/** JSON value semantics, with reuse only for transitively frozen plain data properties. */
|
||||
export function encodeRecoveryValue(input:unknown):Json {
|
||||
const visiting=new Set<object>();
|
||||
function visit(value:unknown,depth:number,key:string):Encoded {
|
||||
if(value===null||typeof value==='string'||typeof value==='boolean'){if(depth>64)throw fault('invalid_recovery_payload');return{value,height:0,reusable:true};}
|
||||
if(typeof value==='number'){if(depth>64)throw fault('invalid_recovery_payload');return{value:Number.isFinite(value)?(Object.is(value,-0)?0:value):null,height:0,reusable:true};}
|
||||
if(value===undefined||typeof value==='symbol')return{value:undefined,height:0,reusable:true};
|
||||
const fallback=()=>{
|
||||
// Preserve the previous JSON codec for toJSON, boxed values and custom prototypes.
|
||||
// These can depend on hidden mutable state and are deliberately never cached.
|
||||
const text=JSON.stringify({[key]:value}),encoded=JSON.parse(text)[key] as Json|undefined;
|
||||
if(encoded!==undefined&&!validJson(encoded,depth))throw fault('invalid_recovery_payload');
|
||||
return{value:freeze(encoded),height:0,reusable:false};
|
||||
};
|
||||
if(typeof value==='bigint'||typeof value==='function')return fallback();
|
||||
if(typeof value!=='object')throw fault('invalid_recovery_payload');
|
||||
const object=value as object,cached=immutable.get(object);
|
||||
if(cached){if(depth+cached.height>64)throw fault('invalid_recovery_payload');return cached;}
|
||||
const prototype=Object.getPrototypeOf(object);
|
||||
// A callable toJSON getter must also be evaluated afresh on every encode.
|
||||
if(typeof (object as {toJSON?:unknown}).toJSON==='function'||(Array.isArray(object)?prototype!==Array.prototype&&prototype!==null:prototype!==Object.prototype&&prototype!==null))return fallback();
|
||||
if(depth>64)throw fault('invalid_recovery_payload');
|
||||
if(visiting.has(object))throw fault('invalid_recovery_payload');visiting.add(object);
|
||||
const toJSON=Object.getOwnPropertyDescriptor(object,'toJSON');
|
||||
let reusable=Object.isFrozen(object)&&(!toJSON||Object.hasOwn(toJSON,'value')),height=0;
|
||||
try {
|
||||
if(Array.isArray(object)){
|
||||
const result:Json[]=[];
|
||||
for(let index=0;index<object.length;index++){
|
||||
const descriptor=Object.getOwnPropertyDescriptor(object,String(index));if(!descriptor||!Object.hasOwn(descriptor,'value'))reusable=false;
|
||||
const child=visit(object[index],depth+1,String(index));if(child.value===undefined&&depth+1>64)throw fault('invalid_recovery_payload');result.push(child.value??null);height=Math.max(height,1+child.height);reusable&&=child.reusable;
|
||||
}
|
||||
const encoded={value:Object.freeze(result) as unknown as Json,height,reusable};if(reusable)immutable.set(object,encoded);return encoded;
|
||||
}
|
||||
const result:Record<string,Json>={};
|
||||
for(const name of Object.keys(object)){
|
||||
const descriptor=Object.getOwnPropertyDescriptor(object,name);if(!descriptor||!Object.hasOwn(descriptor,'value'))reusable=false;
|
||||
const child=visit((object as Record<string,unknown>)[name],depth+1,name);reusable&&=child.reusable;
|
||||
if(child.value!==undefined){Object.defineProperty(result,name,{value:child.value,enumerable:true});height=Math.max(height,1+child.height);}
|
||||
}
|
||||
const encoded={value:Object.freeze(result) as Json,height,reusable};if(reusable)immutable.set(object,encoded);return encoded;
|
||||
}finally{visiting.delete(object);}
|
||||
}
|
||||
const result=visit(input,0,'');if(result.value===undefined)throw fault('invalid_recovery_payload');return result.value;
|
||||
}
|
||||
|
||||
/** Only codec-owned, validated immutable images qualify for the storage copy fast path. */
|
||||
export function sealRecoveryImage(formatVersion:1|2,rootRevision:number,localRevision:number,payload:unknown):RecoveryImage {
|
||||
if(![1,2].includes(formatVersion)||!Number.isSafeInteger(rootRevision)||rootRevision<0||!Number.isSafeInteger(localRevision)||localRevision<0||localRevision>rootRevision)throw fault('corrupt_recovery_image');
|
||||
const image=Object.freeze({formatVersion,rootRevision,localRevision,payload:encodeRecoveryValue(payload)});sealed.add(image);return image;
|
||||
}
|
||||
export function isSealedRecoveryImage(image:RecoveryImage):boolean{return sealed.has(image);}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sealRecoveryImage } from './recovery-encoding.ts';
|
||||
import { validateImportJob, type ImportJob } from './imports.ts';
|
||||
import { validateElisions, withLocalLifecycles } from './elision.ts';
|
||||
import type { Change, CoreSnapshot, Document, HistoryEntry, HistoryState, Id, Json, OperationIntent, Registry } from '../canvas-core/types.ts';
|
||||
@@ -15,14 +16,13 @@ import type { RecoveryImage } from './storage.ts';
|
||||
const documentValue = (document: Document) => ({ projectId: document.projectId, canvases: [...document.canvases.values()], elements: [...document.elements.values()], tombstones: [...document.tombstones] });
|
||||
/** Plain records only: functions, credentials, renderer objects, URLs and Map methods never serialize. */
|
||||
export function encodeRecovery(snapshot: EngineSnapshot): RecoveryImage {
|
||||
const payload = JSON.parse(JSON.stringify({
|
||||
const payload = {
|
||||
document: documentValue(snapshot.document),
|
||||
history: { undo: snapshot.history.undo, redo: snapshot.history.redo, entries: [...snapshot.history.entries] },
|
||||
operations: [...snapshot.operations],
|
||||
runtime: { imports:[...(snapshot.runtime.imports??[])], recoveries:[...(snapshot.runtime.recoveries??[])], runs: [...snapshot.runtime.runs], resources: [...snapshot.runtime.resources], resourcePins: [...snapshot.runtime.resourcePins], acknowledged: documentValue(snapshot.runtime.acknowledged), versions: [...snapshot.runtime.versions], receipts: [...snapshot.runtime.receipts], conflicts: [...snapshot.runtime.conflicts], ledger: { ...snapshot.runtime.ledger, operations: [...snapshot.runtime.ledger.operations], requests: [...snapshot.runtime.ledger.requests] } },
|
||||
})) as Json;
|
||||
if (!validJson(payload)) throw fault('invalid_recovery_payload');
|
||||
return freeze({ formatVersion: snapshot.runtime.imports?.size ? 2 : 1, rootRevision: snapshot.rootRevision, localRevision: snapshot.revision, payload });
|
||||
};
|
||||
return sealRecoveryImage(snapshot.runtime.imports?.size ? 2 : 1,snapshot.rootRevision,snapshot.revision,payload);
|
||||
}
|
||||
type ObjectValue = Record<string, unknown>;
|
||||
function object(value: unknown): ObjectValue { if (!value || Array.isArray(value) || typeof value !== 'object') throw fault('corrupt_recovery_image'); return value as ObjectValue; }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isSealedRecoveryImage } from './recovery-encoding.ts';
|
||||
import type { Json } from '../canvas-core/types.ts';
|
||||
import { clone, equal, fault, validJson } from '../canvas-core/values.ts';
|
||||
|
||||
@@ -117,7 +118,7 @@ export function createBrowserStorage(options: { indexedDB?: IDBFactory; locks?:
|
||||
},
|
||||
async commit(leader: StorageLeader, batch: DurableBatch) {
|
||||
// Copy before awaiting: callers cannot change the durable cut while a transaction queues.
|
||||
const image = clone(batch.image), files = [...(batch.files ?? [])]; validateImage(image);
|
||||
const image = isSealedRecoveryImage(batch.image) ? batch.image : clone(batch.image), files = (batch.files ?? []).map(file => ({id:file.id,bytes:file.bytes})); if(!isSealedRecoveryImage(image))validateImage(image);
|
||||
const stored: StoredImage = { image, checksum: await checksum(image) };
|
||||
return execute(leader, ['checkpoints', 'journal', 'files'], 'readwrite', async (transaction, metadata, key) => {
|
||||
const previous = metadata.throughRevision;
|
||||
|
||||
Reference in New Issue
Block a user