Reuse validated immutable history ranges during resource retention

This commit is contained in:
2026-11-11 01:56:43 +08:00
parent 26d1688f2f
commit 72a8e01114
5 changed files with 90 additions and 4 deletions
+15
View File
@@ -1028,3 +1028,18 @@ latest value and retained focus. These frame opportunities exclude time before
the browser dispatches the input event; they are not end-to-end key latency or
proof that every intermediate composition string reached the display. Mixed
workload timings must be kept separate from the no-input copy baseline.
Resource retention reuses flattened history-transition changes across durability
cuts only after successful validation and a descriptor-based proof of immutable
plain data. Proofs are weakly held. Every own property must be a data property;
shallow freezing, accessors, custom prototypes, sparse arrays and hidden cycles
do not qualify. Cached ranges are frozen. Mutable evidence remains live and is
validated on every read. This cache contains neither resource IDs nor schema
results: each scan still applies the current registry and keeps working/ACK
owners separate. Explicit `validateHistoryTransition(..., checkChanges)` calls
always execute the caller's validator. No persistence/wire format changes.
CPU/allocation diagnostics found repeated history-transition validation under
resource retention. The production mixed-input probe and a same-machine old
history-path control both pass, but observed total runtimes are similar; this
change demonstrates reduced repeated interpretation, not an end-to-end speedup.
+26 -1
View File
@@ -5,6 +5,24 @@ type Splice<T>={readonly offset:number;readonly beforeLength:number;readonly aft
type Meta={readonly label:string;readonly groupId?:Id};
type EntryPatch={readonly id:Id;readonly before:Meta|null;readonly after:Meta|null;readonly changes:Splice<Change>;readonly operationIds:Splice<Id>};
export type HistoryTransition={readonly version:1;readonly rootRevision:number;readonly entryId?:Id;readonly before:{readonly undo:readonly Id[];readonly redo:readonly Id[]};readonly after:{readonly undo:readonly Id[];readonly redo:readonly Id[]};readonly entries:readonly EntryPatch[]};
// Resource scans revisit the same transition across many durability cuts. Cache
// only its flattened changes, never registry-dependent references or callbacks.
const transitionChanges=new WeakMap<HistoryTransition,readonly Change[]>();
const immutableData=new WeakSet<object>();
function immutablePlainData(value:unknown,depth=0):boolean{
if(depth>64)return false;
if(value===null||typeof value!=='object')return typeof value!=='function';
if(immutableData.has(value))return true;
if(!Object.isFrozen(value)||Object.getPrototypeOf(value)!==(Array.isArray(value)?Array.prototype:Object.prototype))return false;
if(Array.isArray(value))for(let i=0;i<value.length;i++)if(!Object.hasOwn(value,i))return false;
// Inspect every own descriptor, including non-enumerable properties. A frozen
// getter or a shallow-frozen container cannot establish reusable data.
for(const key of Reflect.ownKeys(value)){
const descriptor=Object.getOwnPropertyDescriptor(value,key)!;
if(!Object.hasOwn(descriptor,'value')||!immutablePlainData(descriptor.value,depth+1))return false;
}
immutableData.add(value);return true;
}
const meta=(entry:HistoryEntry|undefined):Meta|null=>{if(!entry)return null;const{id:_id,changes:_changes,operationIds:_operations,...remaining}=entry;return remaining;};
function splice<T>(before:readonly T[],after:readonly T[]):Splice<T>{
let offset=0,suffix=0;while(offset<before.length&&offset<after.length&&equal(before[offset],after[offset]))offset++;
@@ -43,4 +61,11 @@ export function applyHistoryTransition(history:HistoryState,transition:HistoryTr
const ids=[...destination.undo,...destination.redo];if(ids.length!==entries.size||ids.some(id=>!entries.has(id)))throw fault('history_transition_conflict');
return Object.freeze({undo:freeze([...destination.undo]),redo:freeze([...destination.redo]),entries:readMap(entries)});
}
export function historyTransitionChanges(transition:HistoryTransition):readonly Change[]{validateHistoryTransition(transition);return transition.entries.flatMap(patch=>[...patch.changes.before,...patch.changes.after]);}
export function historyTransitionChanges(transition:HistoryTransition):readonly Change[]{
const cached=transitionChanges.get(transition);if(cached)return cached;
validateHistoryTransition(transition);
const changes=transition.entries.flatMap(patch=>[...patch.changes.before,...patch.changes.after]);
const ownRanges=Object.hasOwn(transition,'entries')&&transition.entries.every(patch=>Object.hasOwn(patch,'changes')&&Object.hasOwn(patch.changes,'before')&&Object.hasOwn(patch.changes,'after'));
if(!ownRanges||!immutablePlainData(transition))return changes;
const result=Object.freeze(changes);transitionChanges.set(transition,result);return result;
}
+2 -1
View File
@@ -81,7 +81,8 @@ export function createResourcePinScan(history:HistoryState,ledger:LedgerSnapshot
for(const entry of history.entries.values())rememberTargets(entry.changes);
let interpreted=true;
for(const record of ledger.operations.values()){
// Validate every understood transition, including resolved evidence, as before.
// Obtain validated ranges for every understood transition, including resolved
// evidence. The core may reuse only proven immutable transition data.
if(record.intent.historyTransition?.version===1)transitions.set(record,historyTransitionChanges(record.intent.historyTransition));
if(record.phase!=='resolved')rememberTargets(record.intent.changes);
if(record.intent.historyEffect&&record.intent.historyEffect.version!==1)interpreted=false;
@@ -97,3 +97,39 @@ test('history-disabled replacement evidence cannot be omitted merely because the
test('adoption evidence preserves replacement sequence even when all history transitions are no-ops',async()=>{
const f=await fixture(),engine=f.engine;try{edit(engine.editor,'first',{history:false});await engine.settle();engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'second'}},{history:false});await engine.settle();const session=await engine.prepareRecovery(),plan=session.plan(new Map([...engine.getSnapshot().runtime.ledger.operations].map(([id])=>[id,'keep-local'])));await session.resolve(plan);await engine.settle();const image=f.image();assert.equal(image.payload.runtime.recoveries[0][1].historyReplay.events.length,2);image.payload.runtime.recoveries[0][1].historyReplay.events.reverse();assert.throws(()=>decodeRecovery(image,registry),/corrupt_recovery_image/);}finally{await engine.close();}
});
test('resource scans reuse validated immutable transition ranges without skipping caller validation',()=>{
const editor=createEditor({registry,document});edit(editor,'cached');
const transition=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
const first=historyTransitionChanges(transition),second=historyTransitionChanges(transition);
assert.equal(first,second);assert.ok(Object.isFrozen(first));assert.throws(()=>first.push(first[0]),TypeError);
let checked=0;validateHistoryTransition(transition,()=>{checked++;});assert.equal(checked,2*transition.entries.length);
assert.throws(()=>validateHistoryTransition(transition,()=>{throw Error('current schema rejected');}),/current schema rejected/);
});
test('mutable, shallow frozen and accessor transition evidence remains live across resource scans',()=>{
const editor=createEditor({registry,document});edit(editor,'source');
const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
const mutable=structuredClone(original),shallow=Object.freeze(structuredClone(original));
for(const transition of [mutable,shallow]){
const first=historyTransitionChanges(transition);transition.entries[0].changes.after[0]={...first[0],after:'changed'};
const second=historyTransitionChanges(transition);assert.notEqual(first,second);assert.equal(second[0].after,'changed');
transition.entries[0].changes.offset=999;assert.throws(()=>historyTransitionChanges(transition),/invalid_history_transition/);
}
let current=structuredClone(original).entries;
const accessor=Object.freeze({...original,get entries(){return current;}});
const first=historyTransitionChanges(accessor);current=structuredClone(original).entries;current[0].changes.after[0]={...first[0],after:'getter changed'};
assert.equal(historyTransitionChanges(accessor)[0].after,'getter changed');
});
test('hidden frozen cycles and sparse transition data do not qualify for cached ranges',()=>{
const editor=createEditor({registry,document});edit(editor,'source');
const original=[...editor.getSnapshot().operations.values()].at(-1).historyTransition;
const cycle={};Object.defineProperty(cycle,'hidden',{value:cycle});Object.freeze(cycle);
// Non-enumerable metadata is outside JSON validation but must not recurse forever.
const hidden={...original};Object.defineProperty(hidden,'hidden',{value:cycle});Object.freeze(hidden);
assert.notEqual(historyTransitionChanges(hidden),historyTransitionChanges(hidden));
const sparse=Object.freeze([,]);const withSparse={...original};Object.defineProperty(withSparse,'hidden',{value:sparse});Object.freeze(withSparse);
assert.notEqual(historyTransitionChanges(withSparse),historyTransitionChanges(withSparse));
});
+11 -2
View File
@@ -4,9 +4,9 @@ import {createDocument,createRegistry,builtinComponents,describeHistoryTransitio
import {createResourcePinScan,resourcePins} from '../../src/lib/canvas-runtime/resources.ts';
import {resourceRetentionPins} from '../../src/lib/canvas-runtime/resource-retention.ts';
const registry=createRegistry(builtinComponents()),empty={undo:[],redo:[],entries:new Map()},node=(id,asset)=>({id,incarnation:1,ownerCanvasId:'root',componentId:asset?'image':'text',dataVersion:1,structure:'node',geometry:{x:0,y:0,width:100,height:100,rotation:0},data:{title:id,text:'',...(asset?{assetId:asset}:{})}}),doc=(records=[])=>createDocument('p',[{id:'root',parent:null}],records);
test('one retention pass interprets historical evidence once for working and ACK while preserving their separate owners',()=>{
test('immutable history ranges are reused across retention passes while working and ACK keep separate owners',()=>{
const entry={id:'h',label:'h',changes:[{kind:'element',id:'past',before:null,after:node('past','historical')}],operationIds:[]},raw=describeHistoryTransition(empty,{undo:['h'],redo:[],entries:new Map([['h',entry]])},1);let reads=0;const transition=new Proxy(raw,{get(target,key,receiver){if(key==='entries')reads++;return Reflect.get(target,key,receiver);}}),ledger={operations:new Map([['o',{phase:'queued',intent:{id:'o',sequence:1,operationType:'document.change',changes:[],historyTransition:transition}}]])},scan=createResourcePinScan(empty,ledger,registry),preparationReads=reads;assert.ok(preparationReads>0);scan(doc([node('text')]));scan(doc([node('remote','ack')]));assert.equal(reads,preparationReads);
const snapshot={document:doc([node('text')]),history:empty,runtime:{ledger,acknowledged:doc([node('remote','ack')]),resourcePins:new Map(),resources:new Map(),runs:new Map()}};reads=0;const result=resourceRetentionPins(snapshot,registry);assert.equal(reads,preparationReads);assert.equal(result.complete,true);assert.deepEqual([...result.pins.get('historical')],['history-transition:o']);assert.deepEqual([...result.pins.get('ack')],['acknowledged:element:remote:1']);
const snapshot={document:doc([node('text')]),history:empty,runtime:{ledger,acknowledged:doc([node('remote','ack')]),resourcePins:new Map(),resources:new Map(),runs:new Map()}};reads=0;const result=resourceRetentionPins(snapshot,registry);assert.equal(reads,0);assert.equal(result.complete,true);assert.deepEqual([...result.pins.get('historical')],['history-transition:o']);assert.deepEqual([...result.pins.get('ack')],['acknowledged:element:remote:1']);
});
test('historical data fields retain schemas from resolved creations, conservative fallback pins and explicit owner order',()=>{
const field={kind:'field',target:{id:'deleted',incarnation:1},field:'data.assetId',before:'old',after:'new'},history={undo:['h'],redo:[],entries:new Map([['h',{id:'h',label:'h',changes:[field],operationIds:[]}]])},ledger={operations:new Map([['resolved',{phase:'resolved',intent:{id:'resolved',sequence:1,operationType:'document.change',changes:[{kind:'element',id:'deleted',before:null,after:node('deleted','unreferenced')}]} }]])},snapshot={document:doc(),history},result=resourcePins(snapshot,ledger,registry,new Map([['explicit',['old','extra']]]));assert.equal(result.complete,true);assert.deepEqual([...result.pins].map(([id,owners])=>[id,[...owners]]),[['old',['history:h','explicit']],['new',['history:h']],['extra',['explicit']]]);ledger.operations.clear();assert.equal(resourcePins(snapshot,ledger,registry,new Map()).complete,false);
@@ -14,3 +14,12 @@ test('historical data fields retain schemas from resolved creations, conservativ
test('new passes reread mutable input membership and still reject malformed resolved evidence with no resource fields',()=>{
const history={undo:[],redo:[],entries:new Map()},ledger={operations:new Map()},snapshot={document:doc(),history};assert.equal(resourcePins(snapshot,ledger,registry,new Map()).pins.size,0);history.entries.set('h',{id:'h',label:'h',changes:[{kind:'element',id:'image',before:null,after:node('image','late')}],operationIds:[]});assert.ok(resourcePins(snapshot,ledger,registry,new Map()).pins.has('late'));ledger.operations.set('bad',{phase:'resolved',intent:{id:'bad',sequence:1,changes:[],historyTransition:{version:1,rootRevision:-1,before:{undo:[],redo:[]},after:{undo:[],redo:[]},entries:[]}}});assert.throws(()=>resourcePins(snapshot,ledger,registry,new Map()),/invalid_history_transition/);
});
test('cached immutable transition ranges still use each current registry resource definition',()=>{
const element={...node('historical','first'),data:{...node('historical','first').data,otherAsset:'second'}},entry={id:'h',label:'h',changes:[{kind:'element',id:element.id,before:null,after:element}],operationIds:[]};
const transition=describeHistoryTransition(empty,{undo:['h'],redo:[],entries:new Map([['h',entry]])},1),ledger={operations:new Map([['o',{phase:'queued',intent:{id:'o',sequence:1,operationType:'document.change',changes:[],historyTransition:transition}}]])};
const otherRegistry=createRegistry(builtinComponents().map(component=>component.id==='image'?{...component,resourceFields:['otherAsset']}:component));
const first=createResourcePinScan(empty,ledger,registry)(doc()),second=createResourcePinScan(empty,ledger,otherRegistry)(doc());
assert.deepEqual([...first.pins.keys()],['first']);assert.deepEqual([...second.pins.keys()],['second']);assert.equal(first.complete,true);assert.equal(second.complete,true);
});