Schedule copy snapshot preparation by time and byte budgets

This commit is contained in:
2026-11-10 18:11:25 +08:00
parent afccb8c95c
commit 2452fd6ae9
6 changed files with 91 additions and 27 deletions
+13
View File
@@ -0,0 +1,13 @@
import {fault} from '../canvas-core/values.ts';
import {createImportBudget,type ImportScheduling} from './import-budget.ts';
/** Share import scheduling while retaining the copy cancellation/error contract. */
export function createCopyBudget(signal:AbortSignal,options:ImportScheduling={}){
if(signal.aborted)throw fault('copy_aborted');
if(options.frameBudgetMs!==undefined&&(!Number.isFinite(options.frameBudgetMs)||options.frameBudgetMs<=0))throw fault('invalid_copy_budget');
const budget=createImportBudget(signal,options);
const fail=(error:unknown):never=>{if(signal.aborted)throw fault('copy_aborted');throw error;};
return Object.freeze({step(units=1):Promise<void>|undefined{
if(signal.aborted)throw fault('copy_aborted');
try{return budget.step(units)?.catch(fail);}catch(error){return fail(error);}
}});
}
+15 -13
View File
@@ -2,7 +2,8 @@ import type {CanvasRecord,Document,ElementRecord,Id,Json,Registry,Data} from '..
import {createDocument,validateDocumentTopologySteps} from '../canvas-core/document.ts';
import {equal,fault,freeze,validJson} from '../canvas-core/values.ts';
import {createNdjsonImportSource} from './imports.ts';
const task=()=>new Promise<void>(resolve=>setTimeout(resolve,0));
import {createCopyBudget} from './copy-budget.ts';
import type {ImportScheduling} from './import-budget.ts';
/** Only ownership, endpoint and geometry fields needed to decide scope/coordinates. No component content. */
export function copyTopology(record:ElementRecord):ElementRecord{
const raw=record.data.presentation,presentation=raw&&typeof raw==='object'&&!Array.isArray(raw)?Object.fromEntries(['contentScale','unitScale'].filter(key=>Object.hasOwn(raw,key)).map(key=>[key,raw[key]])):{};
@@ -11,25 +12,25 @@ export function copyTopology(record:ElementRecord):ElementRecord{
return record.structure==='node'?{...common,structure:'node',geometry:record.geometry}:{...common,structure:'connection',source:record.source,target:record.target,path:null};
}
/** Format 2 places a compact topology/byte-locator index before the uninterpreted payload region. */
export async function encodeIndexedCopySnapshot(document:Document,signal:AbortSignal):Promise<Blob>{
const index:BlobPart[]=[],payload:BlobPart[]=[];let indexBytes=0,payloadBytes=0,count=0;
export async function encodeIndexedCopySnapshot(document:Document,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<Blob>{
const index:BlobPart[]=[],payload:BlobPart[]=[];const budget=createCopyBudget(signal,scheduling);let indexBytes=0,payloadBytes=0;
function encode(value:unknown){if(signal.aborted)throw fault('copy_aborted');const text=JSON.stringify(value)+'\n',size=new TextEncoder().encode(text).byteLength;if(size>256*1024)throw fault('record_too_large');return{text,size};}
function append(value:unknown){const part=encode(value);index.push(part.text);indexBytes+=part.size;if(indexBytes+payloadBytes>64*1024*1024)throw fault('invalid_import_source');}
function append(value:unknown){const part=encode(value);index.push(part.text);indexBytes+=part.size;if(indexBytes+payloadBytes>64*1024*1024)throw fault('invalid_import_source');return part.size;}
append({kind:'header',version:2,projectId:document.projectId});
for(const record of document.canvases.values()){append({kind:'canvas',record});if(++count%32===0)await task();}
for(const record of document.canvases.values()){const size=append({kind:'canvas',record});await budget.step(Math.max(1,Math.ceil(size/1024)));}
for(const record of document.elements.values()){
const part=encode({kind:'element',record});append({kind:'element-index',record:copyTopology(record),offset:payloadBytes,byteLength:part.size});payload.push(part.text);payloadBytes+=part.size;if(indexBytes+payloadBytes>64*1024*1024)throw fault('invalid_import_source');if(++count%32===0)await task();
const part=encode({kind:'element',record}),indexSize=append({kind:'element-index',record:copyTopology(record),offset:payloadBytes,byteLength:part.size});payload.push(part.text);payloadBytes+=part.size;if(indexBytes+payloadBytes>64*1024*1024)throw fault('invalid_import_source');await budget.step(Math.max(1,Math.ceil((part.size+indexSize)/1024)));
}
for(const record of document.tombstones){append({kind:'tombstone',record});if(++count%32===0)await task();}
for(const record of document.tombstones){const size=append({kind:'tombstone',record});await budget.step(Math.max(1,Math.ceil(size/1024)));}
append({kind:'payload',byteLength:payloadBytes});if(signal.aborted)throw fault('copy_aborted');return new Blob([...index,...payload],{type:'application/x-ndjson'});
}
export type IndexedCopySnapshot={readonly document:Document;readElement(id:Id,signal:AbortSignal):Promise<ElementRecord>};
/** Scan only the small index. Component JSON/schema checks occur just before each record enters the runner. */
export async function openIndexedCopySnapshot(bytes:Blob,projectId:Id,registry:Registry,signal:AbortSignal):Promise<IndexedCopySnapshot>{
export async function openIndexedCopySnapshot(bytes:Blob,projectId:Id,registry:Registry,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<IndexedCopySnapshot>{
if(!(bytes instanceof Blob)||bytes.size>64*1024*1024)throw fault('invalid_import_source');
const source=createNdjsonImportSource({bytes,fingerprint:'indexed-copy-snapshot'}),canvases:CanvasRecord[]=[],elements:ElementRecord[]=[],tombstones=new Map<Id,number>(),locators=new Map<Id,{offset:number;byteLength:number}>();let cursor=0,header=false,payloadOffset=-1,expectedOffset=0,count=0;
const budget=createCopyBudget(signal,scheduling),source=createNdjsonImportSource({bytes,fingerprint:'indexed-copy-snapshot'}),canvases:CanvasRecord[]=[],elements:ElementRecord[]=[],tombstones=new Map<Id,number>(),locators=new Map<Id,{offset:number;byteLength:number}>();let cursor=0,header=false,payloadOffset=-1,expectedOffset=0;
while(true){
if(signal.aborted)throw fault('copy_aborted');const line=await source.read(cursor,signal);if(!line||line.error||line.skip||!line.value)throw fault('invalid_copy_snapshot');cursor=line.nextCursor;
if(signal.aborted)throw fault('copy_aborted');const start=cursor,line=await source.read(cursor,signal);if(!line||line.error||line.skip||!line.value)throw fault('invalid_copy_snapshot');cursor=line.nextCursor;
const value=line.value as Record<string,Json>;
if(!header){if(value.kind!=='header'||value.version!==2||value.projectId!==projectId)throw fault('invalid_copy_snapshot');header=true;continue;}
if(value.kind==='payload'){if(value.byteLength!==expectedOffset||cursor+expectedOffset!==bytes.size)throw fault('invalid_copy_snapshot');payloadOffset=cursor;break;}
@@ -39,10 +40,11 @@ export async function openIndexedCopySnapshot(bytes:Blob,projectId:Id,registry:R
if(!record||!Number.isSafeInteger(offset)||offset!==expectedOffset||!Number.isSafeInteger(byteLength)||byteLength<1||byteLength>256*1024||locators.has(record.id)||!equal(copyTopology(record),record))throw fault('invalid_copy_snapshot');
locators.set(record.id,{offset,byteLength});elements.push(record);expectedOffset+=byteLength;if(expectedOffset>bytes.size)throw fault('invalid_copy_snapshot');
}else if(value.kind==='tombstone'){const row=value.record as [Id,number];if(!Array.isArray(row)||row.length!==2||tombstones.has(row[0]))throw fault('invalid_copy_snapshot');tombstones.set(row[0],row[1]);}else throw fault('invalid_copy_snapshot');
if(++count%32===0)await task();
await budget.step(Math.max(1,Math.ceil((cursor-start)/1024)));
}
const document=createDocument(projectId,canvases,elements,tombstones);let checks=0;
for(const _ of validateDocumentTopologySteps(document,registry)){if(signal.aborted)throw fault('copy_aborted');if(++checks%512===0)await task();}
const document=createDocument(projectId,canvases,elements,tombstones);
for(const _ of validateDocumentTopologySteps(document,registry))await budget.step();
if(signal.aborted)throw fault('copy_aborted');
// Cache raw byte windows, never decoded component objects. Preserve parser batching across Blob task boundaries.
const windows=new Map<number,Uint8Array>(),windowBytes=64*1024;let cachedBytes=0;
return Object.freeze({document,async readElement(id:Id,signal:AbortSignal){
+13 -11
View File
@@ -3,27 +3,29 @@ import { createDocument, validateDocumentSteps } from '../canvas-core/document.t
import { fault } from '../canvas-core/values.ts';
import { createNdjsonImportSource } from './imports.ts';
const task=()=>new Promise<void>(resolve=>setTimeout(resolve,0));
import {createCopyBudget} from './copy-budget.ts';
import type {ImportScheduling} from './import-budget.ts';
/** Capture immutable source bytes before a preparation job is admitted. No mutable live getters. */
export async function encodeCopySnapshot(document:Document,signal:AbortSignal):Promise<Blob>{
const parts:BlobPart[]=[];let bytes=0,count=0;
const append=(value:unknown)=>{if(signal.aborted)throw fault('copy_aborted');const text=JSON.stringify(value)+'\n',size=new TextEncoder().encode(text).byteLength;if(size>256*1024)throw fault('record_too_large');bytes+=size;if(bytes>64*1024*1024)throw fault('invalid_import_source');parts.push(text);};
export async function encodeCopySnapshot(document:Document,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<Blob>{
const budget=createCopyBudget(signal,scheduling),parts:BlobPart[]=[];let bytes=0;
const append=(value:unknown)=>{if(signal.aborted)throw fault('copy_aborted');const text=JSON.stringify(value)+'\n',size=new TextEncoder().encode(text).byteLength;if(size>256*1024)throw fault('record_too_large');bytes+=size;if(bytes>64*1024*1024)throw fault('invalid_import_source');parts.push(text);return size;};
append({kind:'header',version:1,projectId:document.projectId});
for(const[kind,records]of [['canvas',document.canvases.values()],['element',document.elements.values()],['tombstone',document.tombstones.entries()]] as const)for(const record of records){append({kind,record});if(++count%32===0)await task();}
for(const[kind,records]of [['canvas',document.canvases.values()],['element',document.elements.values()],['tombstone',document.tombstones.entries()]] as const)for(const record of records){const size=append({kind,record});await budget.step(Math.max(1,Math.ceil(size/1024)));}
if(signal.aborted)throw fault('copy_aborted');return new Blob(parts,{type:'application/x-ndjson'});
}
export async function decodeCopySnapshot(bytes:Blob,projectId:string,registry:Registry,signal:AbortSignal):Promise<Document>{
const source=createNdjsonImportSource({bytes,fingerprint:'captured-copy-snapshot'}),canvases:CanvasRecord[]=[],elements:ElementRecord[]=[],tombstones=new Map<string,number>();let cursor=0,header=false,count=0;
export async function decodeCopySnapshot(bytes:Blob,projectId:string,registry:Registry,signal:AbortSignal,scheduling:ImportScheduling={}):Promise<Document>{
const budget=createCopyBudget(signal,scheduling),source=createNdjsonImportSource({bytes,fingerprint:'captured-copy-snapshot'}),canvases:CanvasRecord[]=[],elements:ElementRecord[]=[],tombstones=new Map<string,number>();let cursor=0,header=false;
while(true){
if(signal.aborted)throw fault('copy_aborted');const line=await source.read(cursor,signal);if(!line)break;cursor=line.nextCursor;
if(signal.aborted)throw fault('copy_aborted');const start=cursor,line=await source.read(cursor,signal);if(!line)break;cursor=line.nextCursor;
const value=line.value as unknown as {kind:string;version?:number;projectId?:string;record:never};if(line.error||line.skip||!value)throw fault('invalid_copy_snapshot');
if(!header){if(value.kind!=='header'||value.version!==1||value.projectId!==projectId)throw fault('invalid_copy_snapshot');header=true;continue;}
if(value.kind==='canvas')canvases.push(value.record);else if(value.kind==='element')elements.push(value.record);else if(value.kind==='tombstone'){const record=value.record as [string,number];if(!Array.isArray(record)||record.length!==2||tombstones.has(record[0]))throw fault('invalid_copy_snapshot');tombstones.set(record[0],record[1]);}else throw fault('invalid_copy_snapshot');
if(++count%32===0)await task();
await budget.step(Math.max(1,Math.ceil((cursor-start)/1024)));
}
if(!header||signal.aborted)throw fault(signal.aborted?'copy_aborted':'invalid_copy_snapshot');
const document=createDocument(projectId,canvases,elements,tombstones);let checks=0;
for(const _ of validateDocumentSteps(document,registry)){if(signal.aborted)throw fault('copy_aborted');if(++checks%512===0)await task();}
const document=createDocument(projectId,canvases,elements,tombstones);
for(const _ of validateDocumentSteps(document,registry))await budget.step();
if(signal.aborted)throw fault('copy_aborted');
return document;
}
+6 -3
View File
@@ -4,22 +4,25 @@ import type { DuplicatePlan } from '../canvas-core/duplicate.ts';
import { clone, equal, fault, validJson } from '../canvas-core/values.ts';
import type { ImportJob, ImportLine, ImportUnit } from './imports.ts';
import {createCopyBudget} from './copy-budget.ts';
import type {ImportScheduling} from './import-budget.ts';
type ExternalElement = {id: Id; incarnation: number; ownerCanvasId: Id};
type ExternalCanvas = {id: Id; parent: CanvasRecord['parent']; entryIncarnation?: number};
type CopyLine = {key: string; change: Exclude<Change,{kind:'field'}>; dependencies: string[]; externalElements: ExternalElement[]; externalCanvases: ExternalCanvas[]};
/** Internal source: already mapped records, with explicit guards for every retained external reference. */
export async function encodeDuplicateSource(document: Document, registry: Registry, plan: DuplicatePlan, signal: AbortSignal): Promise<Blob> {
export async function encodeDuplicateSource(document: Document, registry: Registry, plan: DuplicatePlan, signal: AbortSignal, scheduling:ImportScheduling={}): Promise<Blob> {
const keys = new Map<Id,string>(),parts: BlobPart[] = [];
for (const [id,mapped] of Object.entries(plan.elementIds)) keys.set(mapped,`element:${id}`);
for (const [id,mapped] of Object.entries(plan.canvasIds)) keys.set(mapped,`canvas:${id}`);
let bytes=0,count=0;
const budget=createCopyBudget(signal,scheduling);let bytes=0;
for (const change of plan.changes) {
if(signal.aborted)throw fault('copy_aborted');
if(change.kind==='field'||!change.after||change.before!==null)throw fault('invalid_duplicate_plan');
const line=duplicateImportLine(document,registry,change,keys);
const text=JSON.stringify(line)+'\n',size=new TextEncoder().encode(text).byteLength;if(size>256*1024)throw fault('record_too_large');bytes+=size;if(bytes>64*1024*1024)throw fault('invalid_import_source');parts.push(text);
if(++count%32===0)await new Promise<void>(resolve=>setTimeout(resolve,0));
await budget.step(Math.max(1,Math.ceil(size/1024)));
}
if(signal.aborted)throw fault('copy_aborted');return new Blob(parts,{type:'application/x-ndjson'});
}