Reuse validated immutable history ranges during resource retention
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user