Slice pending import encoding and reuse completed immutable plans

This commit is contained in:
2026-11-11 04:29:11 +08:00
parent 210bc14074
commit 5eabca5056
5 changed files with 48 additions and 5 deletions
+12 -1
View File
@@ -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.
+4 -4
View File
@@ -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++){
+11
View File
@@ -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();
});
+16
View File
@@ -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}}]);
});