Slice pending import encoding and reuse completed immutable plans
This commit is contained in:
+12
-1
@@ -1005,7 +1005,8 @@ subtrees, first-time immutable normalization and individual scalar operations
|
||||
remain synchronous. This is partial time slicing, not a strict whole-Root latency
|
||||
bound. Grouped history entries now keep shape ownership for newly built changes and
|
||||
operation-ID arrays, including undo/redo lifecycle remaps. Import runners keep it
|
||||
for their own job envelopes, completed-key arrays and binding dictionaries; newly
|
||||
for their own job envelopes, completed-key arrays, binding dictionaries and
|
||||
pending locator tables; newly
|
||||
allocated identity tables use the same private constructor. The engine's source
|
||||
proof refresh preserves this envelope ownership. Arbitrary host checkpoint
|
||||
replacements keep defensive capture. These constructors still freeze nested data
|
||||
@@ -1125,3 +1126,13 @@ and is not a precise browser queue-time measurement. The final server document
|
||||
must retain the original's later edits while the copy retains its earlier source
|
||||
snapshot. Smaller scenarios retain the early burst because they can finish
|
||||
before a later publication threshold is observed.
|
||||
|
||||
Pending import records remain bounded seekable locators, not decoded document
|
||||
payloads. Runner-created locators, dependency arrays, parser appends and filtered
|
||||
pending tables retain private shape ownership, so recovery encoding can slice
|
||||
these waiting records even when endpoints have not arrived. Dependency admission,
|
||||
capacity checks and durable send barriers are unchanged. The asynchronous encoder
|
||||
also rechecks its immutable fact cache when assembling a prepared container:
|
||||
when the same source occurs twice in one image, the second occurrence can reuse
|
||||
the first completed encoding. Preparation still captures unsafe occurrences
|
||||
independently; getters and mutable descendants never qualify for this reuse.
|
||||
|
||||
@@ -162,7 +162,7 @@ export function createImportRunner(ports:ImportRunnerPorts){
|
||||
function completed(job:ImportJob,units:readonly ImportUnit[],pending:readonly ImportLocator[]){
|
||||
const bindings:Record<Id,ImportBinding>=Object.assign(Object.create(null),job.bindings??{});
|
||||
for(const unit of units)for(const change of unit.changes)if(change.kind==='element'&&change.before===null&&change.after)bindings[change.id]={kind:'element',incarnation:change.after.incarnation};else if(change.kind==='canvas'&&change.before===null&&change.after)bindings[change.id]={kind:'canvas',...(change.after.parent?{entryElementId:change.after.parent.entryElementId}:{})};
|
||||
return next(job,{pending,committedSourceKeys:dataArray(freeze([...job.committedSourceKeys,...units.map(unit=>unit.key)])),bindings:dataRecord(freeze({...bindings}))});
|
||||
return next(job,{pending:dataArray(freeze(pending)),committedSourceKeys:dataArray(freeze([...job.committedSourceKeys,...units.map(unit=>unit.key)])),bindings:dataRecord(freeze({...bindings}))});
|
||||
}
|
||||
function commit(units:readonly ImportUnit[],job:ImportJob,value:ImportJob){const base=current();if(base.generation!==job.generation)throw fault('stale_import_job');value=freeze(checkpointEvidence(value));const start=now(),result=ports.commit(units,value,job.generation),elapsed=Math.max(0,now()-start);if(!['applied','rejected'].includes(result?.status)||!equal(current(),result.status==='applied'?value:base))throw fault('invalid_import_commit');if(result.status==='applied')batches.record(units.length,elapsed);return result;}
|
||||
async function pump(signal:AbortSignal){
|
||||
@@ -220,7 +220,7 @@ export function createImportRunner(ports:ImportRunnerPorts){
|
||||
if(!members.every(unit=>unit.dependencies.every(key=>internal.has(key)||job.committedSourceKeys.includes(key))))continue;
|
||||
proposed=completed(job,members,job.pending.filter(value=>!internal.has(value.key)));
|
||||
result=await attempt(members,job,proposed);if(!result)return;
|
||||
if(result.status==='rejected'){const code=result.code;publish(job,{pending:job.pending.filter(value=>!internal.has(value.key)),failures:[...job.failures,...group.map(row=>({key:row.key,cursor:row.cursor,code}))]});}
|
||||
if(result.status==='rejected'){const code=result.code;publish(job,{pending:dataArray(freeze(job.pending.filter(value=>!internal.has(value.key)))),failures:[...job.failures,...group.map(row=>({key:row.key,cursor:row.cursor,code}))]});}
|
||||
await checkpoint();if(closed||signal.aborted||current().status!=='running')return;
|
||||
if(now()-frame>=budget){await(ports.yieldTask??yieldTask)();frame=now();}
|
||||
}
|
||||
@@ -256,9 +256,9 @@ export function createImportRunner(ports:ImportRunnerPorts){
|
||||
else try{
|
||||
const prepared=prepare(line,staged),key=prepared.unit.key;
|
||||
if(staged.committedSourceKeys.includes(key)||staged.pending.some(unit=>unit.key===key)||staged.failures.some(failure=>failure.key===key))throw fault('duplicate_import_source_key');
|
||||
const locator={key,cursor:line.cursor,nextCursor:line.nextCursor,dependencies:prepared.unit.dependencies,digest:await digest(prepared.unit),byteLength:new TextEncoder().encode(JSON.stringify(prepared.unit)).byteLength,changeCount:prepared.unit.changes.length};
|
||||
const locator=dataRecord(freeze({key,cursor:line.cursor,nextCursor:line.nextCursor,dependencies:dataArray(prepared.unit.dependencies),digest:await digest(prepared.unit),byteLength:new TextEncoder().encode(JSON.stringify(prepared.unit)).byteLength,changeCount:prepared.unit.changes.length}));
|
||||
cache(key,{unit:prepared.unit,digest:locator.digest,bytes:locator.byteLength});
|
||||
patch={cursor:line.nextCursor,stableIds:prepared.ids,pending:[...staged.pending,locator]};
|
||||
patch={cursor:line.nextCursor,stableIds:prepared.ids,pending:dataArray([...staged.pending,locator])};
|
||||
}catch(error){patch={cursor:line.nextCursor,failures:[...staged.failures,{key:line.key,cursor:line.cursor,code:(error as {code?:string}).code??'import_decode_failed'}]};}
|
||||
if(closed||signal.aborted||current().generation!==base.generation)return;
|
||||
staged={...staged,...patch};records++;
|
||||
|
||||
@@ -118,6 +118,11 @@ export async function sealRecoveryImageAsync(formatVersion:ImageVersion,rootRevi
|
||||
function* encode(plan:Prepared,depth=0,key=''):Generator<Promise<void>,Encoded,void>{
|
||||
if(plan===null||typeof plan!=='object')return normalize(plan,depth,key);
|
||||
if('height' in plan)return plan;
|
||||
// Preparation must capture every unsafe occurrence before yielding. Once an
|
||||
// earlier occurrence has assembled a proven immutable source, later plans
|
||||
// can reuse that fact without rebuilding the same JSON containers.
|
||||
const cached=immutable.get(plan.source);
|
||||
if(cached){if(depth+cached.height>64)throw fault('invalid_recovery_payload');return cached;}
|
||||
let height=0,bytes=2,reusable=true,properties=0;
|
||||
const array=plan.keys===undefined,result:Json[]|Record<string,Json>=array?[]:{};
|
||||
for(let index=0;index<plan.children.length;index++){
|
||||
|
||||
@@ -171,3 +171,14 @@ test('growing import tables yield when encoded while keeping captured IDs and bi
|
||||
assert.ok(yields>0);assert.deepEqual(image,sealRecoveryImage(17,1,1,job));
|
||||
assert.equal(Object.keys(image.payload.stableIds).length,300);assert.equal(Object.keys(image.payload.bindings).length,300);await f.runner.close();
|
||||
});
|
||||
|
||||
|
||||
test('waiting import locators and dependencies encode cooperatively before their endpoints arrive',async()=>{
|
||||
const {sealRecoveryImageAsync,sealRecoveryImage}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
const f=fixture(Array.from({length:300},(_,i)=>({id:'waiting-'+i,dependencies:['absent']})));await f.runner.start();
|
||||
const pending=f.root.job.pending;assert.equal(pending.length,300);assert.equal(f.root.document.elements.size,0);
|
||||
let ticks=0,yields=0;
|
||||
const image=await sealRecoveryImageAsync(17,1,1,pending,{now:()=>ticks++,frameBudgetMs:1,yieldTask:async()=>{yields++;}});
|
||||
assert.ok(yields>0);assert.deepEqual(image,sealRecoveryImage(17,1,1,pending));
|
||||
assert.deepEqual(image.payload[299].dependencies,['absent']);await f.runner.close();
|
||||
});
|
||||
|
||||
@@ -96,3 +96,19 @@ test('unknown proxies retain the synchronous codec trap order and callback count
|
||||
const actual=await sealRecoveryImageAsync(17,1,1,payload);
|
||||
assert.deepEqual(actual,expected);assert.deepEqual(events,before);
|
||||
});
|
||||
|
||||
|
||||
test('repeated owned containers reuse facts assembled earlier in the same asynchronous image',async()=>{
|
||||
const {dataArray,dataRecord}=await import('../../src/lib/canvas-core/data-containers.ts');
|
||||
const {sealRecoveryImageAsync,sealedRecoveryByteLength}=await import('../../src/lib/canvas-runtime/recovery-encoding.ts');
|
||||
const rows=dataArray(Array.from({length:1000},(_,i)=>dataRecord({index:i,text:'shared-中文😀'})));
|
||||
let tick=0,yields=0;
|
||||
const image=await sealRecoveryImageAsync(17,1,1,dataRecord({left:rows,right:rows}),{now:()=>tick++,frameBudgetMs:1,yieldTask:async()=>{yields++;}});
|
||||
assert.ok(yields>0);assert.equal(image.payload.left,image.payload.right);
|
||||
assert.equal(image.payload.left.length,1000);assert.equal(image.payload.left[999].index,999);
|
||||
assert.equal(sealedRecoveryByteLength(image),new TextEncoder().encode(JSON.stringify(image)).byteLength);
|
||||
// Unsafe descendants still need independent values for every occurrence.
|
||||
let n=0;const unsafe=dataRecord({getValue:Object.freeze({get value(){return ++n;}})});
|
||||
const captured=await sealRecoveryImageAsync(17,1,1,dataArray([unsafe,unsafe]));
|
||||
assert.deepEqual(captured.payload,[{getValue:{value:1}},{getValue:{value:2}}]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user